203 lines
9.9 KiB
Java
203 lines
9.9 KiB
Java
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.mapper.UserMapper;
|
|
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 {
|
|
|
|
private final UserMapper userMapper;
|
|
private final PasswordEncoder passwordEncoder;
|
|
private final JwtUtil jwtUtil;
|
|
private final TwoFactorService twoFactorService;
|
|
private final MailSender mailSender;
|
|
|
|
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 활성 시 실패 누적/잠금. 비활성 시 기존 동작(메시지만) 유지.
|
|
if (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: 비밀번호 불일치");
|
|
}
|
|
|
|
// 비밀번호 검증 통과
|
|
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) {
|
|
String username = jwtUtil.getUsername(token);
|
|
String role = jwtUtil.getRole(token);
|
|
HrmUser u = userMapper.findByUsername(username);
|
|
Map<String, Object> m = new HashMap<>();
|
|
m.put("username", username);
|
|
m.put("role", role);
|
|
m.put("displayName", u != null ? u.getDisplayName() : username);
|
|
return m;
|
|
}
|
|
|
|
// ── 로그인 보조 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));
|
|
}
|
|
}
|