feat(auth): OTP 2FA 백+프론트
This commit is contained in:
parent
d404678d4c
commit
933c8e71bd
@ -50,6 +50,9 @@
|
|||||||
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-impl</artifactId><version>${jjwt.version}</version><scope>runtime</scope></dependency>
|
<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>io.jsonwebtoken</groupId><artifactId>jjwt-jackson</artifactId><version>${jjwt.version}</version><scope>runtime</scope></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>
|
||||||
|
|
||||||
<!-- OpenAPI -->
|
<!-- OpenAPI -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springdoc</groupId>
|
<groupId>org.springdoc</groupId>
|
||||||
|
|||||||
@ -51,6 +51,31 @@ public class AdminController {
|
|||||||
return ApiResponse.ok(null);
|
return ApiResponse.ok(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 OTP 초기화(SUPERADMIN). 대상 사용자의 OTP 시크릿을 폐기하고 등록을 해제한다.
|
||||||
|
* 사용자는 다음 로그인 시 OTP_SETUP(QR 재등록) 플로우를 탄다. 시크릿은 응답/로그에 노출하지 않는다.
|
||||||
|
*/
|
||||||
|
@PostMapping("/users/{id}/otp-reset")
|
||||||
|
public ApiResponse<HrmUser> resetOtp(@PathVariable Long id, Authentication auth) {
|
||||||
|
HrmUser user = adminMapper.findUserById(id);
|
||||||
|
if (user == null) {
|
||||||
|
throw new RuntimeException("ERR-HRM-404: 대상 사용자를 찾을 수 없습니다");
|
||||||
|
}
|
||||||
|
adminMapper.clearOtp(id);
|
||||||
|
|
||||||
|
Map<String, Object> log = new HashMap<>();
|
||||||
|
log.put("actor", AuthSupport.actor(auth));
|
||||||
|
log.put("action", "USER_OTP_RESET");
|
||||||
|
log.put("targetType", "auth.otp");
|
||||||
|
log.put("targetId", user.getUsername());
|
||||||
|
log.put("detail", "OTP 초기화(다음 로그인 재등록)");
|
||||||
|
log.put("ipAddr", null);
|
||||||
|
adminMapper.insertAuditLog(log);
|
||||||
|
|
||||||
|
user.setPasswordHash(null); // 비밀번호 해시 미노출
|
||||||
|
return ApiResponse.ok(user);
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/audit")
|
@GetMapping("/audit")
|
||||||
public ApiResponse<Map<String, Object>> audit(
|
public ApiResponse<Map<String, Object>> audit(
|
||||||
@RequestParam(required = false) String actor,
|
@RequestParam(required = false) String actor,
|
||||||
|
|||||||
@ -14,6 +14,8 @@ public interface AdminMapper {
|
|||||||
int insertUser(HrmUser user);
|
int insertUser(HrmUser user);
|
||||||
int updateUser(HrmUser user);
|
int updateUser(HrmUser user);
|
||||||
int updateUserActive(@Param("id") Long id, @Param("active") boolean active);
|
int updateUserActive(@Param("id") Long id, @Param("active") boolean active);
|
||||||
|
/** 관리자 OTP 초기화: 시크릿 폐기 + otp_enabled=false(다음 로그인 시 재등록 유도). 시크릿 미노출. */
|
||||||
|
int clearOtp(@Param("id") Long id);
|
||||||
List<Map<String, Object>> findAuditLogs(@Param("actor") String actor,
|
List<Map<String, Object>> findAuditLogs(@Param("actor") String actor,
|
||||||
@Param("action") String action,
|
@Param("action") String action,
|
||||||
@Param("offset") int offset,
|
@Param("offset") int offset,
|
||||||
|
|||||||
@ -1,7 +1,15 @@
|
|||||||
package com.zioinfo.hrm.auth;
|
package com.zioinfo.hrm.auth;
|
||||||
|
|
||||||
|
import com.zioinfo.hrm.auth.dto.ChangePasswordRequest;
|
||||||
|
import com.zioinfo.hrm.auth.dto.OtpConfirmRequest;
|
||||||
|
import com.zioinfo.hrm.auth.dto.OtpSetupResponse;
|
||||||
|
import com.zioinfo.hrm.auth.dto.OtpVerifyRequest;
|
||||||
import com.zioinfo.hrm.common.ApiResponse;
|
import com.zioinfo.hrm.common.ApiResponse;
|
||||||
|
import com.zioinfo.hrm.uiws.auth.OtpAuthService;
|
||||||
import com.zioinfo.hrm.uiws.auth.TwoFactorService;
|
import com.zioinfo.hrm.uiws.auth.TwoFactorService;
|
||||||
|
import com.zioinfo.hrm.uiws.common.UiwsApiException;
|
||||||
|
import com.zioinfo.hrm.uiws.common.UiwsErrorCode;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
@ -9,8 +17,11 @@ import java.util.Map;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* HRM 인증 컨트롤러.
|
* HRM 인증 컨트롤러.
|
||||||
* - /login: 2FA off 면 { token, type, twofa:"false" }, 2FA on 이면 { twofa:"true", verifyToken, step, maskedEmail }.
|
* - /login: 2FA off 면 { token, type, twofa:"false" }.
|
||||||
* - /verify: (UIWS 2FA 이식) verify-token + 인증코드 → access/refresh 발급.
|
* OTP on 이면 { twofa:"true", verifyToken, verifyMethod:(OTP|OTP_SETUP), secret?, otpAuthUri?, qrImage? }.
|
||||||
|
* 이메일 2FA on 이면 { twofa:"true", verifyToken, step, maskedEmail }.
|
||||||
|
* - /verify-otp: (UIMS 방식) verify-token + 6자리 → access/refresh(최초 로그인이면 등록 확정).
|
||||||
|
* - /verify: (이메일 2FA) verify-token + 인증코드 → access/refresh.
|
||||||
* 기존 클라이언트(2FA off)는 응답에 token 보존 → 회귀 0.
|
* 기존 클라이언트(2FA off)는 응답에 token 보존 → 회귀 0.
|
||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@ -20,24 +31,75 @@ public class AuthController {
|
|||||||
|
|
||||||
private final AuthService authService;
|
private final AuthService authService;
|
||||||
private final TwoFactorService twoFactorService;
|
private final TwoFactorService twoFactorService;
|
||||||
|
private final OtpAuthService otpAuthService;
|
||||||
|
private final JwtUtil jwtUtil;
|
||||||
|
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
public ApiResponse<Map<String, String>> login(@RequestBody LoginRequest req) {
|
public ApiResponse<Map<String, String>> login(@RequestBody LoginRequest req) {
|
||||||
return ApiResponse.ok(authService.login(req.username(), req.password()));
|
return ApiResponse.ok(authService.login(req.username(), req.password()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** UIWS 2FA 이식: 2차 인증 코드 검증 → access/refresh 발급. */
|
/** 이메일 2FA 이식: 2차 인증 코드 검증 → access/refresh 발급. */
|
||||||
@PostMapping("/verify")
|
@PostMapping("/verify")
|
||||||
public ApiResponse<Map<String, String>> verify(@RequestBody VerifyRequest req) {
|
public ApiResponse<Map<String, String>> verify(@RequestBody VerifyRequest req) {
|
||||||
return ApiResponse.ok(twoFactorService.verify(req.verifyToken(), req.code()));
|
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")
|
@GetMapping("/me")
|
||||||
public ApiResponse<Map<String, Object>> me(@RequestHeader("Authorization") String header) {
|
public ApiResponse<Map<String, Object>> me(@RequestHeader("Authorization") String header) {
|
||||||
String token = header.replace("Bearer ", "");
|
String token = header.replace("Bearer ", "");
|
||||||
return ApiResponse.ok(authService.me(token));
|
return ApiResponse.ok(authService.me(token));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 마이페이지: 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/hrm/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 LoginRequest(String username, String password) {}
|
||||||
|
|
||||||
record VerifyRequest(String verifyToken, String code) {}
|
record VerifyRequest(String verifyToken, String code) {}
|
||||||
|
|||||||
@ -5,7 +5,9 @@ import com.zioinfo.hrm.auth.dto.FindIdResponse;
|
|||||||
import com.zioinfo.hrm.auth.dto.ResetPwRequest;
|
import com.zioinfo.hrm.auth.dto.ResetPwRequest;
|
||||||
import com.zioinfo.hrm.auth.dto.SignupRequest;
|
import com.zioinfo.hrm.auth.dto.SignupRequest;
|
||||||
import com.zioinfo.hrm.auth.dto.SignupResponse;
|
import com.zioinfo.hrm.auth.dto.SignupResponse;
|
||||||
|
import com.zioinfo.hrm.auth.dto.ChangePasswordRequest;
|
||||||
import com.zioinfo.hrm.auth.mapper.UserMapper;
|
import com.zioinfo.hrm.auth.mapper.UserMapper;
|
||||||
|
import com.zioinfo.hrm.uiws.auth.OtpAuthService;
|
||||||
import com.zioinfo.hrm.uiws.auth.TwoFactorService;
|
import com.zioinfo.hrm.uiws.auth.TwoFactorService;
|
||||||
import com.zioinfo.hrm.uiws.common.UiwsApiException;
|
import com.zioinfo.hrm.uiws.common.UiwsApiException;
|
||||||
import com.zioinfo.hrm.uiws.common.UiwsErrorCode;
|
import com.zioinfo.hrm.uiws.common.UiwsErrorCode;
|
||||||
@ -36,6 +38,7 @@ public class AuthService {
|
|||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final JwtUtil jwtUtil;
|
private final JwtUtil jwtUtil;
|
||||||
private final TwoFactorService twoFactorService;
|
private final TwoFactorService twoFactorService;
|
||||||
|
private final OtpAuthService otpAuthService;
|
||||||
private final MailSender mailSender;
|
private final MailSender mailSender;
|
||||||
|
|
||||||
private static final SecureRandom RANDOM = new SecureRandom();
|
private static final SecureRandom RANDOM = new SecureRandom();
|
||||||
@ -65,8 +68,8 @@ public class AuthService {
|
|||||||
throw new RuntimeException("ERR-AUTH-003: 승인 대기 중인 계정입니다. 관리자 승인 후 로그인하세요.");
|
throw new RuntimeException("ERR-AUTH-003: 승인 대기 중인 계정입니다. 관리자 승인 후 로그인하세요.");
|
||||||
}
|
}
|
||||||
if (!passwordEncoder.matches(password, user.getPasswordHash())) {
|
if (!passwordEncoder.matches(password, user.getPasswordHash())) {
|
||||||
// 2FA 활성 시 실패 누적/잠금. 비활성 시 기존 동작(메시지만) 유지.
|
// 2FA(OTP 또는 이메일) 활성 시 실패 누적/잠금. 비활성 시 기존 동작(메시지만) 유지.
|
||||||
if (twoFactorService.isEnabled()) {
|
if (otpAuthService.isEnabled() || twoFactorService.isEnabled()) {
|
||||||
twoFactorService.recordLoginFailure(username);
|
twoFactorService.recordLoginFailure(username);
|
||||||
HrmUser after = userMapper.findByUsername(username);
|
HrmUser after = userMapper.findByUsername(username);
|
||||||
if (after != null && Boolean.TRUE.equals(after.getLocked())) {
|
if (after != null && Boolean.TRUE.equals(after.getLocked())) {
|
||||||
@ -76,7 +79,11 @@ public class AuthService {
|
|||||||
throw new RuntimeException("ERR-AUTH-002: 비밀번호 불일치");
|
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()) {
|
if (twoFactorService.isEnabled()) {
|
||||||
Map<String, String> step1 = twoFactorService.beginTwoFactor(user);
|
Map<String, String> step1 = twoFactorService.beginTwoFactor(user);
|
||||||
return Map.of(
|
return Map.of(
|
||||||
@ -103,6 +110,27 @@ public class AuthService {
|
|||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마이페이지 비밀번호 변경(현재 비밀번호 검증 + BCrypt 저장). UIMS changePassword 미러.
|
||||||
|
* 현재 비번 불일치 → PASSWORD_MISMATCH, 기존과 동일 → PASSWORD_SAME_AS_OLD.
|
||||||
|
* 새 비밀번호 평문은 로그/응답/감사에 절대 기록하지 않는다(보안 불변규칙).
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void changePassword(String username, ChangePasswordRequest req) {
|
||||||
|
HrmUser 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()));
|
||||||
|
log.info("[auth] password changed: username={}", username);
|
||||||
|
}
|
||||||
|
|
||||||
// ── 로그인 보조 3종 (UIWS auth 패턴 이식) ────────────────────────────────────
|
// ── 로그인 보조 3종 (UIWS auth 패턴 이식) ────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -23,7 +23,10 @@ public class HrmUser {
|
|||||||
private LocalDateTime emailVerifyExpire;
|
private LocalDateTime emailVerifyExpire;
|
||||||
private Integer loginFailCount;
|
private Integer loginFailCount;
|
||||||
private Boolean locked;
|
private Boolean locked;
|
||||||
|
/** TOTP 시크릿(보류/확정 공용). API 응답·로그에 절대 미노출. (db/91_uiws_port.sql ALTER) */
|
||||||
private String otpSecret;
|
private String otpSecret;
|
||||||
|
/** OTP 등록 확정 여부(기본 false). 최초 로그인 verify 성공 시 true 로 확정(멱등). (db/93_auth_otp.sql ALTER) */
|
||||||
|
private Boolean otpEnabled;
|
||||||
|
|
||||||
// UIWS 로그인 보조기능 이식 컬럼 (멱등 ALTER — db/91_uiws_port.sql).
|
// UIWS 로그인 보조기능 이식 컬럼 (멱등 ALTER — db/91_uiws_port.sql).
|
||||||
// approved: 가입 신청자는 false(관리자 승인 전 로그인 차단), 기존 계정은 true 보정.
|
// approved: 가입 신청자는 false(관리자 승인 전 로그인 차단), 기존 계정은 true 보정.
|
||||||
|
|||||||
@ -0,0 +1,10 @@
|
|||||||
|
package com.zioinfo.hrm.auth.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
/** 마이페이지 비밀번호 변경 요청 — 현재 비번 검증 + 새 비번(BCrypt 저장). 평문은 응답/로그 미노출. */
|
||||||
|
public record ChangePasswordRequest(
|
||||||
|
@NotBlank(message = "현재 비밀번호는 필수입니다.") String currentPassword,
|
||||||
|
@NotBlank(message = "새 비밀번호는 필수입니다.") String newPassword
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
package com.zioinfo.hrm.auth.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
/** Authenticator(TOTP) 등록 확인 요청 — 앱에 표시된 6자리 코드. */
|
||||||
|
public record OtpConfirmRequest(@NotBlank String code) {
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
package com.zioinfo.hrm.auth.dto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.hrm.auth.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
/** 로그인 2단계 TOTP 검증 요청 — { verifyToken, code }. */
|
||||||
|
public record OtpVerifyRequest(
|
||||||
|
@NotBlank(message = "verifyToken은 필수입니다.") String verifyToken,
|
||||||
|
@NotBlank(message = "code는 필수입니다.") String code
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -100,4 +100,26 @@ public interface UserMapper {
|
|||||||
/** 관리자 잠금 해제(실패 카운트/잠금 초기화). */
|
/** 관리자 잠금 해제(실패 카운트/잠금 초기화). */
|
||||||
@Update("UPDATE hrm_users SET locked = false, login_fail_count = 0 WHERE username = #{username}")
|
@Update("UPDATE hrm_users SET locked = false, login_fail_count = 0 WHERE username = #{username}")
|
||||||
int unlock(@Param("username") String username);
|
int unlock(@Param("username") String username);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BCrypt 비밀번호 해시 갱신(username 기준). 평문은 절대 저장하지 않는다.
|
||||||
|
* 마이페이지 비밀번호 변경 + AdminPasswordSeeder(env 재시드) 공용.
|
||||||
|
*/
|
||||||
|
@Update("UPDATE hrm_users 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 hrm_users SET otp_secret = #{secret} WHERE username = #{username}")
|
||||||
|
int updateOtpSecret(@Param("username") String username, @Param("secret") String secret);
|
||||||
|
|
||||||
|
/** 등록 확정: otp_enabled=true (시크릿은 유지). */
|
||||||
|
@Update("UPDATE hrm_users SET otp_enabled = true WHERE username = #{username}")
|
||||||
|
int enableOtp(@Param("username") String username);
|
||||||
|
|
||||||
|
/** 마이페이지 해제: 시크릿 폐기 + otp_enabled=false. */
|
||||||
|
@Update("UPDATE hrm_users SET otp_secret = NULL, otp_enabled = false WHERE username = #{username}")
|
||||||
|
int disableOtp(@Param("username") String username);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,130 @@
|
|||||||
|
package com.zioinfo.hrm.config;
|
||||||
|
|
||||||
|
import com.zioinfo.hrm.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 방식 + 암호화 저장).
|
||||||
|
*
|
||||||
|
* <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(schema.sql: admin/admin123 시드) 이후 실행되어 기존 해시를 덮어쓴다(멱등: 매 기동 동일 결과).
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@Order(Integer.MIN_VALUE + 10)
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AdminPasswordSeeder implements ApplicationRunner {
|
||||||
|
|
||||||
|
private static final String ADMIN_USERNAME = "admin";
|
||||||
|
private static final int GCM_TAG_BITS = 128;
|
||||||
|
private static final int NONCE_LEN = 12;
|
||||||
|
|
||||||
|
@Value("${ADMIN_PASSWORD_ENC:}")
|
||||||
|
private String adminPasswordEnc;
|
||||||
|
|
||||||
|
@Value("${ADMIN_KEY_FILE:}")
|
||||||
|
private String adminKeyFile;
|
||||||
|
|
||||||
|
private final UserMapper userMapper;
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run(ApplicationArguments args) {
|
||||||
|
if (isBlank(adminPasswordEnc) || isBlank(adminKeyFile)) {
|
||||||
|
log.info("[admin-reseed] ADMIN_PASSWORD_ENC/ADMIN_KEY_FILE 미설정 — 재시드 스킵(기동 계속)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
char[] plain = null;
|
||||||
|
byte[] key = null;
|
||||||
|
try {
|
||||||
|
key = loadHexKey(adminKeyFile); // hex 32바이트 → 32B (AES-256)
|
||||||
|
plain = decrypt(adminPasswordEnc, key); // base64(nonce12+ct+tag) → 평문
|
||||||
|
String hash = passwordEncoder.encode(new String(plain));
|
||||||
|
int updated = userMapper.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,185 @@
|
|||||||
|
package com.zioinfo.hrm.uiws.auth;
|
||||||
|
|
||||||
|
import com.zioinfo.hrm.admin.AdminMapper;
|
||||||
|
import com.zioinfo.hrm.auth.HrmUser;
|
||||||
|
import com.zioinfo.hrm.auth.JwtUtil;
|
||||||
|
import com.zioinfo.hrm.auth.dto.OtpSetupResponse;
|
||||||
|
import com.zioinfo.hrm.auth.mapper.UserMapper;
|
||||||
|
import com.zioinfo.hrm.uiws.common.UiwsApiException;
|
||||||
|
import com.zioinfo.hrm.uiws.common.UiwsErrorCode;
|
||||||
|
import com.zioinfo.hrm.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.HashMap;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TOTP(OTP 2차 인증) 오케스트레이션 레이어 — UIWS AuthService 의 OTP 경로 미러(UIMS 방식).
|
||||||
|
*
|
||||||
|
* <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 AdminMapper adminMapper;
|
||||||
|
|
||||||
|
/** 로그인 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(HrmUser 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);
|
||||||
|
}
|
||||||
|
HrmUser 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);
|
||||||
|
audit(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) {
|
||||||
|
HrmUser 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 재확정)
|
||||||
|
audit(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) {
|
||||||
|
HrmUser 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);
|
||||||
|
audit(username, "OTP_ENABLE", username, "OTP 2차 인증 활성화");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 마이페이지 OTP 해제: 시크릿 폐기 + otp_enabled=false. */
|
||||||
|
@Transactional
|
||||||
|
public void disable(String username) {
|
||||||
|
HrmUser user = userMapper.findByUsername(username);
|
||||||
|
if (user == null) {
|
||||||
|
throw new UiwsApiException(UiwsErrorCode.USER_NOT_FOUND);
|
||||||
|
}
|
||||||
|
userMapper.disableOtp(username);
|
||||||
|
audit(username, "OTP_DISABLE", username, "OTP 2차 인증 해제");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 감사 기록(hrm_audit_log). 시크릿/코드는 절대 detail 에 포함하지 않는다(보안 불변). */
|
||||||
|
private void audit(String actor, String action, String targetId, String detail) {
|
||||||
|
try {
|
||||||
|
Map<String, Object> log = new HashMap<>();
|
||||||
|
log.put("actor", actor);
|
||||||
|
log.put("action", action);
|
||||||
|
log.put("targetType", "auth.otp");
|
||||||
|
log.put("targetId", targetId);
|
||||||
|
log.put("detail", detail);
|
||||||
|
log.put("ipAddr", null);
|
||||||
|
adminMapper.insertAuditLog(log);
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 감사 실패가 인증 흐름을 막지 않도록 방어(값 미기록).
|
||||||
|
log.warn("[otp-audit] 감사 기록 실패 — 스킵. cause={}", e.getClass().getSimpleName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,81 @@
|
|||||||
|
package com.zioinfo.hrm.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)은 hrm_users 에 저장되어 있다고 가정. issuer 만 GUARDiA-HRM 으로 교체.
|
||||||
|
*
|
||||||
|
* <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-HRM";
|
||||||
|
|
||||||
|
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_TOKEN_INVALID("ERR-UIWS-2FA-401", "2차 검증 토큰이 유효하지 않거나 만료되었습니다."),
|
||||||
VERIFY_CODE_INVALID("ERR-UIWS-2FA-401C", "인증 코드가 올바르지 않거나 만료되었습니다."),
|
VERIFY_CODE_INVALID("ERR-UIWS-2FA-401C", "인증 코드가 올바르지 않거나 만료되었습니다."),
|
||||||
ACCOUNT_LOCKED("ERR-UIWS-2FA-LOCK", "로그인 실패 횟수 초과로 계정이 잠겼습니다. 관리자에게 문의하세요."),
|
ACCOUNT_LOCKED("ERR-UIWS-2FA-LOCK", "로그인 실패 횟수 초과로 계정이 잠겼습니다. 관리자에게 문의하세요."),
|
||||||
|
UNAUTHORIZED("ERR-UIWS-401", "인증이 필요합니다."),
|
||||||
|
OTP_NOT_REGISTERED("ERR-UIWS-2FA-OTP404", "OTP가 등록되어 있지 않습니다. 먼저 등록을 진행하세요."),
|
||||||
|
PASSWORD_MISMATCH("ERR-UIWS-PW-401", "현재 비밀번호가 일치하지 않습니다."),
|
||||||
|
PASSWORD_SAME_AS_OLD("ERR-UIWS-PW-409", "새 비밀번호가 기존 비밀번호와 동일합니다."),
|
||||||
|
|
||||||
// system (시스템관리·권한관리 — CMS uiws.system 이식)
|
// system (시스템관리·권한관리 — CMS uiws.system 이식)
|
||||||
DUPLICATE_KEY("ERR-UIWS-SYS-409D", "이미 존재하는 항목입니다."),
|
DUPLICATE_KEY("ERR-UIWS-SYS-409D", "이미 존재하는 항목입니다."),
|
||||||
|
|||||||
@ -24,6 +24,11 @@ public class UiwsProperties {
|
|||||||
public static class Auth {
|
public static class Auth {
|
||||||
/** 2FA 레이어 on/off. 기본 on(켜짐). off 시 기존 단일 JWT 로그인 흐름 유지(회귀 0). */
|
/** 2FA 레이어 on/off. 기본 on(켜짐). off 시 기존 단일 JWT 로그인 흐름 유지(회귀 0). */
|
||||||
private boolean twofaEnabled = true;
|
private boolean twofaEnabled = true;
|
||||||
|
/**
|
||||||
|
* TOTP(OTP 2차 인증) 레이어 on/off. 기본 on. 활성 시 로그인 2단계가 OTP(등록/검증) 경로를 사용한다.
|
||||||
|
* 우선순위: otpEnabled → OTP, (off 이고 twofaEnabled) → 이메일코드, (둘 다 off) → 단일 로그인.
|
||||||
|
*/
|
||||||
|
private boolean otpEnabled = true;
|
||||||
/** 1차 통과 후 verify-token 유효시간(초). 계획서 300(5분). */
|
/** 1차 통과 후 verify-token 유효시간(초). 계획서 300(5분). */
|
||||||
private long verifyTokenValiditySeconds = 300;
|
private long verifyTokenValiditySeconds = 300;
|
||||||
/** 이메일 인증코드 유효시간(초). 계획서 300(5분). */
|
/** 이메일 인증코드 유효시간(초). 계획서 300(5분). */
|
||||||
|
|||||||
@ -20,7 +20,8 @@ spring:
|
|||||||
# 92_uiws_system.sql: UIWS system(권한관리) tb_uiws_* 10테이블(부서/회사/코드/사용자/역할/메뉴/프로그램, 멱등).
|
# 92_uiws_system.sql: UIWS system(권한관리) tb_uiws_* 10테이블(부서/회사/코드/사용자/역할/메뉴/프로그램, 멱등).
|
||||||
mode: ${SQL_INIT_MODE:always}
|
mode: ${SQL_INIT_MODE:always}
|
||||||
# 104_seed_ai_config.sql: AI 플랫폼(provider/모델) 설정 시드 hrm_settings ai.* (멱등 ON CONFLICT DO NOTHING).
|
# 104_seed_ai_config.sql: AI 플랫폼(provider/모델) 설정 시드 hrm_settings ai.* (멱등 ON CONFLICT DO NOTHING).
|
||||||
schema-locations: classpath:db/schema.sql,classpath:db/91_uiws_port.sql,classpath:db/92_uiws_system.sql,classpath:db/104_seed_ai_config.sql
|
# 93_auth_otp.sql: hrm_users otp_enabled 멱등 ALTER(TOTP 2차 인증). ops_otp_reset_all.sql 은 미등재(운영 1회 수동).
|
||||||
|
schema-locations: classpath:db/schema.sql,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
|
continue-on-error: true
|
||||||
servlet:
|
servlet:
|
||||||
multipart:
|
multipart:
|
||||||
@ -65,6 +66,8 @@ hrm:
|
|||||||
uiws:
|
uiws:
|
||||||
auth:
|
auth:
|
||||||
twofa-enabled: ${UIWS_2FA:true}
|
twofa-enabled: ${UIWS_2FA:true}
|
||||||
|
# TOTP(OTP 2차 인증, UIMS 방식) on/off. 기본 on. 우선순위: otp → 이메일코드 → 단일. off 시 이메일/단일 경로로 폴백.
|
||||||
|
otp-enabled: ${UIWS_OTP:true}
|
||||||
verify-token-validity-seconds: 300 # verify-token 5분
|
verify-token-validity-seconds: 300 # verify-token 5분
|
||||||
email-code-validity-seconds: 300 # 이메일 코드 5분
|
email-code-validity-seconds: 300 # 이메일 코드 5분
|
||||||
max-login-fail: 5 # 실패 5회 잠금
|
max-login-fail: 5 # 실패 5회 잠금
|
||||||
|
|||||||
11
backend/src/main/resources/db/93_auth_otp.sql
Normal file
11
backend/src/main/resources/db/93_auth_otp.sql
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- TOTP(OTP 2차 인증) 이식 — hrm_users 멱등 ALTER
|
||||||
|
-- =====================================================================
|
||||||
|
-- otp_secret 은 db/91_uiws_port.sql 에서 이미 ADD(VARCHAR(255)).
|
||||||
|
-- 여기서는 등록 확정 플래그 otp_enabled 만 추가한다.
|
||||||
|
-- schema-locations 마지막에 등재(mode=always + continue-on-error) → 멱등 재실행 안전.
|
||||||
|
|
||||||
|
ALTER TABLE hrm_users ADD COLUMN IF NOT EXISTS otp_enabled BOOLEAN DEFAULT false;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN hrm_users.otp_secret IS 'TOTP 시크릿(보류/확정 공용). API 응답·로그 미노출.';
|
||||||
|
COMMENT ON COLUMN hrm_users.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 초기화 — 멱등
|
||||||
|
-- =====================================================================
|
||||||
|
-- 목적: 인증 강화 전환 시점에 기존 사용자 OTP 를 초기화한다.
|
||||||
|
-- 다음 로그인부터 OTP_SETUP(QR 재등록) 플로우를 타게 한다(UIMS 방식).
|
||||||
|
--
|
||||||
|
-- ★ 이 파일은 schema-locations 에 등재하지 않는다(자동 재실행 금지).
|
||||||
|
-- 운영자가 서버에서 1회 수동 적용:
|
||||||
|
-- psql -U hrm_user -d hrm_db -f db/ops_otp_reset_all.sql
|
||||||
|
--
|
||||||
|
-- 멱등: 여러 번 실행해도 결과 동일(모두 NULL/false).
|
||||||
|
|
||||||
|
UPDATE hrm_users
|
||||||
|
SET otp_secret = NULL,
|
||||||
|
otp_enabled = false
|
||||||
|
WHERE otp_secret IS NOT NULL OR otp_enabled IS DISTINCT FROM false;
|
||||||
@ -39,6 +39,10 @@
|
|||||||
UPDATE hrm_users SET active=#{active} WHERE id=#{id}
|
UPDATE hrm_users SET active=#{active} WHERE id=#{id}
|
||||||
</update>
|
</update>
|
||||||
|
|
||||||
|
<update id="clearOtp">
|
||||||
|
UPDATE hrm_users SET otp_secret=NULL, otp_enabled=false WHERE id=#{id}
|
||||||
|
</update>
|
||||||
|
|
||||||
<select id="findAuditLogs" resultType="map">
|
<select id="findAuditLogs" resultType="map">
|
||||||
SELECT id, actor, action, target_type, target_id, detail, ip_addr, created_at
|
SELECT id, actor, action, target_type, target_id, detail, ip_addr, created_at
|
||||||
FROM hrm_audit_log
|
FROM hrm_audit_log
|
||||||
|
|||||||
@ -18,6 +18,7 @@
|
|||||||
<result property="loginFailCount" column="login_fail_count"/>
|
<result property="loginFailCount" column="login_fail_count"/>
|
||||||
<result property="locked" column="locked"/>
|
<result property="locked" column="locked"/>
|
||||||
<result property="otpSecret" column="otp_secret"/>
|
<result property="otpSecret" column="otp_secret"/>
|
||||||
|
<result property="otpEnabled" column="otp_enabled"/>
|
||||||
<!-- UIWS 로그인 보조기능 이식 컬럼 (회원가입 승인·임시비번 변경유도) -->
|
<!-- UIWS 로그인 보조기능 이식 컬럼 (회원가입 승인·임시비번 변경유도) -->
|
||||||
<result property="approved" column="approved"/>
|
<result property="approved" column="approved"/>
|
||||||
<result property="pwChangeYn" column="pw_change_yn"/>
|
<result property="pwChangeYn" column="pw_change_yn"/>
|
||||||
@ -25,7 +26,7 @@
|
|||||||
|
|
||||||
<select id="findByUsername" resultMap="UserRM">
|
<select id="findByUsername" resultMap="UserRM">
|
||||||
SELECT id, username, password_hash, display_name, email, role, active, created_at,
|
SELECT id, username, password_hash, display_name, email, role, active, created_at,
|
||||||
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,
|
||||||
approved, pw_change_yn
|
approved, pw_change_yn
|
||||||
FROM hrm_users WHERE username = #{username}
|
FROM hrm_users WHERE username = #{username}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
255
backend/src/main/resources/static/assets/index-cExOrMDr.js
Normal file
255
backend/src/main/resources/static/assets/index-cExOrMDr.js
Normal file
File diff suppressed because one or more lines are too long
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 |
@ -2,11 +2,11 @@
|
|||||||
<html lang="ko">
|
<html lang="ko">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>GUARDiA HRM — AI 인사관리 플랫폼</title>
|
<title>GUARDiA HRM — AI 인사관리 플랫폼</title>
|
||||||
<script type="module" crossorigin src="/assets/index-fwjGAGOv.js"></script>
|
<script type="module" crossorigin src="/assets/index-cExOrMDr.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-Bcwr7v00.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-QZl_9xgd.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
<html lang="ko">
|
<html lang="ko">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>GUARDiA HRM — AI 인사관리 플랫폼</title>
|
<title>GUARDiA HRM — AI 인사관리 플랫폼</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@ -12,6 +12,7 @@ import TrainingPage from './pages/TrainingPage'
|
|||||||
import AiPage from './pages/AiPage'
|
import AiPage from './pages/AiPage'
|
||||||
import AiPlatformSettings from './pages/AiPlatformSettings'
|
import AiPlatformSettings from './pages/AiPlatformSettings'
|
||||||
import AdminPage from './pages/AdminPage'
|
import AdminPage from './pages/AdminPage'
|
||||||
|
import MyPage from './pages/MyPage'
|
||||||
import MobileApp from './pages/MobileApp'
|
import MobileApp from './pages/MobileApp'
|
||||||
// UIWS(UIMS) 이식 — 공통 업무협업 화면(업무일지·일정·쪽지·통계)
|
// UIWS(UIMS) 이식 — 공통 업무협업 화면(업무일지·일정·쪽지·통계)
|
||||||
import WorklogList from './pages/uiws/WorklogList'
|
import WorklogList from './pages/uiws/WorklogList'
|
||||||
@ -89,10 +90,13 @@ function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<button className="mt-3 w-full text-xs text-slate-400 hover:text-white"
|
<div className="mt-3 flex items-center justify-between">
|
||||||
|
<NavLink to="/mypage" className="text-xs text-slate-400 hover:text-white">마이페이지</NavLink>
|
||||||
|
<button className="text-xs text-slate-400 hover:text-white"
|
||||||
onClick={() => { localStorage.clear(); window.location.href = '/login'; }}>
|
onClick={() => { localStorage.clear(); window.location.href = '/login'; }}>
|
||||||
로그아웃
|
로그아웃
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@ -142,6 +146,8 @@ export default function App() {
|
|||||||
{/* AI 플랫폼 설정(provider/모델·연결테스트·피드백 학습) — 관리자 전용. 백엔드 /api/hrm/admin/** RBAC 강제 */}
|
{/* AI 플랫폼 설정(provider/모델·연결테스트·피드백 학습) — 관리자 전용. 백엔드 /api/hrm/admin/** RBAC 강제 */}
|
||||||
<Route path="/ai-platform" element={<AdminRoute><AiPlatformSettings /></AdminRoute>} />
|
<Route path="/ai-platform" element={<AdminRoute><AiPlatformSettings /></AdminRoute>} />
|
||||||
<Route path="/admin" element={<PrivateRoute><AdminPage /></PrivateRoute>} />
|
<Route path="/admin" element={<PrivateRoute><AdminPage /></PrivateRoute>} />
|
||||||
|
{/* 마이페이지 — OTP 2차 인증 등록/재설정/해제 + 비밀번호 변경 */}
|
||||||
|
<Route path="/mypage" element={<PrivateRoute><MyPage /></PrivateRoute>} />
|
||||||
{/* UIWS system 이식 — 시스템관리(권한). 관리자 전용(SUPERADMIN/MANAGER/ADMIN), 그 외 대시보드로 */}
|
{/* UIWS system 이식 — 시스템관리(권한). 관리자 전용(SUPERADMIN/MANAGER/ADMIN), 그 외 대시보드로 */}
|
||||||
<Route path="/system" element={<AdminRoute><SystemPage /></AdminRoute>} />
|
<Route path="/system" element={<AdminRoute><SystemPage /></AdminRoute>} />
|
||||||
{/* 통합 메신저 앱 다운로드 QR (ITSM 중앙 APK 저장소 공개 엔드포인트 재사용·읽기전용) */}
|
{/* 통합 메신저 앱 다운로드 QR (ITSM 중앙 APK 저장소 공개 엔드포인트 재사용·읽기전용) */}
|
||||||
|
|||||||
@ -20,3 +20,19 @@ api.interceptors.response.use(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export default api
|
export default api
|
||||||
|
|
||||||
|
// ── Auth / Me ─────────────────────────────────────────────────────────
|
||||||
|
// client baseURL='/api/hrm' → 경로는 상대(/auth/...·/admin/...).
|
||||||
|
export const getMe = () => api.get('/auth/me')
|
||||||
|
|
||||||
|
// ── 2FA / OTP / 계정 보안 (마이페이지, access 토큰 필요) ────────────────
|
||||||
|
// 보안 불변: setup 응답(secret/qrImage)은 화면 표시용만 — 로그/저장 금지.
|
||||||
|
export const otpSetup = () => api.post('/auth/otp/setup')
|
||||||
|
export const otpConfirm = (code: string) => api.post('/auth/otp/confirm', { code })
|
||||||
|
export const otpDisable = () => api.post('/auth/otp/disable')
|
||||||
|
export const changePassword = (currentPassword: string, newPassword: string) =>
|
||||||
|
api.post('/auth/change-password', { currentPassword, newPassword })
|
||||||
|
|
||||||
|
// ── Admin: OTP 초기화 (SUPERADMIN — /api/hrm/admin/users/** 게이트) ─────
|
||||||
|
// 대상 사용자 OTP 해제(다음 로그인 시 OTP_SETUP 재등록). 시크릿 미조회.
|
||||||
|
export const adminOtpReset = (id: number) => api.post(`/admin/users/${id}/otp-reset`)
|
||||||
|
|||||||
@ -28,8 +28,12 @@ api.interceptors.response.use(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ── 2FA
|
// ── 2FA
|
||||||
|
// verify2fa: 이메일 인증코드 경로(하위호환). verifyMethod=EMAIL 일 때 사용.
|
||||||
export const verify2fa = (verifyToken: string, code: string) =>
|
export const verify2fa = (verifyToken: string, code: string) =>
|
||||||
api.post('/api/hrm/auth/verify', { verifyToken, code })
|
api.post('/api/hrm/auth/verify', { verifyToken, code })
|
||||||
|
// verifyOtp: Authenticator(TOTP) 경로. verifyMethod=OTP | OTP_SETUP 일 때 사용.
|
||||||
|
export const verifyOtp = (verifyToken: string, code: string) =>
|
||||||
|
api.post('/api/hrm/auth/verify-otp', { verifyToken, code })
|
||||||
|
|
||||||
// ── 로그인 보조 3종 (UIWS auth 이식, /api/auth permitAll)
|
// ── 로그인 보조 3종 (UIWS auth 이식, /api/auth permitAll)
|
||||||
export const signup = (body: { username: string; password: string; displayName: string; email: string }) =>
|
export const signup = (body: { username: string; password: string; displayName: string; email: string }) =>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import api from '../api/client'
|
import api, { adminOtpReset } from '../api/client'
|
||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
const [users, setUsers] = useState<any[]>([])
|
const [users, setUsers] = useState<any[]>([])
|
||||||
@ -28,6 +28,21 @@ export default function AdminPage() {
|
|||||||
loadUsers()
|
loadUsers()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 관리자 OTP 초기화(SUPERADMIN). 대상 사용자는 다음 로그인 시 OTP_SETUP(QR 재등록)을 탄다.
|
||||||
|
const resetOtp = async (id: number, username: string) => {
|
||||||
|
if (!window.confirm(
|
||||||
|
`'${username}' 사용자의 OTP를 초기화하시겠습니까?\n초기화하면 다음 로그인 시 재등록해야 합니다.`
|
||||||
|
)) return
|
||||||
|
try {
|
||||||
|
await adminOtpReset(id)
|
||||||
|
alert('OTP가 초기화되었습니다.')
|
||||||
|
} catch (e: any) {
|
||||||
|
alert(e?.response?.status === 403
|
||||||
|
? '권한이 없습니다 (SUPERADMIN 전용).'
|
||||||
|
: (e?.response?.data?.message || 'OTP 초기화에 실패했습니다.'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const saveSetting = async (key: string, value: string) => {
|
const saveSetting = async (key: string, value: string) => {
|
||||||
await api.put(`/admin/settings/${key}`, null, { params: { value } })
|
await api.put(`/admin/settings/${key}`, null, { params: { value } })
|
||||||
loadSettings()
|
loadSettings()
|
||||||
@ -79,10 +94,16 @@ export default function AdminPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="table-cell text-xs">{u.last_login_at?.slice(0,16) || '-'}</td>
|
<td className="table-cell text-xs">{u.last_login_at?.slice(0,16) || '-'}</td>
|
||||||
<td className="table-cell">
|
<td className="table-cell">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<button className={`text-xs hover:underline ${u.is_active?'text-red-600':'text-blue-600'}`}
|
<button className={`text-xs hover:underline ${u.is_active?'text-red-600':'text-blue-600'}`}
|
||||||
onClick={() => toggleUser(u.id, u.is_active)}>
|
onClick={() => toggleUser(u.id, u.is_active)}>
|
||||||
{u.is_active ? '비활성화' : '활성화'}
|
{u.is_active ? '비활성화' : '활성화'}
|
||||||
</button>
|
</button>
|
||||||
|
<button className="text-xs text-slate-500 hover:underline hover:text-blue-600"
|
||||||
|
onClick={() => resetOtp(u.id, u.username)} title="OTP 초기화(다음 로그인 재등록)">
|
||||||
|
OTP 초기화
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
import { verify2fa, signup, findId, resetPasswordHelper } from '../api/uiws'
|
import { verify2fa, verifyOtp, signup, findId, resetPasswordHelper } from '../api/uiws'
|
||||||
|
|
||||||
type Helper = null | 'signup' | 'findId' | 'resetPw'
|
type Helper = null | 'signup' | 'findId' | 'resetPw'
|
||||||
|
type VerifyMethod = 'OTP' | 'OTP_SETUP' | 'EMAIL'
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const nav = useNavigate()
|
const nav = useNavigate()
|
||||||
@ -11,9 +12,17 @@ export default function LoginPage() {
|
|||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
// UIWS 2FA — 1차 통과 후 코드 입력 단계
|
// UIWS 2FA — 1차 통과 후 코드 입력 단계.
|
||||||
const [twofa, setTwofa] = useState<{ verifyToken: string; maskedEmail: string } | null>(null)
|
// · EMAIL : 이메일 인증코드(maskedEmail) → /verify (하위호환)
|
||||||
|
// · OTP : Authenticator 6자리 → /verify-otp
|
||||||
|
// · OTP_SETUP : 최초 로그인 — QR(qrImage)+수동키(secret) 등록 후 6자리 → /verify-otp
|
||||||
|
const [twofa, setTwofa] = useState<{
|
||||||
|
verifyToken: string; verifyMethod: VerifyMethod
|
||||||
|
maskedEmail: string; qrImage: string; secret: string
|
||||||
|
} | null>(null)
|
||||||
const [code, setCode] = useState('')
|
const [code, setCode] = useState('')
|
||||||
|
const isOtp = twofa?.verifyMethod === 'OTP' || twofa?.verifyMethod === 'OTP_SETUP'
|
||||||
|
const isSetup = twofa?.verifyMethod === 'OTP_SETUP'
|
||||||
|
|
||||||
// 로그인 보조 3종 모달
|
// 로그인 보조 3종 모달
|
||||||
const [helper, setHelper] = useState<Helper>(null)
|
const [helper, setHelper] = useState<Helper>(null)
|
||||||
@ -31,8 +40,15 @@ export default function LoginPage() {
|
|||||||
const { data } = await api.post('/auth/login', form)
|
const { data } = await api.post('/auth/login', form)
|
||||||
const payload = data.data || {}
|
const payload = data.data || {}
|
||||||
if (payload.twofa === 'true') {
|
if (payload.twofa === 'true') {
|
||||||
// 2FA 활성 — 이메일 인증코드 입력 단계로 전환(access 토큰 미발급)
|
// 2FA 활성 — verifyMethod 로 분기(access 토큰 미발급). 시크릿/QR은 화면 표시용만.
|
||||||
setTwofa({ verifyToken: payload.verifyToken, maskedEmail: payload.maskedEmail || '' })
|
setTwofa({
|
||||||
|
verifyToken: payload.verifyToken,
|
||||||
|
verifyMethod: (payload.verifyMethod as VerifyMethod) || 'EMAIL',
|
||||||
|
maskedEmail: payload.maskedEmail || '',
|
||||||
|
qrImage: payload.qrImage || '',
|
||||||
|
secret: payload.secret || '',
|
||||||
|
})
|
||||||
|
setCode('')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
localStorage.setItem('hrm_token', payload.token)
|
localStorage.setItem('hrm_token', payload.token)
|
||||||
@ -49,7 +65,10 @@ export default function LoginPage() {
|
|||||||
if (!twofa) return
|
if (!twofa) return
|
||||||
setLoading(true); setError('')
|
setLoading(true); setError('')
|
||||||
try {
|
try {
|
||||||
const { data } = await verify2fa(twofa.verifyToken, code)
|
// OTP·OTP_SETUP 은 /verify-otp, 이메일은 /verify(하위호환)
|
||||||
|
const { data } = isOtp
|
||||||
|
? await verifyOtp(twofa.verifyToken, code)
|
||||||
|
: await verify2fa(twofa.verifyToken, code)
|
||||||
localStorage.setItem('hrm_token', data.data.token)
|
localStorage.setItem('hrm_token', data.data.token)
|
||||||
await afterAuthenticated()
|
await afterAuthenticated()
|
||||||
} catch {
|
} catch {
|
||||||
@ -102,18 +121,42 @@ export default function LoginPage() {
|
|||||||
<form onSubmit={verify} className="bg-white rounded-2xl shadow-2xl p-8 space-y-5">
|
<form onSubmit={verify} className="bg-white rounded-2xl shadow-2xl p-8 space-y-5">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-sm font-semibold text-slate-700">2차 인증</p>
|
<p className="text-sm font-semibold text-slate-700">2차 인증</p>
|
||||||
|
{isSetup ? (
|
||||||
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
|
최초 로그인입니다. 아래 QR을 Authenticator 앱(Google·Microsoft)으로 스캔해
|
||||||
|
등록한 뒤 6자리 코드를 입력하세요.
|
||||||
|
</p>
|
||||||
|
) : isOtp ? (
|
||||||
|
<p className="text-xs text-slate-500 mt-1">Authenticator 앱에 표시된 6자리 코드를 입력하세요.</p>
|
||||||
|
) : (
|
||||||
<p className="text-xs text-slate-500 mt-1">
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
{twofa.maskedEmail ? `${twofa.maskedEmail} 으로 발송된 인증 코드를 입력하세요.` : '이메일로 발송된 인증 코드를 입력하세요.'}
|
{twofa.maskedEmail ? `${twofa.maskedEmail} 으로 발송된 인증 코드를 입력하세요.` : '이메일로 발송된 인증 코드를 입력하세요.'}
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{isSetup && twofa.qrImage && (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<img src={twofa.qrImage} alt="OTP QR" width={180} height={180}
|
||||||
|
style={{ background: '#fff', padding: 8, borderRadius: 8, border: '1px solid #e2e8f0' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isSetup && twofa.secret && (
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] text-slate-400 mb-1">QR 스캔이 안 되면 수동 입력 키</p>
|
||||||
|
<code className="block text-xs text-blue-700 bg-slate-50 border border-slate-200 rounded-md px-2 py-1.5 break-all select-all">
|
||||||
|
{twofa.secret}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<input type="text" inputMode="numeric" value={code} autoFocus
|
<input type="text" inputMode="numeric" value={code} autoFocus
|
||||||
onChange={e => setCode(e.target.value)}
|
autoComplete="one-time-code"
|
||||||
|
onChange={e => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||||
className="w-full border border-slate-200 rounded-lg px-4 py-2.5 text-center tracking-widest text-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="w-full border border-slate-200 rounded-lg px-4 py-2.5 text-center tracking-widest text-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
placeholder="6자리 코드" maxLength={6} required />
|
placeholder="6자리 코드" maxLength={6} required />
|
||||||
{error && <p className="text-red-500 text-sm text-center">{error}</p>}
|
{error && <p className="text-red-500 text-sm text-center">{error}</p>}
|
||||||
<button type="submit" disabled={loading}
|
<button type="submit" disabled={loading || code.length !== 6}
|
||||||
className="w-full py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors">
|
className="w-full py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors">
|
||||||
{loading ? '확인 중...' : '인증 확인'}
|
{loading ? '확인 중...' : isSetup ? '등록하고 로그인' : '인증 확인'}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={() => { setTwofa(null); setCode(''); setError('') }}
|
<button type="button" onClick={() => { setTwofa(null); setCode(''); setError('') }}
|
||||||
className="w-full text-xs text-slate-400 hover:text-slate-600">← 다시 로그인</button>
|
className="w-full text-xs text-slate-400 hover:text-slate-600">← 다시 로그인</button>
|
||||||
|
|||||||
261
frontend/src/pages/MyPage.tsx
Normal file
261
frontend/src/pages/MyPage.tsx
Normal file
@ -0,0 +1,261 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마이페이지(HRM 라이트 테마) — OTP 2차 인증(등록/재설정/해제) + 비밀번호 변경.
|
||||||
|
* 보안: setup 응답의 secret/qrImage 는 화면 표시용만 — 로그/저장 절대 금지.
|
||||||
|
* 새 비밀번호는 콘솔/응답에 출력 금지, 검증 4대(필수·8자·일치·현재와 다름).
|
||||||
|
* me() 는 otpEnabled 를 내려주지 않으므로 상태는 중립(null) 표시.
|
||||||
|
*/
|
||||||
|
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 || {}
|
||||||
|
let fallback = ''
|
||||||
|
try { fallback = JSON.parse(localStorage.getItem('hrm_user') || '{}').username || '' } catch { /* noop */ }
|
||||||
|
setUsername(me.username || fallback)
|
||||||
|
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 border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
|
||||||
|
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-lg px-3 py-2 mb-3 border ${
|
||||||
|
m.ok
|
||||||
|
? 'bg-green-50 border-green-200 text-green-700'
|
||||||
|
: 'bg-red-50 border-red-200 text-red-600'
|
||||||
|
}`}>
|
||||||
|
{m.ok ? <CheckCircle2 size={16} /> : <AlertTriangle size={16} />}
|
||||||
|
{m.text}
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-800">마이페이지</h1>
|
||||||
|
<p className="text-sm text-slate-500 mt-1">
|
||||||
|
{username && <>계정: <span className="text-slate-700 font-medium">{username}</span></>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── OTP 2차 인증 ──────────────────────────────────────────────── */}
|
||||||
|
<section className="card">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<ShieldCheck size={18} className="text-blue-600" />
|
||||||
|
<h2 className="font-semibold text-slate-800">2차 인증 — Authenticator(OTP)</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 mb-4">
|
||||||
|
Google·Microsoft Authenticator 앱으로 6자리 코드를 사용하는 2차 인증을 설정합니다.
|
||||||
|
{otpEnabled !== null && (
|
||||||
|
<span className="ml-2">
|
||||||
|
현재 상태:{' '}
|
||||||
|
<span className={otpEnabled ? 'text-green-600 font-medium' : 'text-slate-500'}>
|
||||||
|
{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-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50">
|
||||||
|
<QrCode size={16} /> {otpBusy ? '발급 중...' : otpEnabled ? 'OTP 재설정 시작' : 'OTP 등록 시작'}
|
||||||
|
</button>
|
||||||
|
{otpEnabled && (
|
||||||
|
<button onClick={disableOtp} disabled={otpBusy}
|
||||||
|
className="px-3 py-2 rounded-lg border border-red-300 text-red-600 text-sm hover:bg-red-50 disabled:opacity-50">
|
||||||
|
Authenticator 해제
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === 'setup' && (
|
||||||
|
<div>
|
||||||
|
<ol className="list-decimal list-inside text-sm text-slate-600 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, border: '1px solid #e2e8f0' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{secret && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="text-[11px] text-slate-400 mb-1">수동 입력 키</div>
|
||||||
|
<code className="block text-xs text-blue-700 bg-slate-50 border border-slate-200 rounded-md px-2 py-1.5 break-all select-all">
|
||||||
|
{secret}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<label className="block text-xs text-slate-500 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-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50">
|
||||||
|
{otpBusy ? '확인 중...' : '코드 확인 · 활성화'}
|
||||||
|
</button>
|
||||||
|
<button onClick={cancelSetup} disabled={otpBusy}
|
||||||
|
className="px-4 py-2 rounded-lg border border-slate-200 text-sm text-slate-600 hover:bg-slate-50">
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === 'done' && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button onClick={disableOtp} disabled={otpBusy}
|
||||||
|
className="px-3 py-2 rounded-lg border border-red-300 text-red-600 text-sm hover:bg-red-50 disabled:opacity-50">
|
||||||
|
Authenticator 해제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── 비밀번호 변경 ─────────────────────────────────────────────── */}
|
||||||
|
<section className="card">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Lock size={18} className="text-blue-600" />
|
||||||
|
<h2 className="font-semibold text-slate-800">비밀번호 변경</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StatusMsg m={pwMsg} />
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-slate-500 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-slate-500 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-slate-500 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-slate-500 cursor-pointer select-none">
|
||||||
|
<button type="button" onClick={() => setShowPw(!showPw)} className="text-slate-400 hover:text-blue-600">
|
||||||
|
{showPw ? <EyeOff size={15} /> : <Eye size={15} />}
|
||||||
|
</button>
|
||||||
|
비밀번호 표시
|
||||||
|
</label>
|
||||||
|
<button onClick={submitPw} disabled={pwBusy}
|
||||||
|
className="px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50">
|
||||||
|
{pwBusy ? '변경 중...' : '비밀번호 변경'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user