feat(auth): OTP 2FA 백+프론트
This commit is contained in:
parent
0a4cb67c44
commit
4098ccbe84
@ -52,6 +52,13 @@
|
||||
<version>1.1.3</version>
|
||||
</dependency>
|
||||
|
||||
<!-- TOTP(OTP 2차 인증) — RFC 6238, QR(otpauth) 생성. UIWS 동일 좌표. -->
|
||||
<dependency>
|
||||
<groupId>dev.samstevens.totp</groupId>
|
||||
<artifactId>totp</artifactId>
|
||||
<version>1.7.1</version>
|
||||
</dependency>
|
||||
|
||||
<!-- JWT -->
|
||||
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-api</artifactId><version>${jjwt.version}</version></dependency>
|
||||
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-impl</artifactId><version>${jjwt.version}</version><scope>runtime</scope></dependency>
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
package com.zioinfo.esn.auth;
|
||||
|
||||
import com.zioinfo.esn.common.ApiResponse;
|
||||
import com.zioinfo.esn.uiws.auth.OtpAuthService;
|
||||
import com.zioinfo.esn.uiws.auth.TwoFactorService;
|
||||
import com.zioinfo.esn.uiws.common.UiwsApiException;
|
||||
import com.zioinfo.esn.uiws.common.UiwsErrorCode;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@ -9,8 +13,10 @@ import java.util.Map;
|
||||
|
||||
/**
|
||||
* ESN 인증 컨트롤러.
|
||||
* - /login: 2FA off 면 { twofa:"false", token, type }, 2FA on 이면 { twofa:"true", verifyToken, step, maskedEmail }.
|
||||
* - /verify: (UIWS 2FA 이식) verify-token + 인증코드 → access 토큰 발급.
|
||||
* - /login: 2FA off 면 { twofa:"false", token, type }, 2FA on 이면 { twofa:"true", verifyToken, ... }.
|
||||
* - /verify: (UIWS 이메일 2FA 이식) verify-token + 인증코드 → access 토큰 발급.
|
||||
* - /verify-otp: (TOTP 이식) verify-token + 6자리 코드 → access 토큰 발급(최초 로그인이면 등록 확정).
|
||||
* - /otp/*, /change-password: 마이페이지(본인, access 토큰 필요).
|
||||
* 기존 클라이언트는 응답에 token 필드가 그대로 존재(2FA off 시) → 회귀 0.
|
||||
*/
|
||||
@RestController
|
||||
@ -20,18 +26,26 @@ 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) {
|
||||
return ApiResponse.ok(authService.login(req.username(), req.password()));
|
||||
}
|
||||
|
||||
/** 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 토큰 발급(최초 로그인이면 등록 확정). */
|
||||
@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 ", "");
|
||||
@ -44,6 +58,49 @@ public class AuthController {
|
||||
return ApiResponse.ok("로그아웃 성공", null);
|
||||
}
|
||||
|
||||
// ── 마이페이지: OTP 등록/재설정/해제 + 비밀번호 변경 (본인, 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/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 VerifyRequest(String verifyToken, String code) {}
|
||||
|
||||
@ -1,19 +1,22 @@
|
||||
package com.zioinfo.esn.auth;
|
||||
|
||||
import com.zioinfo.esn.admin.AuditService;
|
||||
import com.zioinfo.esn.auth.mapper.UserAuthMapper;
|
||||
import com.zioinfo.esn.uiws.auth.OtpAuthService;
|
||||
import com.zioinfo.esn.uiws.auth.TwoFactorService;
|
||||
import com.zioinfo.esn.uiws.common.UiwsApiException;
|
||||
import com.zioinfo.esn.uiws.common.UiwsErrorCode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* ESN 인증 서비스.
|
||||
* - 기존 단일 JWT 로그인 보존(2FA off 또는 미설정 시 회귀 0).
|
||||
* - UIWS 2FA 레이어(이메일 코드) 추가: 2FA on 이면 1차 통과 후 verify-token + 이메일코드 발급.
|
||||
* - UIWS 2FA 레이어 추가. 로그인 2단계 우선순위: OTP(TOTP) > 이메일코드 > 단일 로그인.
|
||||
* 실패 누적 max-login-fail 회 시 계정 잠금.
|
||||
*/
|
||||
@Service
|
||||
@ -24,6 +27,8 @@ public class AuthService {
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final JwtUtil jwtUtil;
|
||||
private final TwoFactorService twoFactorService;
|
||||
private final OtpAuthService otpAuthService;
|
||||
private final AuditService auditService;
|
||||
|
||||
/**
|
||||
* 1차 로그인. 2FA 활성 시 verify-token + 이메일코드 흐름으로 분기,
|
||||
@ -43,8 +48,8 @@ public class AuthService {
|
||||
throw new RuntimeException("ERR-AUTH-001: 존재하지 않거나 비활성 계정");
|
||||
}
|
||||
if (!passwordEncoder.matches(password, user.getPasswordHash())) {
|
||||
// 2FA 활성 시 실패 누적/잠금. 비활성 시 기존 동작(메시지만) 유지.
|
||||
if (twoFactorService.isEnabled()) {
|
||||
// 2FA(OTP 또는 이메일) 활성 시 실패 누적/잠금. 비활성 시 기존 동작(메시지만) 유지.
|
||||
if (otpAuthService.isEnabled() || twoFactorService.isEnabled()) {
|
||||
twoFactorService.recordLoginFailure(username);
|
||||
EsnUser after = userMapper.findByUsername(username);
|
||||
if (after != null && Boolean.TRUE.equals(after.getLocked())) {
|
||||
@ -54,7 +59,11 @@ public class AuthService {
|
||||
throw new RuntimeException("ERR-AUTH-002: 비밀번호 불일치");
|
||||
}
|
||||
|
||||
// 비밀번호 검증 통과
|
||||
// 비밀번호 검증 통과 — 2단계 우선순위: OTP > 이메일코드 > 단일 로그인
|
||||
if (otpAuthService.isEnabled()) {
|
||||
// { twofa:true, verifyToken, verifyMethod:(OTP|OTP_SETUP), secret?, otpAuthUri?, qrImage? }
|
||||
return otpAuthService.beginOtp(user);
|
||||
}
|
||||
if (twoFactorService.isEnabled()) {
|
||||
Map<String, String> step1 = twoFactorService.beginTwoFactor(user);
|
||||
return Map.of(
|
||||
@ -78,4 +87,25 @@ public class AuthService {
|
||||
"tenant", jwtUtil.getTenant(token) != null ? jwtUtil.getTenant(token) : ""
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 마이페이지 비밀번호 변경(현재 비밀번호 검증 + BCrypt 저장). UIWS changePassword 미러.
|
||||
* 현재 비번 불일치 → PASSWORD_MISMATCH, 기존과 동일 → PASSWORD_SAME_AS_OLD.
|
||||
* 새 비밀번호 평문은 로그/응답/감사에 절대 기록하지 않는다(보안 불변규칙).
|
||||
*/
|
||||
@Transactional
|
||||
public void changePassword(String username, ChangePasswordRequest req) {
|
||||
EsnUser 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("PASSWORD_CHANGE", "USER", username, "본인 비밀번호 변경");
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,12 @@
|
||||
package com.zioinfo.esn.auth;
|
||||
|
||||
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
|
||||
) {
|
||||
}
|
||||
@ -35,7 +35,9 @@ public class EsnUser {
|
||||
private Integer loginFailCount;
|
||||
/** 계정 잠금 여부(기본 false). */
|
||||
private Boolean locked;
|
||||
/** TOTP 시크릿(UIWS OTP 경로 대비, 현재 이메일 흐름에서는 미사용). */
|
||||
/** TOTP 시크릿(OTP 2차 인증). API 응답·로그 미노출(불변규칙). */
|
||||
@JsonIgnore
|
||||
private String otpSecret;
|
||||
/** OTP 등록 확정 여부(최초 로그인 verify 성공/마이페이지 confirm 시 true). */
|
||||
private Boolean otpEnabled;
|
||||
}
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
package com.zioinfo.esn.auth;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
/** Authenticator(TOTP) 등록 확인 요청 — 앱에 표시된 6자리 코드. */
|
||||
public record OtpConfirmRequest(@NotBlank String code) {
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
package com.zioinfo.esn.auth;
|
||||
|
||||
/**
|
||||
* Authenticator(TOTP) 등록 셋업 응답 — UIWS OtpSetupResponse 미러.
|
||||
* <ul>
|
||||
* <li>secret : Base32 TOTP 시크릿(수동 입력용)</li>
|
||||
* <li>otpAuthUri : otpauth://totp/... (Authenticator 앱 직접 등록용 URI)</li>
|
||||
* <li>qrImage : data:image/png;base64,... (QR 이미지, <img src> 로 표시)</li>
|
||||
* </ul>
|
||||
* 보안 불변: 이 응답(등록 순간)에서만 시크릿/QR 노출. 조회/목록/재조회 응답에 재노출 금지.
|
||||
*/
|
||||
public record OtpSetupResponse(String secret, String otpAuthUri, String qrImage) {
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
package com.zioinfo.esn.auth;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
/** 로그인 2단계 TOTP 검증 요청 — { verifyToken, code }. */
|
||||
public record OtpVerifyRequest(
|
||||
@NotBlank(message = "verifyToken은 필수입니다.") String verifyToken,
|
||||
@NotBlank(message = "code는 필수입니다.") String code
|
||||
) {
|
||||
}
|
||||
@ -30,6 +30,17 @@ public interface UserAuthMapper {
|
||||
/** 관리자 잠금 해제(실패 카운트/잠금 초기화). */
|
||||
int unlock(@Param("username") String username);
|
||||
|
||||
// ── TOTP(OTP 2차 인증) 이식: 시크릿/등록 확정 갱신 (멱등 UPDATE) ─────────────────
|
||||
|
||||
/** 보류 시크릿 저장(등록 확정 전). otp_enabled 은 건드리지 않는다(verify/confirm 에서 확정). */
|
||||
int updateOtpSecret(@Param("username") String username, @Param("secret") String secret);
|
||||
|
||||
/** 등록 확정: otp_enabled=true (시크릿은 유지). */
|
||||
int enableOtp(@Param("username") String username);
|
||||
|
||||
/** 해제/초기화: 시크릿 폐기 + otp_enabled=false. (마이페이지 해제) */
|
||||
int disableOtp(@Param("username") String username);
|
||||
|
||||
// ── 로그인 보조 이식: 회원가입(승인대기) / 아이디찾기 / 비밀번호 초기화 ──────────
|
||||
|
||||
/** 아이디 중복 확인. */
|
||||
|
||||
@ -0,0 +1,130 @@
|
||||
package com.zioinfo.esn.config;
|
||||
|
||||
import com.zioinfo.esn.auth.mapper.UserAuthMapper;
|
||||
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 방식 + 암호화 저장).
|
||||
*
|
||||
* <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>schema.sql init(admin 시드) 이후 실행되어 기존 해시를 덮어쓴다(멱등: 매 기동 동일 결과).
|
||||
*/
|
||||
@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 UserAuthMapper 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();
|
||||
}
|
||||
}
|
||||
@ -70,6 +70,16 @@ public class UserController {
|
||||
return ApiResponse.ok(active ? "활성화 완료" : "비활성화 완료", u);
|
||||
}
|
||||
|
||||
/** 관리자 OTP 초기화(사용자 관리 화면 버튼) — OTP 시크릿 폐기 → 다음 로그인 재등록 유도. */
|
||||
@PostMapping("/{id}/otp-reset")
|
||||
public ApiResponse<EsnUser> resetOtp(@PathVariable Long id) {
|
||||
EsnUser u = service.resetOtp(id);
|
||||
// 보안: OTP 시크릿 값은 기록하지 않는다(대상 id/username 요약만).
|
||||
audit.log(u != null ? u.getTenantCode() : null, "USER_OTP_RESET", "USER",
|
||||
String.valueOf(id), "OTP 초기화(다음 로그인 재등록)", true);
|
||||
return ApiResponse.ok("OTP 초기화 완료", u);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ApiResponse<Void> delete(@PathVariable Long id) {
|
||||
service.delete(id);
|
||||
|
||||
@ -15,4 +15,7 @@ public interface UserMapper {
|
||||
int update(EsnUser user);
|
||||
int updatePassword(@Param("id") Long id, @Param("passwordHash") String passwordHash);
|
||||
int delete(@Param("id") Long id);
|
||||
|
||||
/** 관리자 OTP 초기화: 대상 사용자의 OTP 시크릿 폐기 + 등록 해제(다음 로그인 재등록 유도). */
|
||||
int resetOtpById(@Param("id") Long id);
|
||||
}
|
||||
|
||||
@ -44,4 +44,13 @@ public class UserService {
|
||||
}
|
||||
|
||||
public void delete(Long id) { mapper.delete(id); }
|
||||
|
||||
/**
|
||||
* 관리자 OTP 초기화(사용자 관리 화면 버튼). 대상 사용자의 OTP 시크릿을 폐기하고 등록을 해제한다.
|
||||
* 사용자는 다음 로그인 시 OTP_SETUP(재등록) 플로우를 탄다. 시크릿은 응답/로그에 노출하지 않는다.
|
||||
*/
|
||||
public EsnUser resetOtp(Long id) {
|
||||
mapper.resetOtpById(id);
|
||||
return mapper.findById(id);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,167 @@
|
||||
package com.zioinfo.esn.uiws.auth;
|
||||
|
||||
import com.zioinfo.esn.admin.AuditService;
|
||||
import com.zioinfo.esn.auth.EsnUser;
|
||||
import com.zioinfo.esn.auth.JwtUtil;
|
||||
import com.zioinfo.esn.auth.OtpSetupResponse;
|
||||
import com.zioinfo.esn.auth.mapper.UserAuthMapper;
|
||||
import com.zioinfo.esn.uiws.common.UiwsApiException;
|
||||
import com.zioinfo.esn.uiws.common.UiwsErrorCode;
|
||||
import com.zioinfo.esn.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 경로 미러.
|
||||
*
|
||||
* <p>흐름:
|
||||
* <ol>
|
||||
* <li>1차 로그인 성공 → {@link #beginOtp}: verify-token 발급 + (미등록이면 보류 시크릿+QR) 반환.</li>
|
||||
* <li>{@code POST /verify-otp}(verifyToken+code) → {@link #verifyOtp}: 6자리 검증 후 access 발급.
|
||||
* 최초 로그인이면 등록 확정(otp_enabled=true).</li>
|
||||
* <li>마이페이지: {@link #setup}/{@link #confirm}/{@link #disable}.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>보안 불변: 시크릿·QR·otpauth URI 는 setup/OTP_SETUP 응답에서만 노출. 로그·감사에 시크릿/코드 미기록.
|
||||
* 기존 auth(JWT·RBAC) 엔진 교체 없음 — TOTP 레이어만 추가. access 는 ESN JwtUtil(3-클레임) 정책 재사용.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class OtpAuthService {
|
||||
|
||||
private final UserAuthMapper 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<String, String> beginOtp(EsnUser 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 발급. 최초 로그인이면 등록 확정.
|
||||
* @return { token, type, username, role, tenant } (ESN 2FA verify 응답 shape 동일)
|
||||
*/
|
||||
@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);
|
||||
}
|
||||
EsnUser 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("OTP_ENROLL", "USER", username, "최초 로그인 OTP 등록 확정");
|
||||
}
|
||||
userMapper.resetLoginFail(username);
|
||||
userMapper.updateLastLogin(username);
|
||||
|
||||
String access = jwtUtil.generate(user.getUsername(), user.getRole(), user.getTenantCode());
|
||||
Map<String, String> resp = new LinkedHashMap<>();
|
||||
resp.put("token", access);
|
||||
resp.put("type", "Bearer");
|
||||
resp.put("username", user.getUsername());
|
||||
resp.put("role", user.getRole() == null ? "" : user.getRole());
|
||||
resp.put("tenant", user.getTenantCode() == null ? "" : user.getTenantCode());
|
||||
return resp;
|
||||
}
|
||||
|
||||
/** 마이페이지 OTP 등록/재설정 시작: 새 보류 시크릿 발급(기존 시크릿 무효화) + QR 반환. */
|
||||
@Transactional
|
||||
public OtpSetupResponse setup(String username) {
|
||||
EsnUser 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("OTP_SETUP", "USER", 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) {
|
||||
EsnUser 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("OTP_ENABLE", "USER", username, "OTP 2차 인증 활성화");
|
||||
}
|
||||
|
||||
/** 마이페이지 OTP 해제: 시크릿 폐기 + otp_enabled=false. */
|
||||
@Transactional
|
||||
public void disable(String username) {
|
||||
EsnUser user = userMapper.findByUsername(username);
|
||||
if (user == null) {
|
||||
throw new UiwsApiException(UiwsErrorCode.USER_NOT_FOUND);
|
||||
}
|
||||
userMapper.disableOtp(username);
|
||||
auditService.log("OTP_DISABLE", "USER", username, "OTP 2차 인증 해제");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,81 @@
|
||||
package com.zioinfo.esn.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)은 esn_user 에 저장되어 있다고 가정. issuer 만 GUARDiA-ESN 로 교체.
|
||||
*
|
||||
* <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-ESN";
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,7 @@ public enum UiwsErrorCode {
|
||||
|
||||
// 공통
|
||||
INVALID_REQUEST("ERR-UIWS-400", "요청이 올바르지 않습니다."),
|
||||
UNAUTHORIZED("ERR-UIWS-401", "인증이 필요합니다."),
|
||||
FORBIDDEN("ERR-UIWS-403", "접근 권한이 없습니다."),
|
||||
NOT_FOUND("ERR-UIWS-404", "대상을 찾을 수 없습니다."),
|
||||
|
||||
@ -37,6 +38,11 @@ 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가 등록되어 있지 않습니다. 먼저 등록을 진행하세요."),
|
||||
|
||||
// 비밀번호 변경(마이페이지)
|
||||
PASSWORD_MISMATCH("ERR-UIWS-PW-401", "현재 비밀번호가 일치하지 않습니다."),
|
||||
PASSWORD_SAME_AS_OLD("ERR-UIWS-PW-422", "새 비밀번호가 기존 비밀번호와 동일합니다."),
|
||||
|
||||
// system(권한관리) — UIWS 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. 로그인 2단계 우선순위:
|
||||
* otpEnabled → OTP, (off 이고 twofaEnabled) → 이메일코드, (둘 다 off) → 단일 로그인.
|
||||
*/
|
||||
private boolean otpEnabled = true;
|
||||
/** 1차 통과 후 verify-token 유효시간(초). 계획서 300(5분). */
|
||||
private long verifyTokenValiditySeconds = 300;
|
||||
/** 이메일 인증코드 유효시간(초). 계획서 300(5분). */
|
||||
|
||||
@ -30,6 +30,8 @@ spring:
|
||||
- classpath:db/93_esn_login_helper.sql
|
||||
# AI 플랫폼(Claude 전환) provider/모델 설정 시드 — esn_setting ai.* (멱등)
|
||||
- classpath:db/94_seed_ai_config.sql
|
||||
# TOTP(OTP 2차 인증) — esn_user otp_enabled ALTER (멱등, otp_secret 은 91 에서 이미 추가)
|
||||
- classpath:db/95_auth_otp.sql
|
||||
web:
|
||||
resources:
|
||||
static-locations: classpath:/static/
|
||||
@ -63,6 +65,19 @@ guardia:
|
||||
secret: ${JWT_SECRET:ZioInfoEsnJwtSecret2026VeryLongSecretKeyForHS256AlgorithmAtLeast256Bits}
|
||||
expiration: 86400000
|
||||
|
||||
# UIWS 이식 인증 레이어(2FA/OTP). 미설정이어도 UiwsProperties 안전 기본값으로 동작.
|
||||
# 로그인 2단계 우선순위: otp-enabled → OTP, (off 이고 twofa-enabled) → 이메일코드, (둘 다 off) → 단일 로그인.
|
||||
esn:
|
||||
uiws:
|
||||
auth:
|
||||
# OTP(TOTP) 2단계 on/off. off 로 두면 기존 이메일 2FA/단일 로그인 흐름 유지(회귀 0).
|
||||
otp-enabled: ${ESN_OTP_ENABLED:true}
|
||||
# 이메일 코드 2FA on/off. OTP on 이면 OTP 가 우선(이메일 미사용).
|
||||
twofa-enabled: ${ESN_TWOFA_ENABLED:true}
|
||||
verify-token-validity-seconds: ${ESN_VERIFY_TOKEN_TTL:300}
|
||||
email-code-validity-seconds: ${ESN_EMAIL_CODE_TTL:300}
|
||||
max-login-fail: ${ESN_MAX_LOGIN_FAIL:5}
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
|
||||
13
backend/src/main/resources/db/95_auth_otp.sql
Normal file
13
backend/src/main/resources/db/95_auth_otp.sql
Normal file
@ -0,0 +1,13 @@
|
||||
-- =====================================================================
|
||||
-- TOTP(OTP 2차 인증) 이식 — esn_user 멱등 ALTER
|
||||
-- =====================================================================
|
||||
-- otp_secret 은 db/91_uiws_port.sql 에서 이미 ADD(VARCHAR(255)).
|
||||
-- 여기서는 등록 확정 플래그 otp_enabled 만 추가한다.
|
||||
-- schema-locations 마지막에 등재(mode=always + continue-on-error) → 멱등 재실행 안전.
|
||||
|
||||
ALTER TABLE esn_user ADD COLUMN IF NOT EXISTS otp_enabled BOOLEAN DEFAULT false;
|
||||
|
||||
COMMENT ON COLUMN esn_user.otp_secret IS 'TOTP 시크릿(보류/확정 공용). API 응답·로그 미노출.';
|
||||
COMMENT ON COLUMN esn_user.otp_enabled IS 'OTP 등록 확정 여부(최초 로그인 verify 성공 시 true).';
|
||||
|
||||
-- end 95_auth_otp.sql
|
||||
18
backend/src/main/resources/db/ops_otp_reset_all.sql
Normal file
18
backend/src/main/resources/db/ops_otp_reset_all.sql
Normal file
@ -0,0 +1,18 @@
|
||||
-- =====================================================================
|
||||
-- [1회 운영 작업] 전 사용자 OTP 초기화 — 멱등
|
||||
-- =====================================================================
|
||||
-- 목적: 인증 강화 전환 시점에 기존 사용자 OTP 를 초기화한다.
|
||||
-- 다음 로그인부터 OTP_SETUP(QR 재등록) 플로우를 타게 한다(UIMS 방식).
|
||||
--
|
||||
-- ★ 이 파일은 schema-locations 에 등재하지 않는다(자동 재실행 금지).
|
||||
-- 운영자가 서버에서 1회 수동 적용:
|
||||
-- psql -U guardia_esn_user -d guardia_esn_db -f db/ops_otp_reset_all.sql
|
||||
--
|
||||
-- 멱등: 여러 번 실행해도 결과 동일(모두 NULL/false).
|
||||
|
||||
UPDATE esn_user
|
||||
SET otp_secret = NULL,
|
||||
otp_enabled = false
|
||||
WHERE otp_secret IS NOT NULL OR otp_enabled IS DISTINCT FROM false;
|
||||
|
||||
-- end ops_otp_reset_all.sql
|
||||
@ -23,12 +23,13 @@
|
||||
<result property="loginFailCount" column="login_fail_count"/>
|
||||
<result property="locked" column="locked"/>
|
||||
<result property="otpSecret" column="otp_secret"/>
|
||||
<result property="otpEnabled" column="otp_enabled"/>
|
||||
</resultMap>
|
||||
|
||||
<select id="findByUsername" resultMap="userMap">
|
||||
SELECT id, tenant_code, username, password_hash, role, email, phone,
|
||||
is_active, last_login_at, created_at, name, approval_status,
|
||||
email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret
|
||||
email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret, otp_enabled
|
||||
FROM esn_user
|
||||
WHERE username = #{username}
|
||||
</select>
|
||||
@ -86,7 +87,7 @@
|
||||
<select id="findByUsernameNameEmail" resultMap="userMap">
|
||||
SELECT id, tenant_code, username, password_hash, role, email, phone,
|
||||
is_active, last_login_at, created_at, name, approval_status,
|
||||
email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret
|
||||
email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret, otp_enabled
|
||||
FROM esn_user
|
||||
WHERE username = #{username} AND name = #{name} AND email = #{email}
|
||||
</select>
|
||||
@ -95,4 +96,18 @@
|
||||
UPDATE esn_user SET password_hash = #{passwordHash} WHERE username = #{username}
|
||||
</update>
|
||||
|
||||
<!-- ── TOTP(OTP 2차 인증) 이식: 시크릿/등록 확정 갱신 (멱등 UPDATE) ───────────────── -->
|
||||
|
||||
<update id="updateOtpSecret">
|
||||
UPDATE esn_user SET otp_secret = #{secret} WHERE username = #{username}
|
||||
</update>
|
||||
|
||||
<update id="enableOtp">
|
||||
UPDATE esn_user SET otp_enabled = true WHERE username = #{username}
|
||||
</update>
|
||||
|
||||
<update id="disableOtp">
|
||||
UPDATE esn_user SET otp_secret = NULL, otp_enabled = false WHERE username = #{username}
|
||||
</update>
|
||||
|
||||
</mapper>
|
||||
|
||||
@ -58,4 +58,9 @@
|
||||
|
||||
<delete id="delete">DELETE FROM esn_user WHERE id = #{id}</delete>
|
||||
|
||||
<!-- 관리자 OTP 초기화: 시크릿 폐기 + 등록 해제(다음 로그인 시 OTP_SETUP 재등록). -->
|
||||
<update id="resetOtpById">
|
||||
UPDATE esn_user SET otp_secret = NULL, otp_enabled = false WHERE id = #{id}
|
||||
</update>
|
||||
|
||||
</mapper>
|
||||
|
||||
File diff suppressed because one or more lines are too long
483
backend/src/main/resources/static/assets/index-VsUCSXRR.js
Normal file
483
backend/src/main/resources/static/assets/index-VsUCSXRR.js
Normal file
File diff suppressed because one or more lines are too long
BIN
backend/src/main/resources/static/favicon.ico
Normal file
BIN
backend/src/main/resources/static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@ -4,9 +4,9 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>zioinfo-esn — ESL 통합 관리 플랫폼</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<script type="module" crossorigin src="/assets/index-Brirvf7y.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D3eowNUw.css">
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<script type="module" crossorigin src="/assets/index-VsUCSXRR.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D38igG4E.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>zioinfo-esn — ESL 통합 관리 플랫폼</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@ -21,6 +21,7 @@ import UpdateQueueList from './pages/UpdateQueueList'
|
||||
import AuditLog from './pages/AuditLog'
|
||||
import SystemSettings from './pages/SystemSettings'
|
||||
import MobileApp from './pages/MobileApp'
|
||||
import MyPage from './pages/MyPage'
|
||||
import WorklogList from './pages/uiws/WorklogList'
|
||||
import ScheduleCalendar from './pages/uiws/ScheduleCalendar'
|
||||
import MessageBox from './pages/uiws/MessageBox'
|
||||
@ -42,6 +43,7 @@ export default function App() {
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/mypage" element={<MyPage />} />
|
||||
<Route path="/stores" element={<StoreList />} />
|
||||
<Route path="/templates" element={<TemplateList />} />
|
||||
<Route path="/pos-cvt" element={<PosCvtList />} />
|
||||
|
||||
@ -29,6 +29,16 @@ export const login = (username: string, password: string) =>
|
||||
export const getMe = () => api.get('/api/auth/me')
|
||||
export const logout = () => api.post('/api/auth/logout')
|
||||
|
||||
// ── 2FA / OTP / 계정 보안 (마이페이지, access 토큰 필요) ─────────────────────
|
||||
// 보안 불변: setup 응답(secret/qrImage)은 화면 표시용만 — 로그/저장 금지. raw 응답 반환(호출부 res.data.data).
|
||||
export const otpSetup = () => api.post('/api/auth/otp/setup')
|
||||
export const otpConfirm = (code: string) => api.post('/api/auth/otp/confirm', { code })
|
||||
export const otpDisable = () => api.post('/api/auth/otp/disable')
|
||||
export const changePassword = (currentPassword: string, newPassword: string) =>
|
||||
api.post('/api/auth/change-password', { currentPassword, newPassword })
|
||||
// 관리자 OTP 초기화 — 대상 사용자 OTP 해제(다음 로그인 시 재등록). 시크릿 미조회.
|
||||
export const adminOtpReset = (id: number) => unwrap(api.post(`/api/users/${id}/otp-reset`))
|
||||
|
||||
// ── Auth: 로그인 보조 3종 (공개 — 회원가입 승인대기 / 아이디찾기 / 비밀번호 초기화) ──
|
||||
export interface SignupReq {
|
||||
username: string; password: string; name: string;
|
||||
|
||||
@ -6,9 +6,13 @@ import api from './client'
|
||||
* 응답 봉투: { success, message, data }. 호출부는 res.data.data 로 페이로드 접근.
|
||||
*/
|
||||
|
||||
// ── 2FA
|
||||
// ── 2FA (ESN auth prefix /api/auth)
|
||||
// verify2fa: 이메일 인증코드 경로(하위호환). verifyMethod=EMAIL 일 때 사용.
|
||||
export const verify2fa = (verifyToken: string, code: string) =>
|
||||
api.post('/api/auth/verify', { verifyToken, code })
|
||||
// verifyOtp: Authenticator(TOTP) 경로. verifyMethod=OTP | OTP_SETUP 일 때 사용.
|
||||
export const verifyOtp = (verifyToken: string, code: string) =>
|
||||
api.post('/api/auth/verify-otp', { verifyToken, code })
|
||||
|
||||
// ── 쪽지(message)
|
||||
export const sendMessage = (body: object) => api.post('/api/messages', body)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import { LogOut, User } from 'lucide-react'
|
||||
|
||||
export default function Header() {
|
||||
@ -20,11 +20,11 @@ export default function Header() {
|
||||
<div className="flex-1" />
|
||||
<span className="text-xs text-gray-400">테넌트: <span className="text-brand">{tenant}</span></span>
|
||||
<span className="text-xs text-gray-400">|</span>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-300">
|
||||
<Link to="/mypage" className="flex items-center gap-2 text-sm text-gray-300 hover:text-brand transition-colors" title="마이페이지">
|
||||
<User size={14} />
|
||||
<span>{user}</span>
|
||||
<span className="text-xs text-brand bg-brand/10 px-2 py-0.5 rounded">{role}</span>
|
||||
</div>
|
||||
</Link>
|
||||
<button onClick={handleLogout} className="flex items-center gap-1.5 text-gray-400 hover:text-white text-sm">
|
||||
<LogOut size={14} />
|
||||
로그아웃
|
||||
|
||||
@ -1,15 +1,20 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { login, signup, findId, resetPassword } from '../api/client'
|
||||
import { verify2fa } from '../api/uiws'
|
||||
import { verify2fa, verifyOtp } from '../api/uiws'
|
||||
|
||||
type HelperMode = null | 'signup' | 'findId' | 'resetPw'
|
||||
type VerifyMethod = 'OTP' | 'OTP_SETUP' | 'EMAIL'
|
||||
|
||||
/**
|
||||
* 로그인 화면. UIWS 2FA 이식 반영:
|
||||
* 로그인 화면. UIWS 2FA 이식 + Authenticator(OTP) 확장:
|
||||
* - 2FA off 응답({ twofa:"false", token }) → 기존처럼 즉시 로그인(회귀 0).
|
||||
* - 2FA on 응답({ twofa:"true", verifyToken, maskedEmail }) → 인증코드 입력 단계로 전환.
|
||||
* - 2FA on 응답({ twofa:"true", verifyToken, verifyMethod, ... }) → 2차 분기.
|
||||
* · OTP : Authenticator 6자리 → /verify-otp
|
||||
* · OTP_SETUP : QR(qrImage)+수동키(secret) 등록 후 6자리 → /verify-otp
|
||||
* · EMAIL(기타): 이메일 인증코드 6자리 → /verify (하위호환)
|
||||
* 색상은 ESN Tailwind 테마 토큰(bg-card/text-brand/border-edge…)만 사용 — 하드코딩 없음.
|
||||
* 보안: OTP secret/QR·인증코드는 화면 표시용만 — 로그/저장 절대 금지.
|
||||
*/
|
||||
export default function Login() {
|
||||
const navigate = useNavigate()
|
||||
@ -19,12 +24,18 @@ export default function Login() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [step, setStep] = useState<'login' | 'verify'>('login')
|
||||
const [verifyToken, setVerifyToken] = useState('')
|
||||
const [verifyMethod, setVerifyMethod] = useState<VerifyMethod>('EMAIL')
|
||||
const [maskedEmail, setMaskedEmail] = useState('')
|
||||
const [qrImage, setQrImage] = useState('') // OTP_SETUP 등록 순간만 존재
|
||||
const [secret, setSecret] = useState('') // OTP_SETUP 등록 순간만 존재
|
||||
const [code, setCode] = useState('')
|
||||
|
||||
// ── 로그인 보조 3종 (회원가입/아이디찾기/비밀번호 재설정) 모달 ──────────────────
|
||||
const [helper, setHelper] = useState<HelperMode>(null)
|
||||
|
||||
const isOtp = verifyMethod === 'OTP' || verifyMethod === 'OTP_SETUP'
|
||||
const isSetup = verifyMethod === 'OTP_SETUP'
|
||||
|
||||
function finishLogin(token: string) {
|
||||
localStorage.setItem('esn_token', token)
|
||||
localStorage.setItem('esn_user', username)
|
||||
@ -40,7 +51,11 @@ export default function Login() {
|
||||
const data = res.data?.data
|
||||
if (data?.twofa === 'true') {
|
||||
setVerifyToken(data.verifyToken)
|
||||
setVerifyMethod((data.verifyMethod as VerifyMethod) || 'EMAIL')
|
||||
setMaskedEmail(data.maskedEmail || '')
|
||||
setQrImage(data.qrImage || '')
|
||||
setSecret(data.secret || '')
|
||||
setCode('')
|
||||
setStep('verify')
|
||||
} else {
|
||||
if (!data?.token) throw new Error('토큰 없음')
|
||||
@ -58,8 +73,13 @@ export default function Login() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await verify2fa(verifyToken, code)
|
||||
finishLogin(res.data?.data?.token)
|
||||
// OTP·OTP_SETUP 은 /verify-otp, 이메일은 /verify(하위호환)
|
||||
const res = isOtp
|
||||
? await verifyOtp(verifyToken, code)
|
||||
: await verify2fa(verifyToken, code)
|
||||
const token = res.data?.data?.token
|
||||
if (!token) throw new Error('토큰 없음')
|
||||
finishLogin(token)
|
||||
} catch {
|
||||
setError('인증 코드가 올바르지 않거나 만료되었습니다.')
|
||||
} finally {
|
||||
@ -67,6 +87,11 @@ export default function Login() {
|
||||
}
|
||||
}
|
||||
|
||||
function backToLogin() {
|
||||
setStep('login'); setCode(''); setError('')
|
||||
setQrImage(''); setSecret('') // 시크릿 잔존 방지
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-ink flex items-center justify-center">
|
||||
<div className="w-full max-w-sm">
|
||||
@ -119,31 +144,62 @@ export default function Login() {
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleVerify} className="bg-card border border-edge rounded-lg p-6 space-y-4">
|
||||
{isOtp ? (
|
||||
isSetup ? (
|
||||
<>
|
||||
<p className="text-xs text-gray-400">
|
||||
최초 로그인입니다. 아래 QR을 Authenticator 앱(Google·Microsoft)으로 스캔해
|
||||
등록한 뒤 6자리 코드를 입력하세요.
|
||||
</p>
|
||||
{qrImage && (
|
||||
<div className="flex justify-center">
|
||||
<img src={qrImage} alt="OTP QR" width={176} height={176}
|
||||
style={{ background: '#fff', padding: 8, borderRadius: 8 }} />
|
||||
</div>
|
||||
)}
|
||||
{secret && (
|
||||
<div>
|
||||
<div className="text-[11px] text-gray-500 mb-1">QR 스캔이 안 되면 수동 입력 키</div>
|
||||
<code className="block text-xs text-accent bg-panel border border-edge rounded px-2 py-1.5 break-all select-all">
|
||||
{secret}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-gray-400">
|
||||
Authenticator 앱에 표시된 6자리 코드를 입력하세요.
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
<p className="text-xs text-gray-400">
|
||||
{maskedEmail ? `${maskedEmail} 로 발송된 인증코드를 입력하세요.` : '발송된 인증코드를 입력하세요.'}
|
||||
</p>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">인증코드 6자리</label>
|
||||
<input
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={6}
|
||||
value={code}
|
||||
onChange={e => setCode(e.target.value)}
|
||||
onChange={e => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="000000"
|
||||
autoFocus
|
||||
className="w-full bg-panel border border-edge rounded px-3 py-2 text-sm text-white text-center tracking-[0.4em] placeholder-gray-600 focus:border-brand focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
disabled={loading || code.length !== 6}
|
||||
className="w-full bg-brand hover:bg-brand2 text-white py-2 rounded text-sm font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? '인증 중...' : '인증하고 로그인'}
|
||||
{loading ? '인증 중...' : isSetup ? '등록하고 로그인' : '인증하고 로그인'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setStep('login'); setCode(''); setError('') }}
|
||||
onClick={backToLogin}
|
||||
className="w-full border border-edge text-gray-400 hover:text-white py-2 rounded text-sm transition-colors"
|
||||
>
|
||||
뒤로
|
||||
|
||||
256
frontend/src/pages/MyPage.tsx
Normal file
256
frontend/src/pages/MyPage.tsx
Normal file
@ -0,0 +1,256 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
ShieldCheck, QrCode, Lock, Eye, EyeOff, CheckCircle2, AlertTriangle,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
getMe, otpSetup, otpConfirm, otpDisable, changePassword,
|
||||
} from '../api/client'
|
||||
|
||||
/**
|
||||
* 마이페이지 — OTP 2차 인증(등록/재설정/해제) + 비밀번호 변경.
|
||||
* ESN 테마 토큰(bg-card/bg-panel/border-edge/text-brand/accent)만 사용 — 하드코딩 없음.
|
||||
* 보안: setup 응답의 secret/qrImage 는 화면 표시용만 — 로그/저장 절대 금지.
|
||||
* 새 비밀번호는 콘솔/응답에 출력 금지, 검증 4대(필수·8자·일치·현재와 다름).
|
||||
*/
|
||||
const MIN_PW = 8
|
||||
|
||||
type OtpPhase = 'idle' | 'setup' | 'done'
|
||||
|
||||
function errMsg(e: any, fallback: string): string {
|
||||
if (e?.response?.status === 403) return '권한이 없습니다.'
|
||||
return e?.response?.data?.message || fallback
|
||||
}
|
||||
|
||||
export default function MyPage() {
|
||||
const [username, setUsername] = useState('')
|
||||
const [otpEnabled, setOtpEnabled] = useState<boolean | null>(null)
|
||||
|
||||
// ── OTP 상태머신 ──────────────────────────────────────────────────────
|
||||
const [phase, setPhase] = useState<OtpPhase>('idle')
|
||||
const [qrImage, setQrImage] = useState('')
|
||||
const [secret, setSecret] = useState('')
|
||||
const [otpCode, setOtpCode] = useState('')
|
||||
const [otpBusy, setOtpBusy] = useState(false)
|
||||
const [otpMsg, setOtpMsg] = useState<{ ok: boolean; text: string } | null>(null)
|
||||
|
||||
// ── 비밀번호 변경 ─────────────────────────────────────────────────────
|
||||
const [curPw, setCurPw] = useState('')
|
||||
const [newPw, setNewPw] = useState('')
|
||||
const [newPw2, setNewPw2] = useState('')
|
||||
const [showPw, setShowPw] = useState(false)
|
||||
const [pwBusy, setPwBusy] = useState(false)
|
||||
const [pwMsg, setPwMsg] = useState<{ ok: boolean; text: string } | null>(null)
|
||||
|
||||
const loadMe = () =>
|
||||
getMe().then(r => {
|
||||
const me = r.data?.data || {}
|
||||
setUsername(me.username || localStorage.getItem('esn_user') || '')
|
||||
// 백엔드가 상태를 내려주면 반영, 없으면 null(중립 표시)
|
||||
if (typeof me.otpEnabled === 'boolean') setOtpEnabled(me.otpEnabled)
|
||||
else if (typeof me.verifyMethod === 'string') setOtpEnabled(me.verifyMethod === 'OTP')
|
||||
}).catch(() => {})
|
||||
|
||||
useEffect(() => { loadMe() }, [])
|
||||
|
||||
const startSetup = async () => {
|
||||
setOtpMsg(null); setOtpBusy(true)
|
||||
try {
|
||||
const res = await otpSetup()
|
||||
const d = res.data?.data || {}
|
||||
setQrImage(d.qrImage || '')
|
||||
setSecret(d.secret || '')
|
||||
setOtpCode('')
|
||||
setPhase('setup')
|
||||
} catch (e) {
|
||||
setOtpMsg({ ok: false, text: errMsg(e, 'OTP 등록을 시작하지 못했습니다.') })
|
||||
} finally { setOtpBusy(false) }
|
||||
}
|
||||
|
||||
const confirmOtp = async () => {
|
||||
setOtpMsg(null); setOtpBusy(true)
|
||||
try {
|
||||
await otpConfirm(otpCode)
|
||||
setSecret(''); setQrImage(''); setOtpCode('') // 시크릿 잔존 방지
|
||||
setPhase('done'); setOtpEnabled(true)
|
||||
setOtpMsg({ ok: true, text: '2차 인증이 활성화되었습니다.' })
|
||||
} catch (e) {
|
||||
setOtpMsg({ ok: false, text: errMsg(e, '코드가 일치하지 않거나 만료되었습니다.') })
|
||||
} finally { setOtpBusy(false) }
|
||||
}
|
||||
|
||||
const cancelSetup = () => {
|
||||
setPhase('idle'); setSecret(''); setQrImage(''); setOtpCode(''); setOtpMsg(null)
|
||||
}
|
||||
|
||||
const disableOtp = async () => {
|
||||
if (!window.confirm('Authenticator 2차 인증을 해제하시겠습니까?')) return
|
||||
setOtpMsg(null); setOtpBusy(true)
|
||||
try {
|
||||
await otpDisable()
|
||||
setPhase('idle'); setSecret(''); setQrImage(''); setOtpEnabled(false)
|
||||
setOtpMsg({ ok: true, text: '2차 인증이 해제되었습니다.' })
|
||||
} catch (e) {
|
||||
setOtpMsg({ ok: false, text: errMsg(e, 'OTP 해제에 실패했습니다.') })
|
||||
} finally { setOtpBusy(false) }
|
||||
}
|
||||
|
||||
const submitPw = async () => {
|
||||
setPwMsg(null)
|
||||
if (!curPw) { setPwMsg({ ok: false, text: '현재 비밀번호를 입력하세요.' }); return }
|
||||
if (newPw.length < MIN_PW) { setPwMsg({ ok: false, text: `새 비밀번호는 최소 ${MIN_PW}자 이상이어야 합니다.` }); return }
|
||||
if (newPw !== newPw2) { setPwMsg({ ok: false, text: '새 비밀번호가 일치하지 않습니다.' }); return }
|
||||
if (newPw === curPw) { setPwMsg({ ok: false, text: '새 비밀번호는 현재 비밀번호와 달라야 합니다.' }); return }
|
||||
setPwBusy(true)
|
||||
try {
|
||||
await changePassword(curPw, newPw)
|
||||
setCurPw(''); setNewPw(''); setNewPw2('')
|
||||
setPwMsg({ ok: true, text: '비밀번호가 변경되었습니다.' })
|
||||
} catch (e) {
|
||||
setPwMsg({ ok: false, text: errMsg(e, '비밀번호 변경에 실패했습니다.') })
|
||||
} finally { setPwBusy(false) }
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
'w-full px-3 py-2 rounded bg-panel border border-edge text-sm text-white focus:border-brand outline-none'
|
||||
const codeCls = inputCls + ' tracking-[0.4em] text-center text-lg'
|
||||
|
||||
const StatusMsg = ({ m }: { m: { ok: boolean; text: string } | null }) =>
|
||||
m ? (
|
||||
<div className={`flex items-center gap-2 text-sm rounded px-3 py-2 mb-3 border ${
|
||||
m.ok
|
||||
? 'bg-accent/10 border-accent/40 text-accent'
|
||||
: 'bg-red-500/10 border-red-500/40 text-red-300'
|
||||
}`}>
|
||||
{m.ok ? <CheckCircle2 size={16} /> : <AlertTriangle size={16} />}
|
||||
{m.text}
|
||||
</div>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<h1 className="text-xl font-bold text-white mb-1">마이페이지</h1>
|
||||
<p className="text-sm text-gray-400 mb-6">{username && <>계정: <span className="text-gray-200">{username}</span></>}</p>
|
||||
|
||||
{/* ── OTP 2차 인증 ──────────────────────────────────────────────── */}
|
||||
<section className="bg-card border border-edge rounded-lg p-5 mb-6">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<ShieldCheck size={18} className="text-brand" />
|
||||
<h2 className="font-semibold text-white">2차 인증 — Authenticator(OTP)</h2>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mb-4">
|
||||
Google·Microsoft Authenticator 앱으로 6자리 코드를 사용하는 2차 인증을 설정합니다.
|
||||
{otpEnabled !== null && (
|
||||
<span className="ml-2">
|
||||
현재 상태:{' '}
|
||||
<span className={otpEnabled ? 'text-accent' : 'text-gray-300'}>
|
||||
{otpEnabled ? 'OTP 사용 중' : '미설정'}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<StatusMsg m={otpMsg} />
|
||||
|
||||
{phase === 'idle' && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button onClick={startSetup} disabled={otpBusy}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded bg-brand hover:bg-brand2 text-white text-sm font-semibold transition-colors disabled:opacity-60">
|
||||
<QrCode size={16} /> {otpBusy ? '발급 중…' : otpEnabled ? 'OTP 재설정 시작' : 'OTP 등록 시작'}
|
||||
</button>
|
||||
{otpEnabled && (
|
||||
<button onClick={disableOtp} disabled={otpBusy}
|
||||
className="px-3 py-2 rounded border border-red-500/50 text-red-300 text-sm hover:bg-red-500/10 disabled:opacity-60">
|
||||
Authenticator 해제
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'setup' && (
|
||||
<div>
|
||||
<ol className="list-decimal list-inside text-sm text-gray-300 leading-7 mb-3">
|
||||
<li>Authenticator 앱에서 아래 QR을 스캔하세요.</li>
|
||||
<li>스캔이 안 되면 수동 키를 직접 입력하세요.</li>
|
||||
<li>앱에 표시된 6자리 코드를 입력해 확인하세요.</li>
|
||||
</ol>
|
||||
{qrImage && (
|
||||
<div className="flex justify-center mb-3">
|
||||
<img src={qrImage} alt="OTP QR" width={200} height={200}
|
||||
style={{ background: '#fff', padding: 8, borderRadius: 8 }} />
|
||||
</div>
|
||||
)}
|
||||
{secret && (
|
||||
<div className="mb-4">
|
||||
<div className="text-[11px] text-gray-500 mb-1">수동 입력 키</div>
|
||||
<code className="block text-xs text-accent bg-panel border border-edge rounded px-2 py-1.5 break-all select-all">
|
||||
{secret}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
<label className="block text-xs text-gray-400 mb-1">앱에 표시된 6자리 코드</label>
|
||||
<input value={otpCode} onChange={e => setOtpCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
inputMode="numeric" autoComplete="one-time-code" maxLength={6} placeholder="000000"
|
||||
className={codeCls + ' mb-3'} />
|
||||
<div className="flex gap-2">
|
||||
<button onClick={confirmOtp} disabled={otpBusy || otpCode.length !== 6}
|
||||
className="px-4 py-2 rounded bg-brand hover:bg-brand2 text-white text-sm font-semibold transition-colors disabled:opacity-60">
|
||||
{otpBusy ? '확인 중…' : '코드 확인 · 활성화'}
|
||||
</button>
|
||||
<button onClick={cancelSetup} disabled={otpBusy}
|
||||
className="px-4 py-2 rounded border border-edge text-sm text-gray-300 hover:text-white">
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'done' && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button onClick={disableOtp} disabled={otpBusy}
|
||||
className="px-3 py-2 rounded border border-red-500/50 text-red-300 text-sm hover:bg-red-500/10 disabled:opacity-60">
|
||||
Authenticator 해제
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── 비밀번호 변경 ─────────────────────────────────────────────── */}
|
||||
<section className="bg-card border border-edge rounded-lg p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Lock size={18} className="text-brand" />
|
||||
<h2 className="font-semibold text-white">비밀번호 변경</h2>
|
||||
</div>
|
||||
|
||||
<StatusMsg m={pwMsg} />
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">현재 비밀번호</label>
|
||||
<input type={showPw ? 'text' : 'password'} value={curPw} autoComplete="current-password"
|
||||
onChange={e => setCurPw(e.target.value)} className={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">새 비밀번호 (최소 {MIN_PW}자)</label>
|
||||
<input type={showPw ? 'text' : 'password'} value={newPw} autoComplete="new-password"
|
||||
onChange={e => setNewPw(e.target.value)} className={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">새 비밀번호 확인</label>
|
||||
<input type={showPw ? 'text' : 'password'} value={newPw2} autoComplete="new-password"
|
||||
onChange={e => setNewPw2(e.target.value)} className={inputCls} />
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-xs text-gray-400 cursor-pointer select-none">
|
||||
<button type="button" onClick={() => setShowPw(!showPw)} className="text-gray-400 hover:text-brand">
|
||||
{showPw ? <EyeOff size={15} /> : <Eye size={15} />}
|
||||
</button>
|
||||
비밀번호 표시
|
||||
</label>
|
||||
<button onClick={submitPw} disabled={pwBusy}
|
||||
className="px-4 py-2 rounded bg-brand hover:bg-brand2 text-white text-sm font-semibold transition-colors disabled:opacity-60">
|
||||
{pwBusy ? '변경 중…' : '비밀번호 변경'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Plus, Edit, Trash2, X } from 'lucide-react'
|
||||
import { getUsers, createUser, updateUser, deleteUser } from '../api/client'
|
||||
import { Plus, Edit, Trash2, X, RefreshCw } from 'lucide-react'
|
||||
import { getUsers, createUser, updateUser, deleteUser, adminOtpReset } from '../api/client'
|
||||
|
||||
export default function UserList() {
|
||||
const qc = useQueryClient()
|
||||
@ -22,6 +22,12 @@ export default function UserList() {
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['users'] }),
|
||||
})
|
||||
|
||||
// 관리자 OTP 초기화 — 대상 사용자 OTP 해제(다음 로그인 재등록). 시크릿 미조회.
|
||||
const otpResetMut = useMutation({
|
||||
mutationFn: adminOtpReset,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['users'] }),
|
||||
})
|
||||
|
||||
if (isLoading) return <div className="text-gray-400 p-8">로딩 중...</div>
|
||||
|
||||
return (
|
||||
@ -66,9 +72,14 @@ export default function UserList() {
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setModal(u)} className="text-brand hover:text-blue-300"><Edit size={14} /></button>
|
||||
<button onClick={() => setModal(u)} title="수정" className="text-brand hover:text-blue-300"><Edit size={14} /></button>
|
||||
<button onClick={() => {
|
||||
if (confirm(`'${u.username}' 사용자의 OTP를 초기화하시겠습니까?\n초기화하면 다음 로그인 시 재등록해야 합니다.`))
|
||||
otpResetMut.mutate(u.id)
|
||||
}}
|
||||
title="OTP 초기화" className="text-gray-400 hover:text-brand"><RefreshCw size={14} /></button>
|
||||
<button onClick={() => { if(confirm('삭제?')) deleteMut.mutate(u.id) }}
|
||||
className="text-red-400 hover:text-red-300"><Trash2 size={14} /></button>
|
||||
title="삭제" className="text-red-400 hover:text-red-300"><Trash2 size={14} /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user