diff --git a/backend/src/main/java/com/zioinfo/fa/auth/JwtUtil.java b/backend/src/main/java/com/zioinfo/fa/auth/JwtUtil.java
index a30391a..a90e55c 100644
--- a/backend/src/main/java/com/zioinfo/fa/auth/JwtUtil.java
+++ b/backend/src/main/java/com/zioinfo/fa/auth/JwtUtil.java
@@ -49,6 +49,42 @@ public class JwtUtil {
}
}
+ // ── OTP 2차 인증(verify-token) — 1차 로그인 성공 후 2단계 전용 단기 토큰 ─────────────
+ // 기존 access 토큰(generateToken)과 분리(purpose=2fa) — 2단계 미완료 토큰으로 API 접근 차단.
+
+ /** 1차 통과 후 발급하는 단기 verify-token(2단계 전용). role 미포함(권한 없음). */
+ public String generateVerifyToken(String username, long validitySeconds) {
+ return Jwts.builder()
+ .subject(username)
+ .claim("purpose", "2fa")
+ .issuedAt(new Date())
+ .expiration(new Date(System.currentTimeMillis() + validitySeconds * 1000L))
+ .signWith(getKey())
+ .compact();
+ }
+
+ /** 이 토큰이 verify-token(purpose=2fa)인지 여부. access 토큰 검증 시 거부 용도. */
+ public boolean isVerifyToken(String token) {
+ try {
+ return "2fa".equals(getClaims(token).get("purpose", String.class));
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ /** verify-token 이면 subject(username) 반환, 아니면/무효면 null. 서명·만료 검증 포함. */
+ public String parseVerifyTokenUsername(String token) {
+ try {
+ Claims c = getClaims(token);
+ if (!"2fa".equals(c.get("purpose", String.class))) {
+ return null;
+ }
+ return c.getSubject();
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
private Claims getClaims(String token) {
return Jwts.parser()
.verifyWith(getKey())
diff --git a/backend/src/main/java/com/zioinfo/fa/auth/OtpAuthService.java b/backend/src/main/java/com/zioinfo/fa/auth/OtpAuthService.java
new file mode 100644
index 0000000..dae6867
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/fa/auth/OtpAuthService.java
@@ -0,0 +1,187 @@
+package com.zioinfo.fa.auth;
+
+import com.zioinfo.fa.auth.dto.OtpSetupResponse;
+import com.zioinfo.fa.common.audit.AuditService;
+import com.zioinfo.fa.domain.FaUser;
+import com.zioinfo.fa.mapper.FaUserMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * TOTP(OTP 2차 인증) 오케스트레이션 레이어 — UIMS AuthService 의 OTP 경로 미러(FA 경량화). [GUARDiA-FA]
+ *
+ * 흐름:
+ *
+ * - 1차 로그인 성공 → {@link #beginOtp}: verify-token 발급 + (미등록이면 보류 시크릿+QR) 반환.
+ * - {@code POST /verify-otp}(verifyToken+code) → {@link #verifyOtp}: 6자리 검증 후 access 발급.
+ * 최초 로그인이면 등록 확정(otp_enabled=true).
+ * - 마이페이지: {@link #setup}/{@link #confirm}/{@link #disable} · 관리자 초기화: {@link #adminReset}.
+ *
+ *
+ * 보안 불변: 시크릿·QR·otpauth URI 는 setup/OTP_SETUP 응답에서만 노출. 로그·감사에 시크릿/코드 미기록.
+ * 기존 auth(JWT·RBAC) 엔진 교체 없음 — TOTP·잠금 레이어만 추가.
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class OtpAuthService {
+
+ private final FaUserMapper userMapper;
+ private final JwtUtil jwtUtil;
+ private final TotpService totpService;
+ private final AuditService auditService;
+
+ /** 로그인 2단계에 OTP 경로를 사용할지(기본 on — 인증 강화). */
+ @Value("${fa.auth.otp-enabled:true}")
+ private boolean otpEnabled;
+
+ /** verify-token 유효기간(초). */
+ @Value("${fa.auth.verify-token-validity-seconds:300}")
+ private long verifyTokenValiditySeconds;
+
+ /** 로그인/OTP 연속 실패 임계(도달 시 잠금). */
+ @Value("${fa.auth.max-login-fail:5}")
+ private int maxLoginFail;
+
+ public boolean isEnabled() {
+ return otpEnabled;
+ }
+
+ public int getMaxLoginFail() {
+ return maxLoginFail;
+ }
+
+ public boolean isLocked(FaUser user) {
+ return user != null && Boolean.TRUE.equals(user.getLocked());
+ }
+
+ private static boolean blank(String s) {
+ return s == null || s.isBlank();
+ }
+
+ /**
+ * 1차 로그인 성공 후 OTP 2단계 시작.
+ * @return 미등록: { twofa:true, verifyToken, verifyMethod:OTP_SETUP, secret, otpAuthUri, qrImage }
+ * 등록됨: { twofa:true, verifyToken, verifyMethod:OTP }
+ */
+ @Transactional
+ public Map beginOtp(FaUser user) {
+ String verifyToken = jwtUtil.generateVerifyToken(user.getUsername(), verifyTokenValiditySeconds);
+ userMapper.resetLoginFail(user.getUsername()); // 1차 성공 → 실패카운트 회복
+
+ Map resp = new LinkedHashMap<>();
+ resp.put("twofa", "true");
+ resp.put("verifyToken", verifyToken);
+
+ if (blank(user.getOtpSecret())) {
+ // 미등록 최초 로그인 — 보류 시크릿 발급 + QR(이 응답에서만 노출)
+ String secret = totpService.generateSecret();
+ userMapper.updateOtpSecret(user.getUsername(), secret);
+ resp.put("verifyMethod", "OTP_SETUP");
+ resp.put("secret", secret);
+ resp.put("otpAuthUri", totpService.otpAuthUri(secret, user.getUsername()));
+ resp.put("qrImage", totpService.qrImageDataUri(secret, user.getUsername()));
+ } else {
+ resp.put("verifyMethod", "OTP");
+ }
+ return resp;
+ }
+
+ /**
+ * 2단계 검증: verify-token + 6자리 코드 → access 발급. 최초 로그인이면 등록 확정.
+ * @return { twofa:false, token, type, username, role, workstationCode }
+ */
+ @Transactional
+ public Map verifyOtp(String verifyToken, String code) {
+ String username = jwtUtil.parseVerifyTokenUsername(verifyToken);
+ if (username == null) {
+ throw new RuntimeException("ERR-AUTH-VERIFY-TOKEN: 인증 토큰이 유효하지 않거나 만료되었습니다.");
+ }
+ FaUser user = userMapper.findByUsername(username);
+ if (user == null) {
+ throw new RuntimeException("ERR-AUTH-VERIFY-TOKEN: 인증 토큰이 유효하지 않습니다.");
+ }
+ if (Boolean.TRUE.equals(user.getLocked())) {
+ throw new RuntimeException("ERR-AUTH-LOCKED: 계정이 잠금 상태입니다. 관리자에게 문의하세요.");
+ }
+ if (!totpService.verify(user.getOtpSecret(), code)) {
+ // OTP 오입력도 실패 카운트에 합산 — 임계 도달 시 잠금.
+ userMapper.incrementLoginFail(username, maxLoginFail);
+ throw new RuntimeException("ERR-AUTH-OTP: 인증 코드가 올바르지 않습니다.");
+ }
+
+ // 최초 로그인(보류 시크릿) → 등록 확정(멱등)
+ if (!Boolean.TRUE.equals(user.getOtpEnabled())) {
+ userMapper.enableOtp(username);
+ auditService.log(username, "OTP_ENROLL", username, "최초 로그인 OTP 등록 확정");
+ }
+ userMapper.resetLoginFail(username);
+
+ String token = jwtUtil.generateToken(user.getUsername(), user.getRole());
+ Map resp = new LinkedHashMap<>();
+ resp.put("twofa", "false");
+ resp.put("token", token);
+ resp.put("type", "Bearer");
+ resp.put("username", user.getUsername());
+ resp.put("role", user.getRole());
+ resp.put("workstationCode", user.getWorkstationCode());
+ return resp;
+ }
+
+ /** 마이페이지 OTP 등록/재설정 시작: 새 보류 시크릿 발급(기존 시크릿 무효화) + QR 반환. */
+ @Transactional
+ public OtpSetupResponse setup(String username) {
+ FaUser user = userMapper.findByUsername(username);
+ if (user == null) {
+ throw new RuntimeException("ERR-AUTH-USER: 사용자를 찾을 수 없습니다.");
+ }
+ String secret = totpService.generateSecret();
+ userMapper.updateOtpSecret(username, secret); // 확정 전엔 otp_enabled 유지(confirm 에서 재확정)
+ auditService.log(username, "OTP_SETUP", username, "OTP 등록/재설정 시작(새 시크릿 발급)");
+ return new OtpSetupResponse(
+ secret,
+ totpService.otpAuthUri(secret, username),
+ totpService.qrImageDataUri(secret, username));
+ }
+
+ /** 마이페이지 OTP 확인·활성화: 앱 코드 검증 성공 시 otp_enabled=true. */
+ @Transactional
+ public void confirm(String username, String code) {
+ FaUser user = userMapper.findByUsername(username);
+ if (user == null) {
+ throw new RuntimeException("ERR-AUTH-USER: 사용자를 찾을 수 없습니다.");
+ }
+ if (blank(user.getOtpSecret())) {
+ throw new RuntimeException("ERR-AUTH-OTP: 등록된 OTP 시크릿이 없습니다. 먼저 등록을 시작하세요.");
+ }
+ if (!totpService.verify(user.getOtpSecret(), code)) {
+ throw new RuntimeException("ERR-AUTH-OTP: 인증 코드가 올바르지 않습니다.");
+ }
+ userMapper.enableOtp(username);
+ auditService.log(username, "OTP_ENABLE", username, "OTP 2차 인증 활성화");
+ }
+
+ /** 마이페이지 OTP 해제: 시크릿 폐기 + otp_enabled=false. */
+ @Transactional
+ public void disable(String username) {
+ FaUser user = userMapper.findByUsername(username);
+ if (user == null) {
+ throw new RuntimeException("ERR-AUTH-USER: 사용자를 찾을 수 없습니다.");
+ }
+ userMapper.disableOtp(username);
+ auditService.log(username, "OTP_DISABLE", username, "OTP 2차 인증 해제");
+ }
+
+ /** 관리자 OTP 초기화: 시크릿 폐기 + otp_enabled=false → 다음 로그인 시 QR 재등록 유도. */
+ @Transactional
+ public void adminReset(String username) {
+ userMapper.disableOtp(username);
+ auditService.log("OTP_RESET", username, "관리자 OTP 초기화(재등록 유도)");
+ }
+}
diff --git a/backend/src/main/java/com/zioinfo/fa/auth/TotpService.java b/backend/src/main/java/com/zioinfo/fa/auth/TotpService.java
new file mode 100644
index 0000000..47a6df9
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/fa/auth/TotpService.java
@@ -0,0 +1,81 @@
+package com.zioinfo.fa.auth;
+
+import dev.samstevens.totp.code.CodeGenerator;
+import dev.samstevens.totp.code.CodeVerifier;
+import dev.samstevens.totp.code.DefaultCodeGenerator;
+import dev.samstevens.totp.code.DefaultCodeVerifier;
+import dev.samstevens.totp.code.HashingAlgorithm;
+import dev.samstevens.totp.exceptions.QrGenerationException;
+import dev.samstevens.totp.qr.QrData;
+import dev.samstevens.totp.qr.QrGenerator;
+import dev.samstevens.totp.qr.ZxingPngQrGenerator;
+import dev.samstevens.totp.secret.DefaultSecretGenerator;
+import dev.samstevens.totp.secret.SecretGenerator;
+import dev.samstevens.totp.time.SystemTimeProvider;
+import dev.samstevens.totp.time.TimeProvider;
+import dev.samstevens.totp.util.Utils;
+import org.springframework.stereotype.Service;
+
+/**
+ * TOTP(RFC 6238, HMAC-SHA1, 30s, 6자리, ±1 윈도우) 코덱 — UIMS TotpService 미러. [GUARDiA-FA]
+ * 시크릿(otp_secret)은 fa_users 에 저장. issuer 만 GUARDiA-FA 로 교체.
+ *
+ * 보안 불변: 시크릿·otpauth URI·QR 은 등록 순간 발급 응답에서만 노출. 로그 기록 금지.
+ */
+@Service
+public class TotpService {
+
+ private final TimeProvider timeProvider = new SystemTimeProvider();
+ private final CodeGenerator codeGenerator = new DefaultCodeGenerator(HashingAlgorithm.SHA1, 6);
+ private final SecretGenerator secretGenerator = new DefaultSecretGenerator();
+ private final CodeVerifier codeVerifier = buildVerifier();
+ private final QrGenerator qrGenerator = new ZxingPngQrGenerator();
+
+ private static final String ISSUER = "GUARDiA-FA";
+
+ private CodeVerifier buildVerifier() {
+ DefaultCodeVerifier verifier = new DefaultCodeVerifier(codeGenerator, timeProvider);
+ verifier.setTimePeriod(30);
+ verifier.setAllowedTimePeriodDiscrepancy(1); // ±1 윈도우 허용(시계 오차)
+ return verifier;
+ }
+
+ /** 신규 OTP 시크릿 생성(최초 로그인/마이페이지 OTP 등록용). */
+ public String generateSecret() {
+ return secretGenerator.generate();
+ }
+
+ /** 주어진 시크릿에 대해 사용자 입력 코드가 유효한지 검증. */
+ public boolean verify(String secret, String code) {
+ if (secret == null || secret.isBlank() || code == null || code.isBlank()) {
+ return false;
+ }
+ return codeVerifier.isValidCode(secret, code.trim());
+ }
+
+ /** Authenticator 앱 직접 등록용 otpauth:// URI. */
+ public String otpAuthUri(String secret, String userId) {
+ return buildQrData(secret, userId).getUri();
+ }
+
+ /** QR 이미지(data:image/png;base64,...) — 앱으로 스캔해 등록. */
+ public String qrImageDataUri(String secret, String userId) {
+ try {
+ byte[] image = qrGenerator.generate(buildQrData(secret, userId));
+ return Utils.getDataUriForImage(image, qrGenerator.getImageMimeType());
+ } catch (QrGenerationException e) {
+ throw new IllegalStateException("OTP QR 생성 실패", e);
+ }
+ }
+
+ private QrData buildQrData(String secret, String userId) {
+ return new QrData.Builder()
+ .label(userId)
+ .secret(secret)
+ .issuer(ISSUER)
+ .algorithm(HashingAlgorithm.SHA1)
+ .digits(6)
+ .period(30)
+ .build();
+ }
+}
diff --git a/backend/src/main/java/com/zioinfo/fa/auth/dto/ChangePasswordRequest.java b/backend/src/main/java/com/zioinfo/fa/auth/dto/ChangePasswordRequest.java
new file mode 100644
index 0000000..373b2f1
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/fa/auth/dto/ChangePasswordRequest.java
@@ -0,0 +1,5 @@
+package com.zioinfo.fa.auth.dto;
+
+/** 마이페이지 비밀번호 변경 요청 — { currentPassword, newPassword }. 평문은 응답/로그 미기록. [GUARDiA-FA] */
+public record ChangePasswordRequest(String currentPassword, String newPassword) {
+}
diff --git a/backend/src/main/java/com/zioinfo/fa/auth/dto/OtpConfirmRequest.java b/backend/src/main/java/com/zioinfo/fa/auth/dto/OtpConfirmRequest.java
new file mode 100644
index 0000000..0176a4d
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/fa/auth/dto/OtpConfirmRequest.java
@@ -0,0 +1,5 @@
+package com.zioinfo.fa.auth.dto;
+
+/** Authenticator(TOTP) 등록 확인 요청 — 앱에 표시된 6자리 코드. [GUARDiA-FA] */
+public record OtpConfirmRequest(String code) {
+}
diff --git a/backend/src/main/java/com/zioinfo/fa/auth/dto/OtpSetupResponse.java b/backend/src/main/java/com/zioinfo/fa/auth/dto/OtpSetupResponse.java
new file mode 100644
index 0000000..a3aac70
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/fa/auth/dto/OtpSetupResponse.java
@@ -0,0 +1,13 @@
+package com.zioinfo.fa.auth.dto;
+
+/**
+ * Authenticator(TOTP) 등록 셋업 응답 — UIMS/OCR OtpSetupResponse 미러. [GUARDiA-FA]
+ *
+ * - secret : Base32 TOTP 시크릿(수동 입력용)
+ * - otpAuthUri : otpauth://totp/... (Authenticator 앱 직접 등록용 URI)
+ * - qrImage : data:image/png;base64,... (QR 이미지, <img src> 로 표시)
+ *
+ * 보안 불변: 이 응답(등록 순간)에서만 시크릿/QR 노출. 조회/목록/재조회 응답에 재노출 금지·로그 미기록.
+ */
+public record OtpSetupResponse(String secret, String otpAuthUri, String qrImage) {
+}
diff --git a/backend/src/main/java/com/zioinfo/fa/auth/dto/OtpVerifyRequest.java b/backend/src/main/java/com/zioinfo/fa/auth/dto/OtpVerifyRequest.java
new file mode 100644
index 0000000..7245a4a
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/fa/auth/dto/OtpVerifyRequest.java
@@ -0,0 +1,5 @@
+package com.zioinfo.fa.auth.dto;
+
+/** 로그인 2단계 TOTP 검증 요청 — { verifyToken, code }. [GUARDiA-FA] */
+public record OtpVerifyRequest(String verifyToken, String code) {
+}
diff --git a/backend/src/main/java/com/zioinfo/fa/config/AdminPasswordSeeder.java b/backend/src/main/java/com/zioinfo/fa/config/AdminPasswordSeeder.java
new file mode 100644
index 0000000..36ca62c
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/fa/config/AdminPasswordSeeder.java
@@ -0,0 +1,131 @@
+package com.zioinfo.fa.config;
+
+import com.zioinfo.fa.mapper.FaUserMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.ApplicationArguments;
+import org.springframework.boot.ApplicationRunner;
+import org.springframework.core.annotation.Order;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Component;
+
+import javax.crypto.Cipher;
+import javax.crypto.spec.GCMParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.Base64;
+
+/**
+ * admin 비밀번호 암호화 재시드(UIMS 방식 + 암호화 저장). [GUARDiA-FA]
+ *
+ * 기동 시 멱등 재시드:
+ *
+ * ADMIN_KEY_FILE(hex 32바이트 키 파일) 로드 → ADMIN_PASSWORD_ENC(base64(nonce12 + ct + tag), AES-256-GCM) 복호
+ * → admin 계정 BCrypt 해시 갱신(하드코딩 admin123 시드를 env 값으로 덮어씀).
+ *
+ *
+ * 안전 규칙:
+ *
+ * - env 미설정 / 키 로드 실패 / 복호 실패 시 재시드 스킵(기동 계속). WARN 로그에 값(평문/키/시크릿) 미기록.
+ * - 새 admin 비밀번호 값은 서버 env 에만 존재 — 코드/DB/로그/응답에 평문 미기재(보안 불변규칙).
+ * - 키 값은 별도 파일(root 600)에만 — env/코드/git 에 키 미기재. 여기서는 파일 경로만 읽는다.
+ *
+ *
+ * 스키마 init(schema.sql: admin/admin123 시드) 이후 실행되어 기존 해시를 덮어쓴다(멱등: 매 기동 동일 결과).
+ * env 는 서버 {@code /opt/guardia-fa/guardia-ai.env}(systemd EnvironmentFile)로만 주입.
+ */
+@Slf4j
+@Component
+@Order(Integer.MIN_VALUE + 10)
+@RequiredArgsConstructor
+public class AdminPasswordSeeder implements ApplicationRunner {
+
+ private static final String ADMIN_USERNAME = "admin";
+ private static final int GCM_TAG_BITS = 128;
+ private static final int NONCE_LEN = 12;
+
+ @Value("${ADMIN_PASSWORD_ENC:}")
+ private String adminPasswordEnc;
+
+ @Value("${ADMIN_KEY_FILE:}")
+ private String adminKeyFile;
+
+ private final FaUserMapper userMapper;
+ private final PasswordEncoder passwordEncoder;
+
+ @Override
+ public void run(ApplicationArguments args) {
+ if (isBlank(adminPasswordEnc) || isBlank(adminKeyFile)) {
+ log.info("[admin-reseed] ADMIN_PASSWORD_ENC/ADMIN_KEY_FILE 미설정 — 재시드 스킵(기동 계속)");
+ return;
+ }
+ char[] plain = null;
+ byte[] key = null;
+ try {
+ key = loadHexKey(adminKeyFile); // hex 32바이트 → 32B (AES-256)
+ plain = decrypt(adminPasswordEnc, key); // base64(nonce12+ct+tag) → 평문
+ String hash = passwordEncoder.encode(new String(plain));
+ int updated = userMapper.updatePasswordByUsername(ADMIN_USERNAME, hash);
+ if (updated > 0) {
+ log.info("[admin-reseed] admin 비밀번호 env 값으로 재시드 완료(멱등)");
+ } else {
+ log.warn("[admin-reseed] admin 계정 미존재 — 재시드 스킵");
+ }
+ } catch (Exception e) {
+ // 값 미기록: 예외 클래스명만(메시지에 평문/키가 섞일 여지 차단)
+ log.warn("[admin-reseed] 복호/재시드 실패 — 스킵(기동 계속). cause={}", e.getClass().getSimpleName());
+ } finally {
+ if (plain != null) Arrays.fill(plain, '\0');
+ if (key != null) Arrays.fill(key, (byte) 0);
+ }
+ }
+
+ /** 키 파일(hex 문자열, 공백/개행 무시)을 32바이트 키로 로드. 32바이트가 아니면 예외. */
+ private static byte[] loadHexKey(String path) throws Exception {
+ String hex = Files.readString(Path.of(path), StandardCharsets.UTF_8)
+ .replaceAll("\\s", "");
+ byte[] key = hexToBytes(hex);
+ if (key.length != 32) {
+ throw new IllegalStateException("key length != 32 bytes");
+ }
+ return key;
+ }
+
+ /** base64(nonce12 + ciphertext + tag) → AES-256-GCM 복호 평문(char[]). */
+ private static char[] decrypt(String encBase64, byte[] key) throws Exception {
+ byte[] blob = Base64.getDecoder().decode(encBase64.trim());
+ if (blob.length <= NONCE_LEN) {
+ throw new IllegalStateException("cipher blob too short");
+ }
+ byte[] nonce = Arrays.copyOfRange(blob, 0, NONCE_LEN);
+ byte[] ct = Arrays.copyOfRange(blob, NONCE_LEN, blob.length);
+ Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
+ cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"),
+ new GCMParameterSpec(GCM_TAG_BITS, nonce));
+ byte[] out = cipher.doFinal(ct);
+ char[] chars = new String(out, StandardCharsets.UTF_8).toCharArray();
+ Arrays.fill(out, (byte) 0);
+ return chars;
+ }
+
+ private static byte[] hexToBytes(String hex) {
+ int len = hex.length();
+ if (len % 2 != 0) {
+ throw new IllegalStateException("odd hex length");
+ }
+ byte[] out = new byte[len / 2];
+ for (int i = 0; i < len; i += 2) {
+ out[i / 2] = (byte) ((Character.digit(hex.charAt(i), 16) << 4)
+ + Character.digit(hex.charAt(i + 1), 16));
+ }
+ return out;
+ }
+
+ private static boolean isBlank(String s) {
+ return s == null || s.isBlank();
+ }
+}
diff --git a/backend/src/main/java/com/zioinfo/fa/controller/AdminUserController.java b/backend/src/main/java/com/zioinfo/fa/controller/AdminUserController.java
new file mode 100644
index 0000000..decb649
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/fa/controller/AdminUserController.java
@@ -0,0 +1,61 @@
+package com.zioinfo.fa.controller;
+
+import com.zioinfo.fa.auth.OtpAuthService;
+import com.zioinfo.fa.common.ApiResponse;
+import com.zioinfo.fa.common.audit.AuditService;
+import com.zioinfo.fa.domain.FaUser;
+import com.zioinfo.fa.mapper.FaUserMapper;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 관리자 사용자 관리 — OTP 초기화 / 계정 잠금 해제. [GUARDiA-FA]
+ *
+ *
SecurityConfig {@code /api/admin/** hasRole(ADMIN)} 게이트(별도 설정 변경 불요).
+ * verify-token 은 role 미포함 → ROLE_ADMIN 부재로 자동 차단. 응답에 otp_secret/비번 해시 미노출.
+ */
+@RestController
+@RequestMapping("/api/admin/users")
+public class AdminUserController {
+
+ private final FaUserMapper userMapper;
+ private final OtpAuthService otpAuthService;
+ private final AuditService auditService;
+
+ public AdminUserController(FaUserMapper userMapper, OtpAuthService otpAuthService, AuditService auditService) {
+ this.userMapper = userMapper;
+ this.otpAuthService = otpAuthService;
+ this.auditService = auditService;
+ }
+
+ /** 사용자 목록(관리 화면). findAll 은 otp_secret/password_hash 를 SELECT 하지 않는다(미노출). */
+ @GetMapping
+ public ApiResponse> list() {
+ return ApiResponse.ok(userMapper.findAll());
+ }
+
+ /** 관리자 OTP 초기화 — OTP_SECRET NULL → 다음 로그인 시 QR 재등록 유도. */
+ @PostMapping("/{id}/otp-reset")
+ public ApiResponse