feat(auth): OTP 2FA + admin 암호화 재시드
This commit is contained in:
parent
7b0b696cd6
commit
1409760aa3
@ -33,6 +33,8 @@
|
||||
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-impl</artifactId><version>${jjwt.version}</version><scope>runtime</scope></dependency>
|
||||
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-jackson</artifactId><version>${jjwt.version}</version><scope>runtime</scope></dependency>
|
||||
<dependency><groupId>org.springdoc</groupId><artifactId>springdoc-openapi-starter-webmvc-ui</artifactId><version>${springdoc.version}</version></dependency>
|
||||
<!-- TOTP 2FA (RFC 6238 · SHA1 · 30s · 6자리) — UIWS(uiws build.gradle) 동일 좌표. QR 생성 위해 zxing 전이 포함. -->
|
||||
<dependency><groupId>dev.samstevens.totp</groupId><artifactId>totp</artifactId><version>1.7.1</version></dependency>
|
||||
<dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-webflux</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-websocket</artifactId></dependency>
|
||||
|
||||
@ -56,6 +56,12 @@ public class AdminController {
|
||||
return ApiResponse.ok(userService.resetPassword(id, req.password()));
|
||||
}
|
||||
|
||||
/** 관리자 OTP 초기화(사용자 관리 화면 버튼) — 대상 OTP 시크릿 NULL → 다음 로그인 시 QR 재등록. */
|
||||
@PostMapping("/users/{id}/otp-reset")
|
||||
public ApiResponse<UserDto> otpReset(@PathVariable Long id) {
|
||||
return ApiResponse.ok(userService.otpReset(id));
|
||||
}
|
||||
|
||||
@DeleteMapping("/users/{id}")
|
||||
public ApiResponse<Void> deleteUser(@PathVariable Long id, Authentication auth) {
|
||||
String currentUsername = auth != null ? auth.getName() : null;
|
||||
|
||||
@ -83,6 +83,14 @@ public class AdminUserService {
|
||||
return UserDto.from(user);
|
||||
}
|
||||
|
||||
/** 관리자 OTP 초기화: 대상 사용자 시크릿·확정 플래그 폐기 → 다음 로그인 시 QR 재등록 유도. */
|
||||
public UserDto otpReset(Long id) {
|
||||
MallUser user = require(id);
|
||||
mapper.clearOtp(id);
|
||||
auditService.log("USER_OTP_RESET", user.getUsername(), "OTP 초기화(재등록 유도)");
|
||||
return UserDto.from(user);
|
||||
}
|
||||
|
||||
public void delete(Long id, String currentUsername) {
|
||||
MallUser user = require(id);
|
||||
if (user.getUsername().equals(currentUsername)) {
|
||||
|
||||
@ -22,6 +22,9 @@ public interface AdminUserMapper {
|
||||
|
||||
int updatePassword(@Param("id") Long id, @Param("passwordHash") String passwordHash);
|
||||
|
||||
/** 관리자 OTP 초기화: 시크릿 폐기 + otp_enabled=false → 다음 로그인 시 QR 재등록 유도. */
|
||||
int clearOtp(@Param("id") Long id);
|
||||
|
||||
int deleteById(@Param("id") Long id);
|
||||
|
||||
int countAdmins();
|
||||
|
||||
@ -1,7 +1,15 @@
|
||||
package com.zioinfo.mall.auth;
|
||||
|
||||
import com.zioinfo.mall.auth.dto.ChangePasswordRequest;
|
||||
import com.zioinfo.mall.auth.dto.OtpConfirmRequest;
|
||||
import com.zioinfo.mall.auth.dto.OtpSetupResponse;
|
||||
import com.zioinfo.mall.auth.dto.OtpVerifyRequest;
|
||||
import com.zioinfo.mall.common.ApiResponse;
|
||||
import com.zioinfo.mall.uiws.auth.OtpAuthService;
|
||||
import com.zioinfo.mall.uiws.auth.TwoFactorService;
|
||||
import com.zioinfo.mall.uiws.common.UiwsApiException;
|
||||
import com.zioinfo.mall.uiws.common.UiwsErrorCode;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@ -20,6 +28,8 @@ public class AuthController {
|
||||
|
||||
private final AuthService authService;
|
||||
private final TwoFactorService twoFactorService;
|
||||
private final OtpAuthService otpAuthService;
|
||||
private final JwtUtil jwtUtil;
|
||||
|
||||
@PostMapping("/login")
|
||||
public ApiResponse<Map<String, String>> login(@RequestBody LoginRequest req) {
|
||||
@ -31,18 +41,68 @@ public class AuthController {
|
||||
return ApiResponse.ok(authService.register(req.username(), req.password(), req.displayName()));
|
||||
}
|
||||
|
||||
/** UIWS 2FA 이식: 운영 로그인 2차 인증 코드 검증 → access 발급. */
|
||||
/** UIWS 2FA 이식: 운영 로그인 2차 인증 코드 검증(이메일) → access 발급. */
|
||||
@PostMapping("/verify")
|
||||
public ApiResponse<Map<String, String>> verify(@RequestBody VerifyRequest req) {
|
||||
return ApiResponse.ok(twoFactorService.verify(req.verifyToken(), req.code()));
|
||||
}
|
||||
|
||||
/** TOTP 이식: 운영 로그인 2단계 6자리 검증 → access/refresh 발급(최초 로그인이면 등록 확정). */
|
||||
@PostMapping("/verify-otp")
|
||||
public ApiResponse<Map<String, String>> verifyOtp(@Valid @RequestBody OtpVerifyRequest req) {
|
||||
return ApiResponse.ok(otpAuthService.verifyOtp(req.verifyToken(), req.code()));
|
||||
}
|
||||
|
||||
@GetMapping("/me")
|
||||
public ApiResponse<Map<String, String>> me(@RequestHeader("Authorization") String header) {
|
||||
String token = header.replace("Bearer ", "");
|
||||
return ApiResponse.ok(authService.me(token));
|
||||
}
|
||||
|
||||
// ── 마이페이지: OTP 등록/재설정/해제 + 비밀번호 변경 (본인, access 토큰 필요) ──────────
|
||||
// /api/mall/auth/** 는 permitAll 이라 필터 인증이 없으므로 여기서 access 토큰을 명시 검증.
|
||||
|
||||
/** 마이페이지 OTP 등록/재설정 시작 → { secret, otpAuthUri, qrImage }(이 응답에서만 시크릿/QR 노출). */
|
||||
@PostMapping("/otp/setup")
|
||||
public ApiResponse<OtpSetupResponse> otpSetup(@RequestHeader("Authorization") String header) {
|
||||
return ApiResponse.ok(otpAuthService.setup(requireUser(header)));
|
||||
}
|
||||
|
||||
/** 마이페이지 OTP 확인·활성화(앱 6자리 코드). */
|
||||
@PostMapping("/otp/confirm")
|
||||
public ApiResponse<Map<String, String>> otpConfirm(@RequestHeader("Authorization") String header,
|
||||
@Valid @RequestBody OtpConfirmRequest req) {
|
||||
otpAuthService.confirm(requireUser(header), req.code());
|
||||
return ApiResponse.ok(Map.of("result", "ok"));
|
||||
}
|
||||
|
||||
/** 마이페이지 OTP 해제. */
|
||||
@PostMapping("/otp/disable")
|
||||
public ApiResponse<Map<String, String>> otpDisable(@RequestHeader("Authorization") String header) {
|
||||
otpAuthService.disable(requireUser(header));
|
||||
return ApiResponse.ok(Map.of("result", "ok"));
|
||||
}
|
||||
|
||||
/** 마이페이지 비밀번호 변경(현재 비번 검증 + BCrypt). 새 비번은 응답/로그 미포함. */
|
||||
@PostMapping("/change-password")
|
||||
public ApiResponse<Map<String, String>> changePassword(@RequestHeader("Authorization") String header,
|
||||
@Valid @RequestBody ChangePasswordRequest req) {
|
||||
authService.changePassword(requireUser(header), req);
|
||||
return ApiResponse.ok(Map.of("result", "ok"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorization 헤더에서 인증 사용자를 해석한다. verify-token(2fa 단계용)은 거부.
|
||||
* (/api/mall/auth/** 는 permitAll 이라 필터 인증이 없으므로 여기서 access 토큰을 명시 검증.)
|
||||
*/
|
||||
private String requireUser(String header) {
|
||||
String token = header == null ? "" : header.replace("Bearer ", "").trim();
|
||||
if (token.isEmpty() || jwtUtil.isVerifyToken(token) || !jwtUtil.isValid(token)) {
|
||||
throw new UiwsApiException(UiwsErrorCode.UNAUTHORIZED);
|
||||
}
|
||||
return jwtUtil.getUsername(token);
|
||||
}
|
||||
|
||||
record LoginRequest(String username, String password) {}
|
||||
record RegisterRequest(String username, String password, String displayName) {}
|
||||
record VerifyRequest(String verifyToken, String code) {}
|
||||
|
||||
@ -1,11 +1,14 @@
|
||||
package com.zioinfo.mall.auth;
|
||||
|
||||
import com.zioinfo.mall.admin.AuditService;
|
||||
import com.zioinfo.mall.auth.dto.AuthHelperResult;
|
||||
import com.zioinfo.mall.auth.dto.ChangePasswordRequest;
|
||||
import com.zioinfo.mall.auth.dto.FindIdRequest;
|
||||
import com.zioinfo.mall.auth.dto.FindIdResponse;
|
||||
import com.zioinfo.mall.auth.dto.ResetPasswordRequest;
|
||||
import com.zioinfo.mall.auth.dto.SignupRequest;
|
||||
import com.zioinfo.mall.auth.mapper.UserMapper;
|
||||
import com.zioinfo.mall.uiws.auth.OtpAuthService;
|
||||
import com.zioinfo.mall.uiws.auth.TwoFactorService;
|
||||
import com.zioinfo.mall.uiws.common.UiwsApiException;
|
||||
import com.zioinfo.mall.uiws.common.UiwsErrorCode;
|
||||
@ -42,7 +45,9 @@ public class AuthService {
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final JwtUtil jwtUtil;
|
||||
private final TwoFactorService twoFactorService;
|
||||
private final OtpAuthService otpAuthService;
|
||||
private final MailSender mailSender;
|
||||
private final AuditService auditService;
|
||||
|
||||
/** 운영(2FA 대상) 역할 여부 — 고객(USER)은 제외. */
|
||||
private static boolean isOperationsRole(String role) {
|
||||
@ -70,11 +75,14 @@ public class AuthService {
|
||||
throw new RuntimeException("ERR-AUTH-003: 승인 대기 중인 계정입니다. 관리자 승인 후 이용 가능합니다.");
|
||||
}
|
||||
|
||||
boolean twofaTarget = twoFactorService.isEnabled() && isOperationsRole(user.getRole());
|
||||
boolean opsRole = isOperationsRole(user.getRole());
|
||||
// 2단계 인증 대상: 운영(ADMIN/MANAGER) 로그인만. OTP(TOTP) 우선, 없으면 이메일코드.
|
||||
boolean otpTarget = otpAuthService.isEnabled() && opsRole;
|
||||
boolean emailTarget = twoFactorService.isEnabled() && opsRole;
|
||||
|
||||
if (!passwordEncoder.matches(password, user.getPasswordHash())) {
|
||||
// 운영 + 2FA 활성 시 실패 누적/잠금. 고객/비활성 시 기존 동작(메시지만) 유지.
|
||||
if (twofaTarget) {
|
||||
// 운영 + 2FA(OTP 또는 이메일) 활성 시 실패 누적/잠금. 고객/비활성 시 기존 동작(메시지만) 유지.
|
||||
if (otpTarget || emailTarget) {
|
||||
twoFactorService.recordLoginFailure(username);
|
||||
MallUser after = userMapper.findByUsername(username);
|
||||
if (after != null && Boolean.TRUE.equals(after.getLocked())) {
|
||||
@ -84,8 +92,12 @@ public class AuthService {
|
||||
throw new RuntimeException("ERR-AUTH-002: 비밀번호 불일치");
|
||||
}
|
||||
|
||||
// 비밀번호 검증 통과
|
||||
if (twofaTarget) {
|
||||
// 비밀번호 검증 통과 — 2단계 우선순위: OTP > 이메일코드 > 단일 로그인
|
||||
if (otpTarget) {
|
||||
// { twofa:true, verifyToken, verifyMethod:(OTP|OTP_SETUP), secret?, otpAuthUri?, qrImage? }
|
||||
return otpAuthService.beginOtp(user);
|
||||
}
|
||||
if (emailTarget) {
|
||||
Map<String, String> 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 흐름과 분리.
|
||||
|
||||
|
||||
@ -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) ───────────────
|
||||
/**
|
||||
|
||||
@ -0,0 +1,6 @@
|
||||
package com.zioinfo.mall.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
/** 마이페이지 비밀번호 변경 요청(현재 비번 검증 + 새 비번). 평문은 응답/로그 미노출. */
|
||||
public record ChangePasswordRequest(@NotBlank String currentPassword, @NotBlank String newPassword) {}
|
||||
@ -0,0 +1,6 @@
|
||||
package com.zioinfo.mall.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
/** 마이페이지 OTP 확인·활성화 요청(앱 6자리 코드). */
|
||||
public record OtpConfirmRequest(@NotBlank String code) {}
|
||||
@ -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) {}
|
||||
@ -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) {}
|
||||
@ -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 존재 여부(회원가입 중복 검사 — 운영자 계정 한정). */
|
||||
|
||||
@ -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 파일럿 미러.
|
||||
*
|
||||
* <p>기동 시 멱등 재시드:
|
||||
* <pre>
|
||||
* ADMIN_KEY_FILE(hex 32바이트 키 파일) 로드 → ADMIN_PASSWORD_ENC(base64(nonce12 + ct + tag), AES-256-GCM) 복호
|
||||
* → admin 계정 BCrypt 해시 갱신(하드코딩 admin123 시드를 env 값으로 덮어씀).
|
||||
* </pre>
|
||||
*
|
||||
* <p>안전 규칙:
|
||||
* <ul>
|
||||
* <li>env 미설정 / 키 로드 실패 / 복호 실패 시 <b>재시드 스킵</b>(기동 계속). WARN 로그에 <b>값(평문/키/시크릿) 미기록</b>.</li>
|
||||
* <li>새 admin 비밀번호 값은 서버 env 에만 존재 — 코드/DB/로그/응답에 평문 미기재(보안 불변규칙).</li>
|
||||
* <li>키 값은 별도 파일(root 600)에만 — env/코드/git 에 키 미기재. 여기서는 파일 경로만 읽는다.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>스키마 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();
|
||||
}
|
||||
}
|
||||
@ -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 파일럿 패턴).
|
||||
*
|
||||
* <p>★ Mall 은 운영(ADMIN/MANAGER) 로그인에만 이 레이어를 태운다. 고객(USER) 쇼핑 로그인은 무변경.
|
||||
*
|
||||
* <p>흐름:
|
||||
* <ol>
|
||||
* <li>1차 로그인 성공 → {@link #beginOtp}: verify-token 발급 + (미등록이면 보류 시크릿+QR) 반환.</li>
|
||||
* <li>{@code POST /verify-otp}(verifyToken+code) → {@link #verifyOtp}: 6자리 검증 후 access/refresh 발급.
|
||||
* 최초 로그인이면 등록 확정(otp_enabled=true).</li>
|
||||
* <li>마이페이지: {@link #setup}/{@link #confirm}/{@link #disable}.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>보안 불변: 시크릿·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<String, String> beginOtp(MallUser user) {
|
||||
long tokenValidity = properties.getAuth().getVerifyTokenValiditySeconds();
|
||||
String verifyToken = jwtUtil.generateVerifyToken(user.getUsername(), tokenValidity);
|
||||
// 성공 시 실패카운트 초기화(잠금 회복)
|
||||
userMapper.resetLoginFail(user.getUsername());
|
||||
|
||||
Map<String, String> 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<String, String> 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<String, String> 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차 인증 해제");
|
||||
}
|
||||
}
|
||||
@ -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 로 교체.
|
||||
*
|
||||
* <p>보안 불변: 시크릿·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();
|
||||
}
|
||||
}
|
||||
@ -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", "이미 존재하는 항목입니다."),
|
||||
|
||||
@ -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분). */
|
||||
|
||||
@ -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:
|
||||
|
||||
14
backend/src/main/resources/db/93_auth_otp.sql
Normal file
14
backend/src/main/resources/db/93_auth_otp.sql
Normal file
@ -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).';
|
||||
16
backend/src/main/resources/db/ops_otp_reset_all.sql
Normal file
16
backend/src/main/resources/db/ops_otp_reset_all.sql
Normal file
@ -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;
|
||||
@ -32,6 +32,7 @@
|
||||
<update id="updateRole">UPDATE mall_account SET role = #{role} WHERE id = #{id}</update>
|
||||
<update id="updateActive">UPDATE mall_account SET is_active = #{active} WHERE id = #{id}</update>
|
||||
<update id="updatePassword">UPDATE mall_account SET password_hash = #{passwordHash} WHERE id = #{id}</update>
|
||||
<update id="clearOtp">UPDATE mall_account SET otp_secret = NULL, otp_enabled = false WHERE id = #{id}</update>
|
||||
<delete id="deleteById">DELETE FROM mall_account WHERE id = #{id}</delete>
|
||||
|
||||
<select id="countAdmins" resultType="int">
|
||||
|
||||
@ -18,13 +18,14 @@
|
||||
<result property="loginFailCount" column="login_fail_count"/>
|
||||
<result property="locked" column="locked"/>
|
||||
<result property="otpSecret" column="otp_secret"/>
|
||||
<result property="otpEnabled" column="otp_enabled"/>
|
||||
<!-- 로그인 보조 이식: 회원가입 승인 게이트 -->
|
||||
<result property="approved" column="approved"/>
|
||||
</resultMap>
|
||||
|
||||
<select id="findByUsername" resultMap="userMap">
|
||||
SELECT id, username, password_hash, role, display_name, is_active, created_at,
|
||||
email, email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret, approved
|
||||
email, email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret, otp_enabled, approved
|
||||
FROM mall_account
|
||||
WHERE username = #{username}
|
||||
</select>
|
||||
@ -56,7 +57,7 @@
|
||||
|
||||
<select id="findByDisplayNameAndEmail" resultMap="userMap">
|
||||
SELECT id, username, password_hash, role, display_name, is_active, created_at,
|
||||
email, email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret, approved
|
||||
email, email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret, otp_enabled, approved
|
||||
FROM mall_account
|
||||
WHERE display_name = #{displayName} AND email = #{email}
|
||||
ORDER BY id
|
||||
@ -65,7 +66,7 @@
|
||||
|
||||
<select id="findByUsernameAndEmail" resultMap="userMap">
|
||||
SELECT id, username, password_hash, role, display_name, is_active, created_at,
|
||||
email, email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret, approved
|
||||
email, email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret, otp_enabled, approved
|
||||
FROM mall_account
|
||||
WHERE username = #{username} AND email = #{email}
|
||||
</select>
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#fdfaf5" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 17 KiB |
Loading…
Reference in New Issue
Block a user