chore(deploy): HRM 클린 재동기화 + Claude provider flip

- workspace→repos 전체 소스 클린 재동기화(부분 auto-sync 컴파일불가 해소)
- db/202_ai_provider_claude.sql: hrm_settings ai.provider UPSERT claude(멱등, 104 시드 이후)
- application.yml schema-locations 에 202 등재(mode=always)
- 키 미설정/실패 시 AiTextRouter 가 Ollama 자동 폴백(무중단·무회귀)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DESKTOP-TKLFCPR\ython 2026-07-11 10:59:07 +09:00
parent 4e9436edbe
commit 94705cf941
21 changed files with 942 additions and 77 deletions

View File

@ -42,11 +42,17 @@
<!-- DB Driver -->
<dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId><version>${postgresql.version}</version></dependency>
<!-- DuckDB — 로컬 임베디드 AI 학습 저장소(ai_feedback·ai_infer_log). 파일: /opt/guardia-hrm/data/hrm_learning.duckdb -->
<dependency><groupId>org.duckdb</groupId><artifactId>duckdb_jdbc</artifactId><version>1.1.3</version></dependency>
<!-- JWT -->
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-api</artifactId><version>${jjwt.version}</version></dependency>
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-impl</artifactId><version>${jjwt.version}</version><scope>runtime</scope></dependency>
<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 -->
<dependency>
<groupId>org.springdoc</groupId>

View File

