UserLog 엔티티 필드 추가 및 관련 API User-Agent 처리 지원
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled

- UserLog에 userAgent(500자)와 duplicate(Boolean) 컬럼 추가
- 로그인/실패/로그아웃 로깅 시 User-Agent와 중복 세션 여부 기록
- TwoFactorService/Controller Request 객체 전달 및 처리 반영
This commit is contained in:
Rinjae
2026-08-11 19:10:39 +09:00
parent 8356fc3029
commit e32acfa215
5 changed files with 45 additions and 21 deletions
@@ -41,8 +41,9 @@ public class TwoFactorController {
public TwoFactorSendResponse send(@RequestParam String channel,
@RequestParam(required = false) String purpose,
@RequestParam(required = false, defaultValue = "false") boolean force,
HttpServletRequest request,
HttpSession session) {
return twoFactorService.send(session, channel, purpose, force);
return twoFactorService.send(request, session, channel, purpose, force);
}
/** 인증번호 검증 */
@@ -115,7 +115,8 @@ public class TwoFactorService {
// SEND
// =========================================================================
public TwoFactorSendResponse send(HttpSession session, String channel, String purpose, boolean force) {
public TwoFactorSendResponse send(HttpServletRequest request, HttpSession session, String channel,
String purpose, boolean force) {
TwoFactorSendResponse res = new TwoFactorSendResponse();
TwoFactorContext.Mode mode = resolveMode(session);
@@ -156,7 +157,7 @@ public class TwoFactorService {
res.setMessage("진행 중인 다른 인증 절차가 있습니다. 강제 종료 후 진행하시겠습니까?");
return res;
}
discardContext(session, ctx); // 강제 종료(감사 기록 포함)
discardContext(request, session, ctx); // 강제 종료(감사 기록 포함)
} else if (ctx.getStartedAt() != null
&& ctx.getStartedAt().plusSeconds(RESEND_THROTTLE_SECONDS).isAfter(LocalDateTime.now())) {
res.setValid(false);
@@ -280,7 +281,7 @@ public class TwoFactorService {
String stateError = revalidateLoginState(user);
if (stateError != null) {
userLogService.logFailure(loginId, request.getRemoteAddr(), session.getId(),
LoginFailureReason.ACCOUNT_DISABLED);
LoginFailureReason.ACCOUNT_DISABLED, request.getHeader("User-Agent"));
clearPending(session);
res.setValid(false);
res.setTerminated(true);
@@ -316,7 +317,8 @@ public class TwoFactorService {
if (ctx != null && ctx.getMode() == TwoFactorContext.Mode.LOGIN) {
String loginId = (String) session.getAttribute(ATTR_PENDING_LOGIN_ID);
userLogService.logFailure(loginId, request.getRemoteAddr(), session.getId(), failureReason);
userLogService.logFailure(loginId, request.getRemoteAddr(), session.getId(), failureReason,
request.getHeader("User-Agent"));
}
if (ctx != null && ctx.getRecipient() != null) {
authNumberStorage.deleteAuthNumber(ctx.getRecipient());
@@ -456,10 +458,11 @@ public class TwoFactorService {
}
/** 강제 종료: 인증번호 삭제 + (로그인 컨텍스트면) 취소 감사 기록 */
private void discardContext(HttpSession session, TwoFactorContext ctx) {
private void discardContext(HttpServletRequest request, HttpSession session, TwoFactorContext ctx) {
if (ctx.getMode() == TwoFactorContext.Mode.LOGIN) {
String loginId = (String) session.getAttribute(ATTR_PENDING_LOGIN_ID);
userLogService.logFailure(loginId, "-", session.getId(), LoginFailureReason.TWO_FACTOR_CANCELLED);
userLogService.logFailure(loginId, request.getRemoteAddr(), session.getId(),
LoginFailureReason.TWO_FACTOR_CANCELLED, request.getHeader("User-Agent"));
}
if (ctx.getRecipient() != null) {
authNumberStorage.deleteAuthNumber(ctx.getRecipient());
@@ -472,7 +475,8 @@ public class TwoFactorService {
LoginFailureReason reason, HttpServletRequest request) {
if (ctx.getMode() == TwoFactorContext.Mode.LOGIN) {
String loginId = (String) session.getAttribute(ATTR_PENDING_LOGIN_ID);
userLogService.logFailure(loginId, request.getRemoteAddr(), session.getId(), reason);
userLogService.logFailure(loginId, request.getRemoteAddr(), session.getId(), reason,
request.getHeader("User-Agent"));
clearPending(session);
}
if (ctx.getRecipient() != null) {
@@ -92,7 +92,12 @@ public class LoginFinalizer {
String ip = request.getRemoteAddr();
String sessionId = request.getSession().getId();
userLogService.logSuccess(rawUsername, ip, sessionId, loginType);
// 다른 기기/브라우저의 활성 세션이 남아 있는 상태의 로그인인지(아래 forceLogoutOtherSessions 로
// 기존 세션이 끊기는 건인지) 기록한다. 현재 세션 등록(registerSession)은 이 뒤에 일어나므로
// 여기서 조회되는 활성 세션은 항상 "다른 곳"의 세션이다.
boolean duplicate = userSessionService.getActiveSession(normalizedUsername).isPresent();
userLogService.logSuccess(rawUsername, ip, sessionId, loginType, request.getHeader("User-Agent"),
duplicate);
String contextPath = request.getContextPath();
HttpSession session = request.getSession();
@@ -12,17 +12,22 @@ import org.springframework.transaction.annotation.Transactional;
@Transactional
public class PortalUserLogService {
/** PTL_USER_LOG.USER_AGENT 컬럼 길이. 초과분은 잘라서 저장한다(초과 시 ORA-12899 로 로그인 실패). */
private static final int USER_AGENT_MAX_LENGTH = 500;
private final UserLogRepository userLogRepository;
public PortalUserLogService(UserLogRepository userLogRepository) {
this.userLogRepository = userLogRepository;
}
public void logSuccess(String userId, String ip, String sessionId) {
logSuccess(userId, ip, sessionId, LoginType.NORMAL);
}
public void logSuccess(String userId, String ip, String sessionId, LoginType loginType) {
/**
* 로그인 성공 기록.
*
* @param duplicate 로그인 시점에 다른 활성 세션이 남아 있어 기존 세션이 강제 해제된 건이면 true
*/
public void logSuccess(String userId, String ip, String sessionId, LoginType loginType, String userAgent,
boolean duplicate) {
UserLog log = new UserLog();
log.setLoginId(userId);
log.setLoginTime(LocalDateTime.now());
@@ -30,15 +35,13 @@ public class PortalUserLogService {
log.setSessionId(sessionId);
log.setSuccess(true);
log.setLoginType(loginType != null ? loginType.name() : null);
log.setUserAgent(truncateUserAgent(userAgent));
log.setDuplicate(duplicate);
userLogRepository.save(log);
}
public void logFailure(String userId, String ip, String sessionId) {
logFailure(userId, ip, sessionId, LoginFailureReason.UNKNOWN);
}
public void logFailure(String userId, String ip, String sessionId, LoginFailureReason reason) {
public void logFailure(String userId, String ip, String sessionId, LoginFailureReason reason, String userAgent) {
UserLog log = new UserLog();
log.setLoginId(userId);
log.setLoginTime(LocalDateTime.now());
@@ -46,19 +49,29 @@ public class PortalUserLogService {
log.setSessionId(sessionId);
log.setSuccess(false);
log.setFailureReason(reason != null ? reason.name() : LoginFailureReason.UNKNOWN.name());
log.setUserAgent(truncateUserAgent(userAgent));
userLogRepository.save(log);
}
@Transactional
public void logLogout(String userId, String ip, String sessionId) {
public void logLogout(String userId, String ip, String sessionId, String userAgent) {
UserLog log = new UserLog();
log.setLoginId(userId);
log.setLogoutTime(LocalDateTime.now());
log.setIp(ip);
log.setSessionId(sessionId);
log.setSuccess(true);
log.setUserAgent(truncateUserAgent(userAgent));
userLogRepository.save(log);
}
private String truncateUserAgent(String userAgent) {
if (userAgent == null) {
return null;
}
return userAgent.length() <= USER_AGENT_MAX_LENGTH
? userAgent : userAgent.substring(0, USER_AGENT_MAX_LENGTH);
}
}
@@ -97,7 +97,8 @@ public class PortalAuthenticationFailureHandler implements AuthenticationFailure
}
}
userLogService.logFailure(username, ip, sessionId, resolveFailureReason(exception));
userLogService.logFailure(username, ip, sessionId, resolveFailureReason(exception),
request.getHeader("User-Agent"));
// 로그인 실패 시 세션 정보 로깅
logLoginFailure(request, username, exception);