step1 = twoFactorService.beginTwoFactor(user);
return Map.of(
@@ -178,6 +187,27 @@ public class AuthService {
return new AuthHelperResult(true, okMsg);
}
+ /**
+ * 마이페이지 비밀번호 변경(현재 비밀번호 검증 + BCrypt 저장). UIWS changePassword 미러.
+ * 현재 비번 불일치 → PASSWORD_MISMATCH, 기존과 동일 → PASSWORD_SAME_AS_OLD.
+ * 새 비밀번호 평문은 로그/응답/감사에 절대 기록하지 않는다(보안 불변규칙).
+ */
+ @Transactional
+ public void changePassword(String username, ChangePasswordRequest req) {
+ CmsUser user = userMapper.findByUsername(username);
+ if (user == null) {
+ throw new UiwsApiException(UiwsErrorCode.USER_NOT_FOUND);
+ }
+ if (!passwordEncoder.matches(req.currentPassword(), user.getPasswordHash())) {
+ throw new UiwsApiException(UiwsErrorCode.PASSWORD_MISMATCH);
+ }
+ if (passwordEncoder.matches(req.newPassword(), user.getPasswordHash())) {
+ throw new UiwsApiException(UiwsErrorCode.PASSWORD_SAME_AS_OLD);
+ }
+ userMapper.updatePasswordHash(username, passwordEncoder.encode(req.newPassword()));
+ auditService.log(username, "PASSWORD_CHANGE", username, "본인 비밀번호 변경");
+ }
+
private static String generateTempPassword() {
StringBuilder sb = new StringBuilder(10);
for (int i = 0; i < 10; i++) {
diff --git a/backend/src/main/java/com/zioinfo/cms/auth/CmsUser.java b/backend/src/main/java/com/zioinfo/cms/auth/CmsUser.java
index 44035e8..36a1c87 100644
--- a/backend/src/main/java/com/zioinfo/cms/auth/CmsUser.java
+++ b/backend/src/main/java/com/zioinfo/cms/auth/CmsUser.java
@@ -30,8 +30,10 @@ public class CmsUser {
private Integer loginFailCount;
/** 계정 잠금 여부(기본 false). */
private Boolean locked;
- /** TOTP 시크릿(UIWS OTP 경로 대비, 현재 이메일 흐름에서는 미사용). */
+ /** TOTP 시크릿(UIWS OTP 경로). 등록 확정 전 보류 시크릿도 여기 저장. API 응답에 절대 미포함. */
private String otpSecret;
+ /** OTP 등록 확정 여부(기본 false). 최초 로그인 verify 성공 시 true 로 확정(멱등). */
+ private Boolean otpEnabled;
// ── 로그인 보조 이식(회원가입 승인 게이트) cms_user.approved ───────────────
/** 회원가입 승인 여부(기본 true). 신규 가입자는 false → SUPERADMIN 승인 전 로그인 차단. */
diff --git a/backend/src/main/java/com/zioinfo/cms/auth/dto/ChangePasswordRequest.java b/backend/src/main/java/com/zioinfo/cms/auth/dto/ChangePasswordRequest.java
new file mode 100644
index 0000000..22ced25
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/cms/auth/dto/ChangePasswordRequest.java
@@ -0,0 +1,12 @@
+package com.zioinfo.cms.auth.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+
+/** 마이페이지 비밀번호 변경 요청 — 현재 비밀번호 검증 + 새 비밀번호(BCrypt 저장). */
+public record ChangePasswordRequest(
+ @NotBlank(message = "현재 비밀번호는 필수입니다.") String currentPassword,
+ @NotBlank(message = "새 비밀번호는 필수입니다.")
+ @Size(min = 8, message = "새 비밀번호는 최소 8자입니다.") String newPassword
+) {
+}
diff --git a/backend/src/main/java/com/zioinfo/cms/auth/dto/OtpConfirmRequest.java b/backend/src/main/java/com/zioinfo/cms/auth/dto/OtpConfirmRequest.java
new file mode 100644
index 0000000..7c016ae
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/cms/auth/dto/OtpConfirmRequest.java
@@ -0,0 +1,7 @@
+package com.zioinfo.cms.auth.dto;
+
+import jakarta.validation.constraints.NotBlank;
+
+/** Authenticator(TOTP) 등록 확인 요청 — 앱에 표시된 6자리 코드. */
+public record OtpConfirmRequest(@NotBlank String code) {
+}
diff --git a/backend/src/main/java/com/zioinfo/cms/auth/dto/OtpSetupResponse.java b/backend/src/main/java/com/zioinfo/cms/auth/dto/OtpSetupResponse.java
new file mode 100644
index 0000000..dc12abb
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/cms/auth/dto/OtpSetupResponse.java
@@ -0,0 +1,13 @@
+package com.zioinfo.cms.auth.dto;
+
+/**
+ * Authenticator(TOTP) 등록 셋업 응답 — UIWS OtpSetupResponse 미러.
+ *
+ * - 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/cms/auth/dto/OtpVerifyRequest.java b/backend/src/main/java/com/zioinfo/cms/auth/dto/OtpVerifyRequest.java
new file mode 100644
index 0000000..0f11151
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/cms/auth/dto/OtpVerifyRequest.java
@@ -0,0 +1,10 @@
+package com.zioinfo.cms.auth.dto;
+
+import jakarta.validation.constraints.NotBlank;
+
+/** 로그인 2단계 TOTP 검증 요청 — { verifyToken, code }. */
+public record OtpVerifyRequest(
+ @NotBlank(message = "verifyToken은 필수입니다.") String verifyToken,
+ @NotBlank(message = "code는 필수입니다.") String code
+) {
+}
diff --git a/backend/src/main/java/com/zioinfo/cms/auth/mapper/UserMapper.java b/backend/src/main/java/com/zioinfo/cms/auth/mapper/UserMapper.java
index 2891b26..f5e1873 100644
--- a/backend/src/main/java/com/zioinfo/cms/auth/mapper/UserMapper.java
+++ b/backend/src/main/java/com/zioinfo/cms/auth/mapper/UserMapper.java
@@ -3,6 +3,7 @@ package com.zioinfo.cms.auth.mapper;
import com.zioinfo.cms.auth.CmsUser;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Update;
import java.time.LocalDateTime;
@@ -57,4 +58,18 @@ public interface UserMapper {
/** 임시 비밀번호 적용 + 잠금/실패카운트 해제(초기화 시). */
int updatePasswordHash(@Param("username") String username,
@Param("passwordHash") String passwordHash);
+
+ // ── TOTP(OTP 2차 인증) 이식 (멱등 UPDATE, @Update — XML 미정의 신규 메서드) ─────────
+
+ /** 보류 시크릿 저장(등록 확정 전). otp_enabled 은 건드리지 않는다(verify/confirm 에서 확정). */
+ @Update("UPDATE cms_user SET otp_secret = #{secret} WHERE username = #{username}")
+ int updateOtpSecret(@Param("username") String username, @Param("secret") String secret);
+
+ /** 등록 확정: otp_enabled=true (시크릿은 유지). */
+ @Update("UPDATE cms_user SET otp_enabled = true WHERE username = #{username}")
+ int enableOtp(@Param("username") String username);
+
+ /** 해제/초기화: 시크릿 폐기 + otp_enabled=false. (마이페이지 해제) */
+ @Update("UPDATE cms_user SET otp_secret = NULL, otp_enabled = false WHERE username = #{username}")
+ int disableOtp(@Param("username") String username);
}
diff --git a/backend/src/main/java/com/zioinfo/cms/config/AdminPasswordSeeder.java b/backend/src/main/java/com/zioinfo/cms/config/AdminPasswordSeeder.java
new file mode 100644
index 0000000..e58034c
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/cms/config/AdminPasswordSeeder.java
@@ -0,0 +1,130 @@
+package com.zioinfo.cms.config;
+
+import com.zioinfo.cms.auth.mapper.UserMapper;
+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 방식 + 암호화 저장).
+ *
+ * 기동 시 멱등 재시드:
+ *
+ * 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 시드) 이후 실행되어 기존 해시를 덮어쓴다(멱등: 매 기동 동일 결과).
+ */
+@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 UserMapper 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.updatePasswordHash(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/cms/uiws/auth/OtpAuthService.java b/backend/src/main/java/com/zioinfo/cms/uiws/auth/OtpAuthService.java
new file mode 100644
index 0000000..e7c604b
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/cms/uiws/auth/OtpAuthService.java
@@ -0,0 +1,167 @@
+package com.zioinfo.cms.uiws.auth;
+
+import com.zioinfo.cms.admin.AuditService;
+import com.zioinfo.cms.auth.CmsUser;
+import com.zioinfo.cms.auth.JwtUtil;
+import com.zioinfo.cms.auth.dto.OtpSetupResponse;
+import com.zioinfo.cms.auth.mapper.UserMapper;
+import com.zioinfo.cms.uiws.common.UiwsApiException;
+import com.zioinfo.cms.uiws.common.UiwsErrorCode;
+import com.zioinfo.cms.uiws.config.UiwsProperties;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * TOTP(OTP 2차 인증) 오케스트레이션 레이어 — UIWS AuthService 의 OTP 경로 미러.
+ *
+ *
흐름:
+ *
+ * - 1차 로그인 성공 → {@link #beginOtp}: verify-token 발급 + (미등록이면 보류 시크릿+QR) 반환.
+ * - {@code POST /verify-otp}(verifyToken+code) → {@link #verifyOtp}: 6자리 검증 후 access/refresh 발급.
+ * 최초 로그인이면 등록 확정(otp_enabled=true).
+ * - 마이페이지: {@link #setup}/{@link #confirm}/{@link #disable}.
+ *
+ *
+ * 보안 불변: 시크릿·QR·otpauth URI 는 setup/OTP_SETUP 응답에서만 노출. 로그·감사에 시크릿/코드 미기록.
+ * 기존 auth(JWT·RBAC) 엔진 교체 없음 — TOTP 레이어만 추가.
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class OtpAuthService {
+
+ private final UserMapper userMapper;
+ private final JwtUtil jwtUtil;
+ private final TotpService totpService;
+ private final UiwsProperties properties;
+ private final AuditService auditService;
+
+ /** 로그인 2단계에 OTP 경로를 사용할지. */
+ public boolean isEnabled() {
+ return properties.getAuth().isOtpEnabled();
+ }
+
+ 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(CmsUser user) {
+ long tokenValidity = properties.getAuth().getVerifyTokenValiditySeconds();
+ String verifyToken = jwtUtil.generateVerifyToken(user.getUsername(), tokenValidity);
+ // 성공 시 실패카운트 초기화(잠금 회복)
+ userMapper.resetLoginFail(user.getUsername());
+
+ 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/refresh 발급. 최초 로그인이면 등록 확정.
+ * @return { token, refreshToken, type, username, role }
+ */
+ @Transactional
+ public Map verifyOtp(String verifyToken, String code) {
+ String username = jwtUtil.parseVerifyTokenUsername(verifyToken);
+ if (username == null) {
+ throw new UiwsApiException(UiwsErrorCode.VERIFY_TOKEN_INVALID);
+ }
+ CmsUser user = userMapper.findByUsername(username);
+ if (user == null) {
+ throw new UiwsApiException(UiwsErrorCode.VERIFY_TOKEN_INVALID);
+ }
+ if (Boolean.TRUE.equals(user.getLocked())) {
+ throw new UiwsApiException(UiwsErrorCode.ACCOUNT_LOCKED);
+ }
+ if (!totpService.verify(user.getOtpSecret(), code)) {
+ // OTP 오입력도 실패 카운트에 합산(정책) — 임계 도달 시 잠금.
+ userMapper.incrementLoginFail(username, properties.getAuth().getMaxLoginFail());
+ throw new UiwsApiException(UiwsErrorCode.VERIFY_CODE_INVALID);
+ }
+
+ // 최초 로그인(보류 시크릿) → 등록 확정(멱등)
+ if (!Boolean.TRUE.equals(user.getOtpEnabled())) {
+ userMapper.enableOtp(username);
+ auditService.log(username, "OTP_ENROLL", username, "최초 로그인 OTP 등록 확정");
+ }
+ userMapper.resetLoginFail(username);
+
+ String access = jwtUtil.generate(user.getUsername(), user.getRole());
+ String refresh = jwtUtil.generate(user.getUsername(), user.getRole());
+ Map resp = new LinkedHashMap<>();
+ resp.put("token", access);
+ resp.put("refreshToken", refresh);
+ resp.put("type", "Bearer");
+ resp.put("username", user.getUsername());
+ resp.put("role", user.getRole() == null ? "" : user.getRole());
+ return resp;
+ }
+
+ /** 마이페이지 OTP 등록/재설정 시작: 새 보류 시크릿 발급(기존 시크릿 무효화) + QR 반환. */
+ @Transactional
+ public OtpSetupResponse setup(String username) {
+ CmsUser user = userMapper.findByUsername(username);
+ if (user == null) {
+ throw new UiwsApiException(UiwsErrorCode.USER_NOT_FOUND);
+ }
+ 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) {
+ CmsUser user = userMapper.findByUsername(username);
+ if (user == null) {
+ throw new UiwsApiException(UiwsErrorCode.USER_NOT_FOUND);
+ }
+ if (blank(user.getOtpSecret())) {
+ throw new UiwsApiException(UiwsErrorCode.OTP_NOT_REGISTERED);
+ }
+ if (!totpService.verify(user.getOtpSecret(), code)) {
+ throw new UiwsApiException(UiwsErrorCode.VERIFY_CODE_INVALID);
+ }
+ userMapper.enableOtp(username);
+ auditService.log(username, "OTP_ENABLE", username, "OTP 2차 인증 활성화");
+ }
+
+ /** 마이페이지 OTP 해제: 시크릿 폐기 + otp_enabled=false. */
+ @Transactional
+ public void disable(String username) {
+ CmsUser user = userMapper.findByUsername(username);
+ if (user == null) {
+ throw new UiwsApiException(UiwsErrorCode.USER_NOT_FOUND);
+ }
+ userMapper.disableOtp(username);
+ auditService.log(username, "OTP_DISABLE", username, "OTP 2차 인증 해제");
+ }
+}
diff --git a/backend/src/main/java/com/zioinfo/cms/uiws/auth/TotpService.java b/backend/src/main/java/com/zioinfo/cms/uiws/auth/TotpService.java
new file mode 100644
index 0000000..3b3fc30
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/cms/uiws/auth/TotpService.java
@@ -0,0 +1,81 @@
+package com.zioinfo.cms.uiws.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 윈도우) 코덱 — UIWS TotpService 미러.
+ * 시크릿(otp_secret)은 cms_user 에 저장되어 있다고 가정. issuer 만 GUARDiA-CMS 로 교체.
+ *
+ * 보안 불변: 시크릿·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-CMS";
+
+ 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/cms/uiws/common/UiwsErrorCode.java b/backend/src/main/java/com/zioinfo/cms/uiws/common/UiwsErrorCode.java
index 6fe5409..583cdb3 100644
--- a/backend/src/main/java/com/zioinfo/cms/uiws/common/UiwsErrorCode.java
+++ b/backend/src/main/java/com/zioinfo/cms/uiws/common/UiwsErrorCode.java
@@ -37,6 +37,10 @@ public enum UiwsErrorCode {
VERIFY_TOKEN_INVALID("ERR-UIWS-2FA-401", "2차 검증 토큰이 유효하지 않거나 만료되었습니다."),
VERIFY_CODE_INVALID("ERR-UIWS-2FA-401C", "인증 코드가 올바르지 않거나 만료되었습니다."),
ACCOUNT_LOCKED("ERR-UIWS-2FA-LOCK", "로그인 실패 횟수 초과로 계정이 잠겼습니다. 관리자에게 문의하세요."),
+ OTP_NOT_REGISTERED("ERR-UIWS-2FA-OTP404", "OTP가 등록되어 있지 않습니다. 먼저 OTP를 등록하세요."),
+ UNAUTHORIZED("ERR-UIWS-2FA-401U", "인증이 필요합니다."),
+ PASSWORD_MISMATCH("ERR-UIWS-PW-401", "현재 비밀번호가 일치하지 않습니다."),
+ PASSWORD_SAME_AS_OLD("ERR-UIWS-PW-409", "새 비밀번호가 기존 비밀번호와 동일합니다."),
// system(시스템관리·권한관리) 이식
DUPLICATE_KEY("ERR-UIWS-SYS-409D", "이미 존재하는 항목입니다."),
diff --git a/backend/src/main/java/com/zioinfo/cms/uiws/config/UiwsProperties.java b/backend/src/main/java/com/zioinfo/cms/uiws/config/UiwsProperties.java
index b4af2c2..aa96f26 100644
--- a/backend/src/main/java/com/zioinfo/cms/uiws/config/UiwsProperties.java
+++ b/backend/src/main/java/com/zioinfo/cms/uiws/config/UiwsProperties.java
@@ -24,6 +24,11 @@ public class UiwsProperties {
public static class Auth {
/** 2FA 레이어 on/off. 기본 on(켜짐). off 시 기존 단일 JWT 로그인 흐름 유지(회귀 0). */
private boolean twofaEnabled = true;
+ /**
+ * TOTP(OTP 2차 인증) 레이어 on/off. 기본 on. 활성 시 로그인 2단계가 OTP(등록/검증) 경로를 사용한다.
+ * 우선순위: otpEnabled → OTP, (off 이고 twofaEnabled) → 이메일코드, (둘 다 off) → 단일 로그인.
+ */
+ private boolean otpEnabled = true;
/** 1차 통과 후 verify-token 유효시간(초). 계획서 300(5분). */
private long verifyTokenValiditySeconds = 300;
/** 이메일 인증코드 유효시간(초). 계획서 300(5분). */
diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml
index fa3873c..0d46d19 100644
--- a/backend/src/main/resources/application.yml
+++ b/backend/src/main/resources/application.yml
@@ -18,8 +18,8 @@ spring:
# 기존 schema.sql 은 deploy_server 가 psql 로 별도 적용(비멱등 cms_setting/cms_i18n 시드 충돌 회피).
# 91_uiws_port.sql 단독은 완전 멱등 → mode:always 안전. continue-on-error 로 부분 실패 격리.
mode: ${SQL_INIT_MODE:always}
- # 91=업무모듈+2FA ALTER, 92=system(시스템관리·권한관리) 이식, 104=AI 플랫폼 설정 시드. 전부 멱등.
- schema-locations: classpath:db/91_uiws_port.sql,classpath:db/92_uiws_system.sql,classpath:db/104_seed_ai_config.sql
+ # 91=업무모듈+2FA ALTER, 92=system(시스템관리·권한관리) 이식, 93=OTP otp_enabled ALTER, 104=AI 플랫폼 설정 시드. 전부 멱등.
+ schema-locations: classpath:db/91_uiws_port.sql,classpath:db/92_uiws_system.sql,classpath:db/93_auth_otp.sql,classpath:db/104_seed_ai_config.sql
continue-on-error: true
servlet:
multipart:
@@ -70,6 +70,8 @@ cms:
auth:
# 2FA 레이어 on/off. 기본 on. off 면 기존 단일 JWT 로그인 흐름 유지(회귀 0).
twofa-enabled: ${CMS_TWOFA_ENABLED:true}
+ # TOTP(OTP 2차 인증) on/off. 기본 on. 활성 시 로그인 2단계가 OTP(등록/검증) 경로 사용(이메일코드보다 우선).
+ otp-enabled: ${CMS_OTP_ENABLED:true}
verify-token-validity-seconds: 300 # 1차 통과 후 verify-token 5분
email-code-validity-seconds: 300 # 이메일 인증코드 5분
max-login-fail: 5 # 실패 5회 시 계정 잠금
diff --git a/backend/src/main/resources/db/93_auth_otp.sql b/backend/src/main/resources/db/93_auth_otp.sql
new file mode 100644
index 0000000..18e64c3
--- /dev/null
+++ b/backend/src/main/resources/db/93_auth_otp.sql
@@ -0,0 +1,11 @@
+-- =====================================================================
+-- TOTP(OTP 2차 인증) 이식 — cms_user 멱등 ALTER
+-- =====================================================================
+-- otp_secret 은 db/91_uiws_port.sql 에서 이미 ADD(VARCHAR(255)).
+-- 여기서는 등록 확정 플래그 otp_enabled 만 추가한다.
+-- schema-locations 마지막(104 앞)에 등재(mode=always + continue-on-error) → 멱등 재실행 안전.
+
+ALTER TABLE cms_user ADD COLUMN IF NOT EXISTS otp_enabled BOOLEAN DEFAULT false;
+
+COMMENT ON COLUMN cms_user.otp_secret IS 'TOTP 시크릿(보류/확정 공용). API 응답·로그 미노출.';
+COMMENT ON COLUMN cms_user.otp_enabled IS 'OTP 등록 확정 여부(최초 로그인 verify 성공 시 true).';
diff --git a/backend/src/main/resources/db/ops_otp_reset_all.sql b/backend/src/main/resources/db/ops_otp_reset_all.sql
new file mode 100644
index 0000000..999834a
--- /dev/null
+++ b/backend/src/main/resources/db/ops_otp_reset_all.sql
@@ -0,0 +1,16 @@
+-- =====================================================================
+-- [1회 운영 작업] 전 사용자 OTP 초기화 — 멱등
+-- =====================================================================
+-- 목적: 인증 강화 전환 시점에 기존 사용자 OTP 를 초기화한다.
+-- 다음 로그인부터 OTP_SETUP(QR 재등록) 플로우를 타게 한다(UIMS 방식).
+--
+-- ★ 이 파일은 schema-locations 에 등재하지 않는다(자동 재실행 금지).
+-- 운영자가 서버에서 1회 수동 적용:
+-- psql -U cms_user -d cms_db -f db/ops_otp_reset_all.sql
+--
+-- 멱등: 여러 번 실행해도 결과 동일(모두 NULL/false).
+
+UPDATE cms_user
+ SET otp_secret = NULL,
+ otp_enabled = false
+ WHERE otp_secret IS NOT NULL OR otp_enabled IS DISTINCT FROM false;
diff --git a/backend/src/main/resources/mapper/AdminUserMapper.xml b/backend/src/main/resources/mapper/AdminUserMapper.xml
index e2afc31..4efbfbc 100644
--- a/backend/src/main/resources/mapper/AdminUserMapper.xml
+++ b/backend/src/main/resources/mapper/AdminUserMapper.xml
@@ -53,4 +53,9 @@
SELECT COUNT(*) FROM cms_user WHERE username = #{username}
+
+
+ UPDATE cms_user SET otp_secret = NULL, otp_enabled = false WHERE id = #{id}
+
+
diff --git a/backend/src/main/resources/mapper/UserMapper.xml b/backend/src/main/resources/mapper/UserMapper.xml
index 15f35b2..1c356d1 100644
--- a/backend/src/main/resources/mapper/UserMapper.xml
+++ b/backend/src/main/resources/mapper/UserMapper.xml
@@ -18,13 +18,14 @@
+
@@ -83,7 +84,7 @@