step1 = twoFactorService.beginTwoFactor(user);
return Map.of(
"twofa", "true",
@@ -95,7 +107,7 @@ public class AuthService {
}
// 고객(USER) 또는 2FA 비활성 — 기존 단일 로그인 흐름(회귀 0)
- if (isOperationsRole(user.getRole())) {
+ if (opsRole) {
userMapper.resetLoginFail(username);
}
String token = jwtUtil.generate(username, user.getRole());
@@ -127,6 +139,27 @@ public class AuthService {
return Map.of("username", username, "role", role);
}
+ /**
+ * 마이페이지 비밀번호 변경(현재 비밀번호 검증 + BCrypt 저장). UIWS changePassword 미러.
+ * 현재 비번 불일치 → PASSWORD_MISMATCH, 기존과 동일 → PASSWORD_SAME_AS_OLD.
+ * 새 비밀번호 평문은 로그/응답/감사에 절대 기록하지 않는다(보안 불변규칙).
+ */
+ @Transactional
+ public void changePassword(String username, ChangePasswordRequest req) {
+ MallUser 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.updatePasswordByUsername(username, passwordEncoder.encode(req.newPassword()));
+ auditService.log(username, "PASSWORD_CHANGE", username, "본인 비밀번호 변경");
+ }
+
// ── 로그인 보조 이식: 회원가입 / 아이디찾기 / 비밀번호 초기화 ───────────────────
// 대상: Mall 관리자/운영자 계정(mall_account, ADMIN/MANAGER). 고객(USER) register 흐름과 분리.
diff --git a/backend/src/main/java/com/zioinfo/mall/auth/MallUser.java b/backend/src/main/java/com/zioinfo/mall/auth/MallUser.java
index 08e0e12..125d46d 100644
--- a/backend/src/main/java/com/zioinfo/mall/auth/MallUser.java
+++ b/backend/src/main/java/com/zioinfo/mall/auth/MallUser.java
@@ -26,8 +26,10 @@ public class MallUser {
private Integer loginFailCount;
/** 계정 잠금 여부(기본 false). */
private Boolean locked;
- /** TOTP 시크릿(UIWS OTP 경로 대비, 현재 이메일 흐름에서는 미사용). */
+ /** TOTP 시크릿(UIWS OTP 경로). 등록 확정 전 보류 시크릿도 여기 저장. API 응답에 절대 미포함. */
private String otpSecret;
+ /** OTP 등록 확정 여부(기본 false). 최초 로그인 verify 성공 시 true 로 확정(멱등). */
+ private Boolean otpEnabled;
// ── 로그인 보조 이식 컬럼 (mall_account ALTER, db/91_uiws_port.sql) ───────────────
/**
diff --git a/backend/src/main/java/com/zioinfo/mall/auth/dto/ChangePasswordRequest.java b/backend/src/main/java/com/zioinfo/mall/auth/dto/ChangePasswordRequest.java
new file mode 100644
index 0000000..b3e83f9
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mall/auth/dto/ChangePasswordRequest.java
@@ -0,0 +1,6 @@
+package com.zioinfo.mall.auth.dto;
+
+import jakarta.validation.constraints.NotBlank;
+
+/** 마이페이지 비밀번호 변경 요청(현재 비번 검증 + 새 비번). 평문은 응답/로그 미노출. */
+public record ChangePasswordRequest(@NotBlank String currentPassword, @NotBlank String newPassword) {}
diff --git a/backend/src/main/java/com/zioinfo/mall/auth/dto/OtpConfirmRequest.java b/backend/src/main/java/com/zioinfo/mall/auth/dto/OtpConfirmRequest.java
new file mode 100644
index 0000000..78298f4
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mall/auth/dto/OtpConfirmRequest.java
@@ -0,0 +1,6 @@
+package com.zioinfo.mall.auth.dto;
+
+import jakarta.validation.constraints.NotBlank;
+
+/** 마이페이지 OTP 확인·활성화 요청(앱 6자리 코드). */
+public record OtpConfirmRequest(@NotBlank String code) {}
diff --git a/backend/src/main/java/com/zioinfo/mall/auth/dto/OtpSetupResponse.java b/backend/src/main/java/com/zioinfo/mall/auth/dto/OtpSetupResponse.java
new file mode 100644
index 0000000..444e8ab
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mall/auth/dto/OtpSetupResponse.java
@@ -0,0 +1,10 @@
+package com.zioinfo.mall.auth.dto;
+
+/**
+ * OTP 등록/재설정 시작 응답 — 이 응답에서만 시크릿/QR/otpauth URI 를 노출한다(보안 불변).
+ *
+ * @param secret TOTP 시크릿(Base32). 로그/그 외 응답 미노출.
+ * @param otpAuthUri Authenticator 앱 직접 등록용 otpauth:// URI.
+ * @param qrImage QR 이미지 data URI(data:image/png;base64,...).
+ */
+public record OtpSetupResponse(String secret, String otpAuthUri, String qrImage) {}
diff --git a/backend/src/main/java/com/zioinfo/mall/auth/dto/OtpVerifyRequest.java b/backend/src/main/java/com/zioinfo/mall/auth/dto/OtpVerifyRequest.java
new file mode 100644
index 0000000..8b1993a
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mall/auth/dto/OtpVerifyRequest.java
@@ -0,0 +1,6 @@
+package com.zioinfo.mall.auth.dto;
+
+import jakarta.validation.constraints.NotBlank;
+
+/** 로그인 2단계 OTP 검증 요청: verify-token + 앱 6자리 코드. */
+public record OtpVerifyRequest(@NotBlank String verifyToken, @NotBlank String code) {}
diff --git a/backend/src/main/java/com/zioinfo/mall/auth/mapper/UserMapper.java b/backend/src/main/java/com/zioinfo/mall/auth/mapper/UserMapper.java
index 8931c2d..e838e28 100644
--- a/backend/src/main/java/com/zioinfo/mall/auth/mapper/UserMapper.java
+++ b/backend/src/main/java/com/zioinfo/mall/auth/mapper/UserMapper.java
@@ -53,6 +53,27 @@ public interface UserMapper {
@Update("UPDATE mall_account SET locked = false, login_fail_count = 0 WHERE username = #{username}")
int unlock(@Param("username") String username);
+ // ── admin 재시드 / 마이페이지 비밀번호 변경: password_hash 만 갱신(부수효과 없음) ────────
+
+ /** username 기준 BCrypt 해시 갱신(잠금/실패카운트 무영향). AdminPasswordSeeder·changePassword 공용. */
+ @Update("UPDATE mall_account SET password_hash = #{passwordHash} WHERE username = #{username}")
+ int updatePasswordByUsername(@Param("username") String username,
+ @Param("passwordHash") String passwordHash);
+
+ // ── TOTP(OTP 2차 인증) 이식 (멱등 UPDATE) ────────────────────────────────────
+
+ /** 보류 시크릿 저장(등록 확정 전). otp_enabled 은 건드리지 않는다(verify/confirm 에서 확정). */
+ @Update("UPDATE mall_account SET otp_secret = #{secret} WHERE username = #{username}")
+ int updateOtpSecret(@Param("username") String username, @Param("secret") String secret);
+
+ /** 등록 확정: otp_enabled=true (시크릿은 유지). */
+ @Update("UPDATE mall_account SET otp_enabled = true WHERE username = #{username}")
+ int enableOtp(@Param("username") String username);
+
+ /** 해제/초기화: 시크릿 폐기 + otp_enabled=false. (마이페이지 해제) */
+ @Update("UPDATE mall_account SET otp_secret = NULL, otp_enabled = false WHERE username = #{username}")
+ int disableOtp(@Param("username") String username);
+
// ── 로그인 보조 이식: 회원가입 / 아이디찾기 / 비밀번호 초기화 (UserMapper.xml) ───────
/** email 존재 여부(회원가입 중복 검사 — 운영자 계정 한정). */
diff --git a/backend/src/main/java/com/zioinfo/mall/config/AdminPasswordSeeder.java b/backend/src/main/java/com/zioinfo/mall/config/AdminPasswordSeeder.java
new file mode 100644
index 0000000..b320015
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mall/config/AdminPasswordSeeder.java
@@ -0,0 +1,130 @@
+package com.zioinfo.mall.config;
+
+import com.zioinfo.mall.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 방식 + 암호화 저장). — OCR 파일럿 미러.
+ *
+ * 기동 시 멱등 재시드:
+ *
+ * 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(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.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/mall/uiws/auth/OtpAuthService.java b/backend/src/main/java/com/zioinfo/mall/uiws/auth/OtpAuthService.java
new file mode 100644
index 0000000..5726476
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mall/uiws/auth/OtpAuthService.java
@@ -0,0 +1,169 @@
+package com.zioinfo.mall.uiws.auth;
+
+import com.zioinfo.mall.admin.AuditService;
+import com.zioinfo.mall.auth.JwtUtil;
+import com.zioinfo.mall.auth.MallUser;
+import com.zioinfo.mall.auth.dto.OtpSetupResponse;
+import com.zioinfo.mall.auth.mapper.UserMapper;
+import com.zioinfo.mall.uiws.common.UiwsApiException;
+import com.zioinfo.mall.uiws.common.UiwsErrorCode;
+import com.zioinfo.mall.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 OTP 경로 미러(OCR 파일럿 패턴).
+ *
+ *
★ Mall 은 운영(ADMIN/MANAGER) 로그인에만 이 레이어를 태운다. 고객(USER) 쇼핑 로그인은 무변경.
+ *
+ *
흐름:
+ *
+ * - 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 경로를 사용할지(전역 토글). 실제 적용 대상 게이팅은 AuthService 가 운영 역할로 한정. */
+ 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(MallUser 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);
+ }
+ MallUser 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) {
+ MallUser 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) {
+ MallUser 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) {
+ MallUser 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/mall/uiws/auth/TotpService.java b/backend/src/main/java/com/zioinfo/mall/uiws/auth/TotpService.java
new file mode 100644
index 0000000..f370f2a
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mall/uiws/auth/TotpService.java
@@ -0,0 +1,81 @@
+package com.zioinfo.mall.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 미러(OCR 파일럿 패턴).
+ * 시크릿(otp_secret)은 mall_account 에 저장되어 있다고 가정. issuer 만 GUARDiA-MALL 로 교체.
+ *
+ * 보안 불변: 시크릿·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-MALL";
+
+ 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/mall/uiws/common/UiwsErrorCode.java b/backend/src/main/java/com/zioinfo/mall/uiws/common/UiwsErrorCode.java
index fb44dba..5ddcce8 100644
--- a/backend/src/main/java/com/zioinfo/mall/uiws/common/UiwsErrorCode.java
+++ b/backend/src/main/java/com/zioinfo/mall/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", "로그인 실패 횟수 초과로 계정이 잠겼습니다. 관리자에게 문의하세요."),
+ UNAUTHORIZED("ERR-UIWS-2FA-401U", "인증이 필요합니다."),
+ OTP_NOT_REGISTERED("ERR-UIWS-OTP-409", "OTP 가 등록되어 있지 않습니다."),
+ 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/mall/uiws/config/UiwsProperties.java b/backend/src/main/java/com/zioinfo/mall/uiws/config/UiwsProperties.java
index 19f24d4..eeed501 100644
--- a/backend/src/main/java/com/zioinfo/mall/uiws/config/UiwsProperties.java
+++ b/backend/src/main/java/com/zioinfo/mall/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;
+ /**
+ * OTP(TOTP) 2차 인증 경로 on/off. 기본 on(켜짐) — 운영(ADMIN/MANAGER) 로그인에 우선 적용.
+ * on 이면 이메일코드보다 우선(OTP > 이메일 > 단일). 고객(USER) 쇼핑 로그인은 무관(회귀 0).
+ */
+ 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 8042761..a0595f7 100644
--- a/backend/src/main/resources/application.yml
+++ b/backend/src/main/resources/application.yml
@@ -14,7 +14,7 @@ spring:
sql:
init:
mode: ${SQL_INIT_MODE:always}
- schema-locations: classpath:db/91_uiws_port.sql,classpath:db/92_uiws_system.sql,classpath:db/104_seed_ai_config.sql
+ 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:
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..c003195
--- /dev/null
+++ b/backend/src/main/resources/db/93_auth_otp.sql
@@ -0,0 +1,14 @@
+-- =====================================================================
+-- TOTP(OTP 2차 인증) 이식 — mall_account 멱등 ALTER (OCR 파일럿 미러)
+-- =====================================================================
+-- otp_secret 은 db/91_uiws_port.sql 에서 이미 ADD(VARCHAR(255)).
+-- 여기서는 등록 확정 플래그 otp_enabled 만 추가한다.
+-- schema-locations 마지막에 등재(mode=always + continue-on-error) → 멱등 재실행 안전.
+-- ※ 2FA/OTP 는 운영(ADMIN/MANAGER) 로그인에만 적용 — 고객(USER) 쇼핑 로그인 회귀 0.
+
+SET client_encoding = 'UTF8';
+
+ALTER TABLE mall_account ADD COLUMN IF NOT EXISTS otp_enabled BOOLEAN DEFAULT false;
+
+COMMENT ON COLUMN mall_account.otp_secret IS 'TOTP 시크릿(보류/확정 공용). API 응답·로그 미노출.';
+COMMENT ON COLUMN mall_account.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..e86e329
--- /dev/null
+++ b/backend/src/main/resources/db/ops_otp_reset_all.sql
@@ -0,0 +1,16 @@
+-- =====================================================================
+-- [1회 운영 작업] 전 사용자 OTP 초기화 — 멱등 (OCR 파일럿 미러)
+-- =====================================================================
+-- 목적: 인증 강화 전환 시점에 기존 사용자 OTP 를 초기화한다.
+-- 다음 로그인부터 OTP_SETUP(QR 재등록) 플로우를 타게 한다(UIMS 방식).
+--
+-- ★ 이 파일은 schema-locations 에 등재하지 않는다(자동 재실행 금지).
+-- 운영자가 서버에서 1회 수동 적용:
+-- psql -U mall_user -d mall_db -f db/ops_otp_reset_all.sql
+--
+-- 멱등: 여러 번 실행해도 결과 동일(모두 NULL/false).
+
+UPDATE mall_account
+ 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 0279811..37a801c 100644
--- a/backend/src/main/resources/mapper/AdminUserMapper.xml
+++ b/backend/src/main/resources/mapper/AdminUserMapper.xml
@@ -32,6 +32,7 @@
UPDATE mall_account SET role = #{role} WHERE id = #{id}
UPDATE mall_account SET is_active = #{active} WHERE id = #{id}
UPDATE mall_account SET password_hash = #{passwordHash} WHERE id = #{id}
+ UPDATE mall_account SET otp_secret = NULL, otp_enabled = false WHERE id = #{id}
DELETE FROM mall_account WHERE id = #{id}