@ -51,6 +51,31 @@ public class AdminController {
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")
public ApiResponse<Map<String, Object>> audit(
@RequestParam(required = false) String actor,

View File

@ -14,6 +14,8 @@ public interface AdminMapper {
int insertUser(HrmUser user);
int updateUser(HrmUser user);
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,
@Param("action") String action,
@Param("offset") int offset,

View File

@ -1,37 +1,27 @@
package com.zioinfo.hrm.ai;
import com.zioinfo.hrm.ai.service.AiTextRouter;
import com.zioinfo.hrm.common.ai.TextAiClient.GenResult;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import java.time.Duration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Ollama 온프레미스 AI 인사 분석 서비스.
* 외부 AI API 절대 금지 localhost:11434 .
* Ollama 응답 실패 Java 폴백 로직으로 대체.
* AI 인사 분석 서비스. 텍스트 생성은 {@link AiTextRouter} 경유(provider 선택: Claude Ollama).
* 외부 AI API 소유자 승인 예외인 Claude(api.anthropic.com) 허용, 온프레미스 Ollama .
* Claude/Ollama 응답 실패 Java 폴백 로직으로 대체(무회귀).
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AiService {
@Value("${guardia.ollama-url:http://localhost:11434}")
private String ollamaUrl;
@Value("${guardia.ollama-text-model:llama3}")
private String textModel;
private WebClient ollamaClient() {
return WebClient.builder().baseUrl(ollamaUrl)
.codecs(c -> c.defaultCodecs().maxInMemorySize(4 * 1024 * 1024))
.build();
}
/** provider 라우터(Claude→Ollama 폴백 + 추론 로그). 기존 Ollama 직접호출을 대체. */
private final AiTextRouter aiTextRouter;
public Map<String, Object> predictTurnover(Long empId) {
try {
@ -117,16 +107,12 @@ public class AiService {
}
private Map<String, Object> callOllama(String prompt) {
Map<String, Object> body = Map.of("model", textModel, "prompt", prompt, "stream", false);
@SuppressWarnings("unchecked")
Map<String, Object> resp = ollamaClient().post().uri("/api/generate")
.bodyValue(body)
.retrieve()
.bodyToMono(Map.class)
.timeout(Duration.ofSeconds(30))
.block();
if (resp == null) return fallbackTurnover();
String response = (String) resp.getOrDefault("response", "{}");
// provider 라우터 경유: provider=claude·키설정·활성 Claude, 실패/그외 Ollama(선택모델) 폴백.
GenResult gr = aiTextRouter.generate(prompt);
if (gr.degraded() || gr.text() == null || gr.text().isBlank()) {
return fallbackTurnover();
}
String response = gr.text();
// JSON 추출
int start = response.indexOf('{');
int end = response.lastIndexOf('}');
@ -134,10 +120,10 @@ public class AiService {
// 간단 파싱: 실제로는 Jackson 사용
Map<String, Object> r = new HashMap<>();
r.put("raw", response.substring(start, end + 1));
r.put("source", "ollama");
r.put("source", "ai");
return r;
}
return Map.of("response", response, "source", "ollama");
return Map.of("response", response, "source", "ai");
}
private Map<String, Object> fallbackTurnover() {

View File

@ -1,22 +1,54 @@
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.uiws.auth.OtpAuthService;
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 org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* HRM 인증 컨트롤러.
* - /login: 2FA off { token, type, twofa:"false" }.
* 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.
*/
@RestController
@RequestMapping("/api/hrm/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
private final TwoFactorService twoFactorService;
private final OtpAuthService otpAuthService;
private final JwtUtil jwtUtil;
@PostMapping("/login")
public ApiResponse<Map<String, String>> login(@RequestBody LoginRequest req) {
String token = authService.login(req.username(), req.password());
return ApiResponse.ok(Map.of("token", token, "type", "Bearer"));
return ApiResponse.ok(authService.login(req.username(), req.password()));
}
/** 이메일 2FA 이식: 2차 인증 코드 검증 → access/refresh 발급. */
@PostMapping("/verify")
public ApiResponse<Map<String, String>> verify(@RequestBody VerifyRequest req) {
return ApiResponse.ok(twoFactorService.verify(req.verifyToken(), req.code()));
}
/** TOTP 이식: 로그인 2단계 6자리 검증 → access/refresh 발급(최초 로그인이면 등록 확정). */
@PostMapping("/verify-otp")
public ApiResponse<Map<String, String>> verifyOtp(@Valid @RequestBody OtpVerifyRequest req) {
return ApiResponse.ok(otpAuthService.verifyOtp(req.verifyToken(), req.code()));
}
@GetMapping("/me")
@ -25,5 +57,50 @@ public class AuthController {
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 VerifyRequest(String verifyToken, String code) {}
}

View File

@ -1,13 +1,35 @@
package com.zioinfo.hrm.auth;
import com.zioinfo.hrm.auth.dto.FindIdRequest;
import com.zioinfo.hrm.auth.dto.FindIdResponse;
import com.zioinfo.hrm.auth.dto.ResetPwRequest;
import com.zioinfo.hrm.auth.dto.SignupRequest;
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.uiws.auth.OtpAuthService;
import com.zioinfo.hrm.uiws.auth.TwoFactorService;
import com.zioinfo.hrm.uiws.common.UiwsApiException;
import com.zioinfo.hrm.uiws.common.UiwsErrorCode;
import com.zioinfo.hrm.uiws.common.mail.MailSender;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.security.SecureRandom;
import java.util.HashMap;
import java.util.Map;
/**
* HRM 인증 서비스.
* - 기존 단일 JWT 로그인 보존(2FA off 또는 미설정 회귀 0).
* - UIWS 2FA 레이어(이메일 코드) 추가: 2FA on 이면 1차 통과 verify-token + 이메일코드 발급.
* 실패 누적 max-login-fail 계정 잠금.
* - UIWS 로그인 보조 3종(회원가입·아이디찾기·비밀번호 초기화) 이식.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AuthService {
@ -15,16 +37,66 @@ public class AuthService {
private final UserMapper userMapper;
private final PasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil;
private final TwoFactorService twoFactorService;
private final OtpAuthService otpAuthService;
private final MailSender mailSender;
public String login(String username, String password) {
private static final SecureRandom RANDOM = new SecureRandom();
// 혼동 문자(0/O/1/l/I) 제외 임시비번 가독성.
private static final String TMP_PW_CHARS = "ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789";
/**
* 1차 로그인. 2FA 활성 verify-token + 이메일코드 흐름으로 분기,
* 비활성 기존처럼 access 토큰 즉시 발급.
*
* @return 2FA off: { token, type, twofa:"false" }
* 2FA on : { verifyToken, step:"EMAIL", maskedEmail, twofa:"true" }
*/
public Map<String, String> login(String username, String password) {
HrmUser user = userMapper.findByUsername(username);
// 잠금 우선 차단(존재하는 계정에 한해 잠금 메시지 존재 여부 누설 최소화)
if (user != null && twoFactorService.isLocked(user)) {
throw new UiwsApiException(UiwsErrorCode.ACCOUNT_LOCKED);
}
if (user == null || !user.isActive()) {
throw new RuntimeException("ERR-AUTH-001: 존재하지 않거나 비활성 계정");
}
// 회원가입 승인 게이트(UIWS 로그인 보조 이식): 가입 신청자(approved=false) 관리자 승인 로그인 차단.
// 기존 계정은 approved=true(91_uiws_port.sql 멱등 보정) 회귀 없음.
if (Boolean.FALSE.equals(user.getApproved())) {
throw new RuntimeException("ERR-AUTH-003: 승인 대기 중인 계정입니다. 관리자 승인 후 로그인하세요.");
}
if (!passwordEncoder.matches(password, user.getPasswordHash())) {
// 2FA(OTP 또는 이메일) 활성 실패 누적/잠금. 비활성 기존 동작(메시지만) 유지.
if (otpAuthService.isEnabled() || twoFactorService.isEnabled()) {
twoFactorService.recordLoginFailure(username);
HrmUser after = userMapper.findByUsername(username);
if (after != null && Boolean.TRUE.equals(after.getLocked())) {
throw new UiwsApiException(UiwsErrorCode.ACCOUNT_LOCKED);
}
}
throw new RuntimeException("ERR-AUTH-002: 비밀번호 불일치");
}
return jwtUtil.generate(username, user.getRole());
// 비밀번호 검증 통과 2단계 우선순위: OTP > 이메일코드 > 단일 로그인
if (otpAuthService.isEnabled()) {
// { twofa:true, verifyToken, verifyMethod:(OTP|OTP_SETUP), secret?, otpAuthUri?, qrImage? }
return otpAuthService.beginOtp(user);
}
if (twoFactorService.isEnabled()) {
Map<String, String> step1 = twoFactorService.beginTwoFactor(user);
return Map.of(
"twofa", "true",
"verifyToken", step1.get("verifyToken"),
"step", step1.get("step"),
"maskedEmail", step1.getOrDefault("maskedEmail", ""));
}
// 2FA 비활성 기존 단일 로그인 흐름(회귀 0)
userMapper.resetLoginFail(username);
String token = jwtUtil.generate(username, user.getRole());
return Map.of("twofa", "false", "token", token, "type", "Bearer");
}
public Map<String, Object> me(String token) {
@ -37,4 +109,122 @@ public class AuthService {
m.put("displayName", u != null ? u.getDisplayName() : username);
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 패턴 이식)
/**
* 운영자 회원가입(승인 대기 INSERT). username/email 중복 차단, 비번 BCrypt, approved=false.
* 보안: 응답에 자격증명·임시정보 미포함, 일반 메시지만.
*/
public SignupResponse signup(SignupRequest req) {
if (req.username() == null || req.username().isBlank()
|| req.password() == null || req.password().length() < 4
|| req.email() == null || req.email().isBlank()) {
return new SignupResponse(false, "아이디·비밀번호(4자 이상)·이메일은 필수입니다.");
}
if (userMapper.countByUsername(req.username()) > 0) {
return new SignupResponse(false, "이미 사용 중인 아이디입니다.");
}
if (userMapper.countByEmail(req.email()) > 0) {
return new SignupResponse(false, "이미 등록된 이메일입니다.");
}
HrmUser u = new HrmUser();
u.setUsername(req.username());
u.setPasswordHash(passwordEncoder.encode(req.password()));
u.setDisplayName(req.displayName() != null && !req.displayName().isBlank()
? req.displayName() : req.username());
u.setEmail(req.email());
// insertSignup: role='VIEWER', approved=false, locked=false, login_fail_count=0 (XML 고정)
userMapper.insertSignup(u);
log.info("[auth-helper] signup pending approval: username={}", req.username());
return new SignupResponse(true, "가입 신청이 접수되었습니다. 관리자 승인 후 로그인할 수 있습니다.");
}
/**
* 아이디 찾기: 표시명+이메일 동시 일치 계정 1건 조회. username 부분 마스킹 반환.
* 미발견 found=false(원문 username 절대 미노출).
*/
public FindIdResponse findId(FindIdRequest req) {
if (req.displayName() == null || req.displayName().isBlank()
|| req.email() == null || req.email().isBlank()) {
return new FindIdResponse(false, "");
}
HrmUser u = userMapper.findByDisplayNameAndEmail(req.displayName(), req.email());
if (u == null) {
return new FindIdResponse(false, "");
}
return new FindIdResponse(true, maskUsername(u.getUsername()));
}
/**
* 비밀번호 초기화: username+email 일치 검증 임시비번 생성·BCrypt 저장·잠금/실패카운트 해제·변경유도.
* 임시비번은 메일(미설정 LogMailSender 로그)로만 전달. API 응답·로그 메시지에 비번 미노출.
* 대상 미존재여도 success=true(계정 열거 방지).
*/
@Transactional
public SignupResponse resetPassword(ResetPwRequest req) {
final String okMsg = "임시 비밀번호를 등록된 이메일로 발송했습니다. 메일을 확인하세요.";
if (req.username() == null || req.username().isBlank()
|| req.email() == null || req.email().isBlank()) {
return new SignupResponse(false, "아이디와 이메일을 모두 입력하세요.");
}
HrmUser u = userMapper.findByUsernameAndEmail(req.username(), req.email());
if (u == null) {
// 존재 여부 누설 방지 동일 성공 메시지 반환(실제 발송 없음).
log.info("[auth-helper] reset-password no match (suppressed): username={}", req.username());
return new SignupResponse(true, okMsg);
}
String tempPw = generateTempPassword();
// applyTempPassword: BCrypt 저장 + pw_change_yn=true + locked=false + login_fail_count=0 (멱등)
userMapper.applyTempPassword(req.username(), passwordEncoder.encode(tempPw));
String subject = "[GUARDiA HRM] 임시 비밀번호 안내";
String body = String.format(
"안녕하세요 %s 님,\n임시 비밀번호는 [%s] 입니다.\n로그인 후 즉시 비밀번호를 변경하세요.",
u.getDisplayName() != null ? u.getDisplayName() : u.getUsername(), tempPw);
// 메일 본문에만 임시비번 포함. mailSender 미설정 환경은 LogMailSender 폴백(서버 로그).
mailSender.send(u.getEmail(), subject, body);
log.info("[auth-helper] reset-password issued temp pw (sent via mail/log): username={}", req.username());
return new SignupResponse(true, okMsg);
}
private static String generateTempPassword() {
StringBuilder sb = new StringBuilder(10);
for (int i = 0; i < 10; i++) {
sb.append(TMP_PW_CHARS.charAt(RANDOM.nextInt(TMP_PW_CHARS.length())));
}
return sb.toString();
}
/** username 부분 마스킹: 앞 2자만 노출(예: admin → ad***). 2자 이하는 첫 글자만. */
private static String maskUsername(String username) {
if (username == null || username.isBlank()) {
return "";
}
if (username.length() <= 2) {
return username.charAt(0) + "*";
}
return username.substring(0, 2) + "*".repeat(Math.max(1, username.length() - 2));
}
}

View File

@ -17,4 +17,20 @@ public class HrmUser {
private String role;
private boolean active;
private LocalDateTime createdAt;
// UIWS 2FA 이식 컬럼 (멱등 ALTER db/91_uiws_port.sql).
private String emailVerifyCode;
private LocalDateTime emailVerifyExpire;
private Integer loginFailCount;
private Boolean locked;
/** TOTP 시크릿(보류/확정 공용). API 응답·로그에 절대 미노출. (db/91_uiws_port.sql ALTER) */
private String otpSecret;
/** OTP 등록 확정 여부(기본 false). 최초 로그인 verify 성공 시 true 로 확정(멱등). (db/93_auth_otp.sql ALTER) */
private Boolean otpEnabled;
// UIWS 로그인 보조기능 이식 컬럼 (멱등 ALTER db/91_uiws_port.sql).
// approved: 가입 신청자는 false(관리자 승인 로그인 차단), 기존 계정은 true 보정.
// pwChangeYn: 임시비번 발급 true(다음 로그인 변경 유도).
private Boolean approved;
private Boolean pwChangeYn;
}

View File

@ -26,7 +26,10 @@ public class JwtFilter extends OncePerRequestFilter {
String header = req.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7);
if (jwtUtil.isValid(token)) {
// 보안(UIWS 2FA): purpose=2fa verify-token access 토큰이 아니다.
// 동일 서명키라 isValid() 통과하므로 별도 차단하지 않으면 2차 인증 보호 API 접근(2FA 우회) 가능.
// verify-token 인증 컨텍스트를 세우지 않고 무시한다(/api/hrm/auth/verify 에서만 사용).
if (jwtUtil.isValid(token) && !jwtUtil.isVerifyToken(token)) {
String username = jwtUtil.getUsername(token);
String role = jwtUtil.getRole(token);
var auth = new UsernamePasswordAuthenticationToken(

View File

@ -34,6 +34,47 @@ public class JwtUtil {
.compact();
}
/**
* UIWS 2FA 이식: 1차 로그인 통과 발급하는 단기 verify-token.
* purpose=2fa 클레임으로 access 토큰과 구분(verify-token 으로는 보호 API 접근 불가).
*/
public String generateVerifyToken(String username, long validitySeconds) {
return Jwts.builder()
.subject(username)
.claim("purpose", "2fa")
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + validitySeconds * 1000L))
.signWith(key())
.compact();
}
/** verify-token 검증 후 username 반환. 유효하지 않거나 purpose!=2fa 면 null. */
public String parseVerifyTokenUsername(String token) {
try {
Claims c = parse(token);
if (!"2fa".equals(c.get("purpose", String.class))) {
return null;
}
return c.getSubject();
} catch (JwtException | IllegalArgumentException e) {
log.debug("verify-token 검증 실패: {}", e.getMessage());
return null;
}
}
/**
* 보안: 토큰이 2FA verify-token(purpose=2fa)인지 판별.
* JwtFilter access 토큰만 인증 컨텍스트로 인정하도록 verify-token 걸러내는 사용
* (2차 인증 verify-token 으로 보호 API 접근하는 2FA 우회 차단).
*/
public boolean isVerifyToken(String token) {
try {
return "2fa".equals(parse(token).get("purpose", String.class));
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
public Claims parse(String token) {
return Jwts.parser().verifyWith(key()).build()
.parseSignedClaims(token).getPayload();

View File

@ -3,9 +3,123 @@ package com.zioinfo.hrm.auth.mapper;
import com.zioinfo.hrm.auth.HrmUser;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.time.LocalDateTime;
/**
* HRM 운영자 계정 매퍼.
* 기존 메서드(findByUsername/insert) UIWS 2FA·로그인 보조기능 이식 메서드를 추가.
* 대상 테이블 hrm_users (컬럼 active pms_user.is_active 다름).
*/
@Mapper
public interface UserMapper {
HrmUser findByUsername(@Param("username") String username);
int insert(HrmUser user);
/** 회원가입(승인 대기): username·password_hash·display_name·email + role='VIEWER'·approved=false. */
int insertSignup(HrmUser user);
// UIWS 로그인 보조기능 이식(회원가입·아이디찾기·비번초기화)
/** 가입 중복 검사: username 존재 여부. */
@Select("SELECT COUNT(1) FROM hrm_users WHERE username = #{username}")
int countByUsername(@Param("username") String username);
/** 가입 중복 검사: email 존재 여부(enumeration 최소화 — 내부 사용). */
@Select("SELECT COUNT(1) FROM hrm_users WHERE email = #{email}")
int countByEmail(@Param("email") String email);
/** 아이디찾기: 이름(display_name)·이메일 일치 사용자(존재 시 마스킹 반환). */
@Select("""
SELECT id, username, password_hash, role, active, created_at,
display_name, email,
email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret,
approved, pw_change_yn
FROM hrm_users
WHERE display_name = #{displayName} AND email = #{email}
ORDER BY id LIMIT 1
""")
HrmUser findByDisplayNameAndEmail(@Param("displayName") String displayName,
@Param("email") String email);
/** 비번초기화: username·email 동시 일치 검증용. */
@Select("""
SELECT id, username, password_hash, role, active, created_at,
display_name, email,
email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret,
approved, pw_change_yn
FROM hrm_users
WHERE username = #{username} AND email = #{email}
""")
HrmUser findByUsernameAndEmail(@Param("username") String username,
@Param("email") String email);
/** 비번초기화: 임시비번 적용 + 변경유도 + 잠금/실패카운트 해제(멱등 UPDATE). */
@Update("""
UPDATE hrm_users
SET password_hash = #{passwordHash}, pw_change_yn = true,
locked = false, login_fail_count = 0
WHERE username = #{username}
""")
int applyTempPassword(@Param("username") String username,
@Param("passwordHash") String passwordHash);
// UIWS 2FA 이식: 실패 카운트/잠금/인증코드 갱신 (멱등 UPDATE)
/** 로그인 성공 시 실패 카운트 초기화. */
@Update("UPDATE hrm_users SET login_fail_count = 0 WHERE username = #{username}")
int resetLoginFail(@Param("username") String username);
/** 로그인 실패 누적(+1) 및 임계 도달 시 잠금. */
@Update("""
UPDATE hrm_users
SET login_fail_count = COALESCE(login_fail_count, 0) + 1,
locked = (COALESCE(login_fail_count, 0) + 1 >= #{maxFail})
WHERE username = #{username}
""")
int incrementLoginFail(@Param("username") String username, @Param("maxFail") int maxFail);
/** 1차 통과 시 이메일 인증코드/만료 저장(verify 단계에서 검증). */
@Update("""
UPDATE hrm_users
SET email_verify_code = #{code}, email_verify_expire = #{expire}, login_fail_count = 0
WHERE username = #{username}
""")
int saveEmailCode(@Param("username") String username,
@Param("code") String code,
@Param("expire") LocalDateTime expire);
/** 2차 검증 성공 시 코드 폐기. */
@Update("UPDATE hrm_users SET email_verify_code = NULL, email_verify_expire = NULL WHERE username = #{username}")
int clearEmailCode(@Param("username") String username);
/** 관리자 잠금 해제(실패 카운트/잠금 초기화). */
@Update("UPDATE hrm_users SET locked = false, login_fail_count = 0 WHERE username = #{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

@ -41,10 +41,18 @@ public class SecurityConfig {
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/hrm/auth/**").permitAll()
// UIWS 로그인 보조 3종(회원가입·아이디찾기·비번초기화) 로그인 무인증 접근.
.requestMatchers("/api/auth/**").permitAll()
// UIWS system: 가입 화면 공개 조회(부서/회사 GET) permitAll.
.requestMatchers(HttpMethod.GET, "/api/public/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/api/hrm/docs/**", "/api/hrm/swagger/**").permitAll()
.requestMatchers("/", "/index.html", "/assets/**", "/favicon.ico").permitAll()
// UIWS system(권한관리): 조회는 MANAGER 이상, 변경은 SUPERADMIN.
.requestMatchers(HttpMethod.GET, "/api/system/**").hasAnyRole("SUPERADMIN", "MANAGER")
.requestMatchers("/api/system/**").hasRole("SUPERADMIN")
.requestMatchers("/api/hrm/admin/users/**").hasRole("SUPERADMIN")
.requestMatchers(HttpMethod.GET, "/api/hrm/admin/settings").hasAnyRole("SUPERADMIN", "MANAGER")
.requestMatchers("/api/hrm/admin/settings/**").hasRole("SUPERADMIN")

View File

@ -15,8 +15,15 @@ spring:
minimum-idle: 1
sql:
init:
mode: ${SQL_INIT_MODE:never}
schema-locations: classpath:db/schema.sql
# schema.sql(전부 IF NOT EXISTS + ON CONFLICT DO NOTHING)·UIWS 이식 파일(전부 멱등)만 재적용.
# 91_uiws_port.sql: 업무 모듈 tb_uiws_* + hrm_users 2FA/로그인보조 컬럼 ALTER(멱등).
# 92_uiws_system.sql: UIWS system(권한관리) tb_uiws_* 10테이블(부서/회사/코드/사용자/역할/메뉴/프로그램, 멱등).
mode: ${SQL_INIT_MODE:always}
# 104_seed_ai_config.sql: AI 플랫폼(provider/모델) 설정 시드 hrm_settings ai.* (멱등 ON CONFLICT DO NOTHING).
# 93_auth_otp.sql: hrm_users otp_enabled 멱등 ALTER(TOTP 2차 인증). ops_otp_reset_all.sql 은 미등재(운영 1회 수동).
# 202_ai_provider_claude.sql: AI provider 를 claude 로 flip(멱등 UPSERT). 반드시 104 시드 이후로 등재.
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,classpath:db/202_ai_provider_claude.sql
continue-on-error: true
servlet:
multipart:
max-file-size: 20MB
@ -38,15 +45,38 @@ guardia:
erp-url: ${ERP_URL:http://localhost:8003}
itsm-url: ${ITSM_URL:http://localhost:9001}
groupware-url: ${GROUPWARE_URL:http://localhost:8009}
# 보안 불변 규칙: Ollama 온프레미스만 허용. 외부 AI API 절대 금지.
# 보안 불변 규칙: Ollama 온프레미스만 허용. 외부 AI API 절대 금지(Claude 는 소유자 승인 예외).
ollama-url: ${OLLAMA_URL:http://localhost:11434}
ollama-text-model: ${OLLAMA_TEXT_MODEL:llama3}
# 중앙 guardia-rag 연동(피드백 전달). base-url 은 서버 내부 루프백 전용 — 외부 URL 금지.
rag:
base-url: ${RAG_URL:http://127.0.0.1:8020}
enabled: ${RAG_ENABLED:true}
# 로컬 임베디드 DuckDB 학습 저장소 파일 경로(솔루션별 격리).
learning:
duckdb-path: ${HRM_LEARNING_DUCKDB:/opt/guardia-hrm/data/hrm_learning.duckdb}
crypto:
secret: ${HRM_CRYPTO_SECRET:guardia-hrm-aes-256-gcm-master-key-2026-zioinfo}
jwt:
secret: ${JWT_SECRET:guardia-hrm-jwt-secret-2026-minimum-256bit-key-zioinfo}
expiration: 86400000
# UIWS 이식 모듈 설정 (worklog·schedule·message·stats + 2FA 레이어 + 로그인 보조).
# 2FA 토글: hrm.uiws.auth.twofa-enabled=false 면 기존 단일 JWT 로그인 회귀 0.
hrm:
uiws:
auth:
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분
email-code-validity-seconds: 300 # 이메일 코드 5분
max-login-fail: 5 # 실패 5회 잠금
mail:
mode: ${UIWS_MAIL_MODE:log} # log 폴백(외부 API 금지). smtp 운영 시 별도 구현
upload:
upload-dir: ${UIWS_UPLOAD_DIR:./uploads/uiws}
logging:
level:
com.zioinfo.hrm: DEBUG

View File

@ -0,0 +1,22 @@
-- =====================================================================
-- GUARDiA HRM — 202. AI provider 를 claude 로 flip (멱등 UPSERT)
-- 대상 DB : hrm_db (테이블 hrm_settings, PK=key — schema.sql 에서 생성)
-- 적용 : application.yml spring.sql.init.mode=always + schema-locations 에 본 파일을
-- 104_seed_ai_config.sql **이후**로 등재(시드가 먼저 기본값 ollama INSERT → 본 파일이 claude 로 전환).
-- 설명 : 런타임 AI provider 를 Claude(Anthropic) 로 전환한다. UIWS db/202 대응본.
-- 키 미설정/실패 시 AiTextRouter 가 자동으로 Ollama 로 폴백하므로 무중단·무회귀.
-- 멱등 : provider 행이 없어도(UPSERT) INSERT, 있으면 value='claude' 로 UPDATE. 재실행 안전.
-- * hrm 서버 실측: hrm_settings 에 ai.provider 행 부재(코드 기본값 ollama) → INSERT 경로로 생성.
-- 보안 : Claude API 키는 DB 에 저장하지 않는다 — 서버 환경변수 ANTHROPIC_API_KEY 로만 주입.
-- 본 파일에 키/시크릿/IP/비밀번호 일절 미포함.
-- 활성화 : 실제 Claude 경로는 provider=claude · ANTHROPIC_API_KEY 설정됨 · ai.enabled=true 3조건
-- (AiConfigService.isClaudeActive) 충족 시. 그 외에는 Ollama 폴백(정상 동작).
-- =====================================================================
SET client_encoding = 'UTF8';
INSERT INTO hrm_settings (key, value, description) VALUES
('ai.provider', 'claude', 'AI provider(ollama/claude/qwen3/deepseek/glm)')
ON CONFLICT (key) DO UPDATE SET value = 'claude';
-- end 202_ai_provider_claude.sql

View File

@ -39,6 +39,10 @@
UPDATE hrm_users SET active=#{active} WHERE id=#{id}
</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, actor, action, target_type, target_id, detail, ip_addr, created_at
FROM hrm_audit_log

View File

@ -12,10 +12,22 @@
<result property="role" column="role"/>
<result property="active" column="active"/>
<result property="createdAt" column="created_at"/>
<!-- UIWS 2FA 이식 컬럼 (db/91_uiws_port.sql 멱등 ALTER 로 추가) -->
<result property="emailVerifyCode" column="email_verify_code"/>
<result property="emailVerifyExpire" column="email_verify_expire"/>
<result property="loginFailCount" column="login_fail_count"/>
<result property="locked" column="locked"/>
<result property="otpSecret" column="otp_secret"/>
<result property="otpEnabled" column="otp_enabled"/>
<!-- UIWS 로그인 보조기능 이식 컬럼 (회원가입 승인·임시비번 변경유도) -->
<result property="approved" column="approved"/>
<result property="pwChangeYn" column="pw_change_yn"/>
</resultMap>
<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, otp_enabled,
approved, pw_change_yn
FROM hrm_users WHERE username = #{username}
</select>
@ -24,4 +36,13 @@
VALUES (#{username}, #{passwordHash}, #{displayName}, #{email}, #{role}, #{active})
</insert>
<!-- UIWS 회원가입 이식: 가입자는 항상 VIEWER(최소권한)·승인 대기(approved=false). -->
<insert id="insertSignup" parameterType="com.zioinfo.hrm.auth.HrmUser"
useGeneratedKeys="true" keyProperty="id">
INSERT INTO hrm_users (username, password_hash, role, display_name, email,
active, approved, login_fail_count, locked)
VALUES (#{username}, #{passwordHash}, 'VIEWER', #{displayName}, #{email},
true, false, 0, false)
</insert>
</mapper>

View File

@ -2,7 +2,7 @@
<html lang="ko">
<head>
<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" />
<title>GUARDiA HRM — AI 인사관리 플랫폼</title>
</head>

View File

@ -10,7 +10,21 @@ import PerformancePage from './pages/PerformancePage'
import RecruitmentPage from './pages/RecruitmentPage'
import TrainingPage from './pages/TrainingPage'
import AiPage from './pages/AiPage'
import WiseAiPage from './pages/WiseAiPage'
import AiPlatformSettings from './pages/AiPlatformSettings'
import AdminPage from './pages/AdminPage'
import MyPage from './pages/MyPage'
import MobileApp from './pages/MobileApp'
// UIWS(UIMS) 이식 — 공통 업무협업 화면(업무일지·일정·쪽지·통계)
import WorklogList from './pages/uiws/WorklogList'
import ScheduleCalendar from './pages/uiws/ScheduleCalendar'
import MessageBox from './pages/uiws/MessageBox'
import StatsPivot from './pages/uiws/StatsPivot'
// UIWS system(권한관리) 이식 — 시스템관리(권한) 화면 셸(탭: 권한/역할-메뉴/공통코드/메뉴/부서/거래처)
import SystemPage from './pages/uiws/system/SystemPage'
// 시스템관리(권한)는 관리자 전용. 백엔드 /api/system/** 조회=SUPERADMIN/MANAGER, 쓰기=SUPERADMIN.
const ADMIN_ROLES = ['SUPERADMIN', 'MANAGER', 'ADMIN']
const MENU = [
{ path: '/dashboard', label: '대시보드', icon: '📊' },
@ -21,13 +35,25 @@ const MENU = [
{ path: '/performance', label: '성과평가', icon: '🎯' },
{ path: '/recruitment', label: '채용관리', icon: '🔍' },
{ path: '/training', label: '교육관리', icon: '📚' },
// UIWS 이식 — 업무 협업
{ path: '/worklogs', label: '업무일지', icon: '📝' },
{ path: '/schedules', label: '일정관리', icon: '📅' },
{ path: '/messages', label: '쪽지함', icon: '✉️' },
{ path: '/work-stats', label: '업무통계', icon: '📈' },
{ path: '/ai', label: 'AI 인사분석', icon: '🤖' },
{ path: '/wise-ai', label: 'WISE AI', icon: '✨' },
{ path: '/ai-platform', label: 'AI 플랫폼 설정', icon: '🧠', adminOnly: true },
{ path: '/admin', label: '시스템관리', icon: '⚙️' },
// UIWS system 이식 — 권한·코드·메뉴·부서·거래처 (관리자 전용)
{ path: '/system', label: '시스템관리(권한)', icon: '🔐', adminOnly: true },
{ path: '/mobile-app', label: '모바일 앱', icon: '📱' },
]
function Layout({ children }: { children: React.ReactNode }) {
const [collapsed, setCollapsed] = useState(false)
const user = JSON.parse(localStorage.getItem('hrm_user') || '{"displayName":"관리자","role":"SUPERADMIN"}')
const isAdmin = ADMIN_ROLES.includes(user.role)
const visibleMenu = MENU.filter(m => !m.adminOnly || isAdmin)
return (
<div className="flex h-screen overflow-hidden">
@ -45,7 +71,7 @@ function Layout({ children }: { children: React.ReactNode }) {
</button>
</div>
<nav className="flex-1 overflow-y-auto py-3 px-2 space-y-1">
{MENU.map(m => (
{visibleMenu.map(m => (
<NavLink key={m.path} to={m.path}
className={({ isActive }) => `sidebar-item ${isActive ? 'active' : ''}`}>
<span className="text-base">{m.icon}</span>
@ -66,10 +92,13 @@ function Layout({ children }: { children: React.ReactNode }) {
)}
</div>
{!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'; }}>
</button>
</div>
)}
</div>
</aside>
@ -87,6 +116,15 @@ function PrivateRoute({ children }: { children: React.ReactNode }) {
return <Layout>{children}</Layout>
}
// 관리자 전용 라우트 가드(UI 차단). 실제 인가는 백엔드 /api/system/** RBAC가 강제(403).
function AdminRoute({ children }: { children: React.ReactNode }) {
const token = localStorage.getItem('hrm_token')
if (!token) return <Navigate to="/login" replace />
const user = JSON.parse(localStorage.getItem('hrm_user') || '{"role":"SUPERADMIN"}')
if (!ADMIN_ROLES.includes(user.role)) return <Navigate to="/dashboard" replace />
return <Layout>{children}</Layout>
}
export default function App() {
return (
<BrowserRouter>
@ -101,8 +139,23 @@ export default function App() {
<Route path="/performance" element={<PrivateRoute><PerformancePage /></PrivateRoute>} />
<Route path="/recruitment" element={<PrivateRoute><RecruitmentPage /></PrivateRoute>} />
<Route path="/training" element={<PrivateRoute><TrainingPage /></PrivateRoute>} />
{/* UIWS(UIMS) 이식 — 업무 협업 화면 */}
<Route path="/worklogs" element={<PrivateRoute><WorklogList /></PrivateRoute>} />
<Route path="/schedules" element={<PrivateRoute><ScheduleCalendar /></PrivateRoute>} />
<Route path="/messages" element={<PrivateRoute><MessageBox /></PrivateRoute>} />
<Route path="/work-stats" element={<PrivateRoute><StatsPivot /></PrivateRoute>} />
<Route path="/ai" element={<PrivateRoute><AiPage /></PrivateRoute>} />
{/* WISE AI — 중앙 guardia-rag 근거·인용 질의응답(인증 사용자) */}
<Route path="/wise-ai" element={<PrivateRoute><WiseAiPage /></PrivateRoute>} />
{/* AI 플랫폼 설정(provider/모델·연결테스트·피드백 학습) — 관리자 전용. 백엔드 /api/hrm/admin/** RBAC 강제 */}
<Route path="/ai-platform" element={<AdminRoute><AiPlatformSettings /></AdminRoute>} />
<Route path="/admin" element={<PrivateRoute><AdminPage /></PrivateRoute>} />
{/* 마이페이지 — OTP 2차 인증 등록/재설정/해제 + 비밀번호 변경 */}
<Route path="/mypage" element={<PrivateRoute><MyPage /></PrivateRoute>} />
{/* UIWS system 이식 — 시스템관리(권한). 관리자 전용(SUPERADMIN/MANAGER/ADMIN), 그 외 대시보드로 */}
<Route path="/system" element={<AdminRoute><SystemPage /></AdminRoute>} />
{/* 통합 메신저 앱 다운로드 QR (ITSM 중앙 APK 저장소 공개 엔드포인트 재사용·읽기전용) */}
<Route path="/mobile-app" element={<PrivateRoute><MobileApp /></PrivateRoute>} />
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</BrowserRouter>

View File

@ -20,3 +20,30 @@ api.interceptors.response.use(
)
export default api
// ── WISE AI (중앙 guardia-rag 프록시) ──────────────────────────────────
// 별도 baseURL(/api/wise) — 공용 api(/api/hrm)와 경로가 다르므로 전용 인스턴스.
// 인증 사용자 전용(로그인 JWT). 응답은 ApiResponse 봉투(r.data.data 에 rag 결과).
const wiseApi = axios.create({ baseURL: '/api/wise' })
wiseApi.interceptors.request.use(cfg => {
const token = localStorage.getItem('hrm_token')
if (token) cfg.headers.Authorization = `Bearer ${token}`
return cfg
})
export const wiseAsk = (query: string) => wiseApi.post('/ask', { query })
// ── 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

@ -40,3 +40,45 @@ body {
.table-cell {
@apply px-4 py-3 text-sm text-slate-700;
}
/*
* UIWS 이식 컴포넌트(components/uiws/ui.tsx) 디자인 토큰.
* 색상 하드코딩 금지 컴포넌트는 var(--uiws-*) 사용한다.
* 기본은 HRM 라이트 테마와 정합. OS 다크 모드 자동 전환(대비/가독성 보존).
* 전역 HRM 테마를 덮어쓰지 않도록 토큰만 정의(모듈 스코프).
*/
:root {
--uiws-surface: #ffffff;
--uiws-surface-2: #f1f5f9;
--uiws-border: #e2e8f0;
--uiws-text: #1e293b;
--uiws-text-muted: #64748b;
--uiws-text-faint: #94a3b8;
--uiws-primary: #2563eb;
--uiws-primary-contrast: #ffffff;
--uiws-primary-soft: #eff6ff;
--uiws-input-bg: #ffffff;
--uiws-row-hover: #f8fafc;
--uiws-danger: #dc2626;
--uiws-success: #16a34a;
--uiws-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
}
@media (prefers-color-scheme: dark) {
:root {
--uiws-surface: #1e293b;
--uiws-surface-2: #0f172a;
--uiws-border: #334155;
--uiws-text: #e2e8f0;
--uiws-text-muted: #94a3b8;
--uiws-text-faint: #64748b;
--uiws-primary: #3b82f6;
--uiws-primary-contrast: #ffffff;
--uiws-primary-soft: #1e3a5f;
--uiws-input-bg: #0f172a;
--uiws-row-hover: #273449;
--uiws-danger: #f87171;
--uiws-success: #4ade80;
--uiws-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
}
}

View File

@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react'
import api from '../api/client'
import api, { adminOtpReset } from '../api/client'
export default function AdminPage() {
const [users, setUsers] = useState<any[]>([])
@ -28,6 +28,21 @@ export default function AdminPage() {
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) => {
await api.put(`/admin/settings/${key}`, null, { params: { value } })
loadSettings()
@ -79,10 +94,16 @@ export default function AdminPage() {
</td>
<td className="table-cell text-xs">{u.last_login_at?.slice(0,16) || '-'}</td>
<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'}`}
onClick={() => toggleUser(u.id, u.is_active)}>
{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>
</tr>
))}

View File

@ -1,6 +1,10 @@
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import api from '../api/client'
import { verify2fa, verifyOtp, signup, findId, resetPasswordHelper } from '../api/uiws'
type Helper = null | 'signup' | 'findId' | 'resetPw'
type VerifyMethod = 'OTP' | 'OTP_SETUP' | 'EMAIL'
export default function LoginPage() {
const nav = useNavigate()
@ -8,17 +12,67 @@ export default function LoginPage() {
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
// UIWS 2FA — 1차 통과 후 코드 입력 단계.
// · 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 isOtp = twofa?.verifyMethod === 'OTP' || twofa?.verifyMethod === 'OTP_SETUP'
const isSetup = twofa?.verifyMethod === 'OTP_SETUP'
// 로그인 보조 3종 모달
const [helper, setHelper] = useState<Helper>(null)
const afterAuthenticated = async () => {
const me = await api.get('/auth/me')
localStorage.setItem('hrm_user', JSON.stringify(me.data.data))
nav('/dashboard')
}
const login = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true); setError('')
try {
const { data } = await api.post('/auth/login', form)
const payload = data.data || {}
if (payload.twofa === 'true') {
// 2FA 활성 — verifyMethod 로 분기(access 토큰 미발급). 시크릿/QR은 화면 표시용만.
setTwofa({
verifyToken: payload.verifyToken,
verifyMethod: (payload.verifyMethod as VerifyMethod) || 'EMAIL',
maskedEmail: payload.maskedEmail || '',
qrImage: payload.qrImage || '',
secret: payload.secret || '',
})
setCode('')
return
}
localStorage.setItem('hrm_token', payload.token)
await afterAuthenticated()
} catch (err: any) {
const msg = err?.response?.data?.message || ''
setError(msg.includes('잠겼') || msg.includes('승인')
? msg : '아이디 또는 비밀번호가 올바르지 않습니다.')
} finally { setLoading(false) }
}
const verify = async (e: React.FormEvent) => {
e.preventDefault()
if (!twofa) return
setLoading(true); setError('')
try {
// 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)
const me = await api.get('/auth/me')
localStorage.setItem('hrm_user', JSON.stringify(me.data.data))
nav('/dashboard')
await afterAuthenticated()
} catch {
setError('아이디 또는 비밀번호가 올바르지 않습니다.')
setError('인증 코드가 올바르지 않거나 만료되었습니다.')
} finally { setLoading(false) }
}
@ -29,6 +83,8 @@ export default function LoginPage() {
<h1 className="text-3xl font-bold text-white">GUARDiA HRM</h1>
<p className="text-blue-300 mt-2">AI </p>
</div>
{!twofa ? (
<form onSubmit={login} className="bg-white rounded-2xl shadow-2xl p-8 space-y-5">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1"></label>
@ -49,11 +105,132 @@ export default function LoginPage() {
className="w-full py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors">
{loading ? '로그인 중...' : '로그인'}
</button>
{/* UIWS 로그인 보조 3종 */}
<div className="flex items-center justify-center gap-3 text-xs text-slate-500">
<button type="button" className="hover:text-blue-600" onClick={() => { setHelper('signup'); setError('') }}></button>
<span className="text-slate-300">|</span>
<button type="button" className="hover:text-blue-600" onClick={() => { setHelper('findId'); setError('') }}> </button>
<span className="text-slate-300">|</span>
<button type="button" className="hover:text-blue-600" onClick={() => { setHelper('resetPw'); setError('') }}> </button>
</div>
<p className="text-center text-xs text-slate-400">
GUARDiA HRM v1.0 | AI
</p>
</form>
) : (
<form onSubmit={verify} className="bg-white rounded-2xl shadow-2xl p-8 space-y-5">
<div className="text-center">
<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">
{twofa.maskedEmail ? `${twofa.maskedEmail} 으로 발송된 인증 코드를 입력하세요.` : '이메일로 발송된 인증 코드를 입력하세요.'}
</p>
)}
</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
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"
placeholder="6자리 코드" maxLength={6} required />
{error && <p className="text-red-500 text-sm text-center">{error}</p>}
<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">
{loading ? '확인 중...' : isSetup ? '등록하고 로그인' : '인증 확인'}
</button>
<button type="button" onClick={() => { setTwofa(null); setCode(''); setError('') }}
className="w-full text-xs text-slate-400 hover:text-slate-600"> </button>
</form>
)}
</div>
{helper && <HelperModal kind={helper} onClose={() => setHelper(null)} />}
</div>
)
}
/** 로그인 보조 3종 모달 — 회원가입 / 아이디찾기 / 비밀번호 초기화. */
function HelperModal({ kind, onClose }: { kind: Exclude<Helper, null>; onClose: () => void }) {
const [f, setF] = useState({ username: '', password: '', displayName: '', email: '' })
const [msg, setMsg] = useState('')
const [busy, setBusy] = useState(false)
const set = (k: string, v: string) => setF({ ...f, [k]: v })
const TITLE: Record<string, string> = { signup: '회원가입', findId: '아이디 찾기', resetPw: '비밀번호 초기화' }
const submit = async (e: React.FormEvent) => {
e.preventDefault()
setBusy(true); setMsg('')
try {
if (kind === 'signup') {
const { data } = await signup({ username: f.username, password: f.password, displayName: f.displayName, email: f.email })
setMsg(data.data?.message || '처리되었습니다.')
} else if (kind === 'findId') {
const { data } = await findId({ displayName: f.displayName, email: f.email })
setMsg(data.data?.found ? `회원님의 아이디: ${data.data.maskedUsername}` : '일치하는 계정을 찾을 수 없습니다.')
} else {
const { data } = await resetPasswordHelper({ username: f.username, email: f.email })
setMsg(data.data?.message || '처리되었습니다.')
}
} catch {
setMsg('요청 처리 중 오류가 발생했습니다.')
} finally { setBusy(false) }
}
const input = (k: string, ph: string, type = 'text') => (
<input type={type} value={(f as any)[k]} onChange={e => set(k, e.target.value)}
className="w-full border border-slate-200 rounded-lg px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder={ph} required />
)
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50" onClick={onClose}>
<form onClick={e => e.stopPropagation()} onSubmit={submit}
className="bg-white rounded-2xl shadow-2xl p-7 w-full max-w-sm space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-bold text-slate-800">{TITLE[kind]}</h2>
<button type="button" onClick={onClose} className="text-slate-400 hover:text-slate-600"></button>
</div>
{kind === 'signup' && <>
{input('username', '아이디')}
{input('password', '비밀번호(4자 이상)', 'password')}
{input('displayName', '이름')}
{input('email', '이메일', 'email')}
</>}
{kind === 'findId' && <>
{input('displayName', '이름')}
{input('email', '이메일', 'email')}
</>}
{kind === 'resetPw' && <>
{input('username', '아이디')}
{input('email', '이메일', 'email')}
</>}
{msg && <p className="text-sm text-blue-700 bg-blue-50 rounded-lg px-3 py-2">{msg}</p>}
<button type="submit" disabled={busy}
className="w-full py-2.5 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors">
{busy ? '처리 중...' : '확인'}
</button>
</form>
</div>
)
}