feat(auth): OTP 2FA 백+프론트

This commit is contained in:
GUARDiA 2026-07-04 08:32:22 +09:00
parent d404678d4c
commit 933c8e71bd
35 changed files with 1265 additions and 263 deletions

View File

@ -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>

View File

@ -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,

View File

@ -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,

View File

@ -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) {}

View File

@ -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 패턴 이식)
/** /**

View File

@ -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 보정.

View File

@ -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
) {
}

View File

@ -0,0 +1,7 @@
package com.zioinfo.hrm.auth.dto;
import jakarta.validation.constraints.NotBlank;
/** Authenticator(TOTP) 등록 확인 요청 — 앱에 표시된 6자리 코드. */
public record OtpConfirmRequest(@NotBlank String code) {
}

View File

@ -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 이미지, &lt;img src&gt; 표시)</li>
* </ul>
* 보안 불변: 응답(등록 순간)에서만 시크릿/QR 노출. 조회/목록/재조회 응답에 재노출 금지.
*/
public record OtpSetupResponse(String secret, String otpAuthUri, String qrImage) {
}

View File

@ -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
) {
}

View File

@ -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);
} }

View File

@ -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();
}
}

View File

@ -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());
}
}
}

View File

@ -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();
}
}

View File

@ -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", "이미 존재하는 항목입니다."),

View File

@ -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분). */

View File

@ -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회 잠금

View 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).';

View 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;

View File

@ -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

View File

@ -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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -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>

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -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">
onClick={() => { localStorage.clear(); window.location.href = '/login'; }}> <NavLink to="/mypage" className="text-xs text-slate-400 hover:text-white"></NavLink>
<button className="text-xs text-slate-400 hover:text-white"
</button> onClick={() => { localStorage.clear(); window.location.href = '/login'; }}>
</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 저장소 공개 엔드포인트 재사용·읽기전용) */}

View File

@ -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`)

View File

@ -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 }) =>

View File

@ -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">
<button className={`text-xs hover:underline ${u.is_active?'text-red-600':'text-blue-600'}`} <div className="flex items-center gap-3">
onClick={() => toggleUser(u.id, u.is_active)}> <button className={`text-xs hover:underline ${u.is_active?'text-red-600':'text-blue-600'}`}
{u.is_active ? '비활성화' : '활성화'} onClick={() => toggleUser(u.id, u.is_active)}>
</button> {u.is_active ? '비활성화' : '활성화'}
</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>
))} ))}

View File

@ -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>
<p className="text-xs text-slate-500 mt-1"> {isSetup ? (
{twofa.maskedEmail ? `${twofa.maskedEmail} 으로 발송된 인증 코드를 입력하세요.` : '이메일로 발송된 인증 코드를 입력하세요.'} <p className="text-xs text-slate-500 mt-1">
</p> . 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">
{twofa.maskedEmail ? `${twofa.maskedEmail} 으로 발송된 인증 코드를 입력하세요.` : '이메일로 발송된 인증 코드를 입력하세요.'}
</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>

View 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>
)
}