diff --git a/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/TwoFactorController.java b/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/TwoFactorController.java index e26ad47..46f9391 100644 --- a/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/TwoFactorController.java +++ b/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/TwoFactorController.java @@ -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); } /** 인증번호 검증 */ diff --git a/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/TwoFactorService.java b/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/TwoFactorService.java index 43d87c6..aa1e6b5 100644 --- a/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/TwoFactorService.java +++ b/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/TwoFactorService.java @@ -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) { diff --git a/src/main/java/com/eactive/apim/portal/apps/login/service/LoginFinalizer.java b/src/main/java/com/eactive/apim/portal/apps/login/service/LoginFinalizer.java index b6d3978..2ca023b 100644 --- a/src/main/java/com/eactive/apim/portal/apps/login/service/LoginFinalizer.java +++ b/src/main/java/com/eactive/apim/portal/apps/login/service/LoginFinalizer.java @@ -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(); diff --git a/src/main/java/com/eactive/apim/portal/apps/user/service/PortalUserLogService.java b/src/main/java/com/eactive/apim/portal/apps/user/service/PortalUserLogService.java index b214538..13abf39 100644 --- a/src/main/java/com/eactive/apim/portal/apps/user/service/PortalUserLogService.java +++ b/src/main/java/com/eactive/apim/portal/apps/user/service/PortalUserLogService.java @@ -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); + } } diff --git a/src/main/java/com/eactive/apim/portal/config/PortalAuthenticationFailureHandler.java b/src/main/java/com/eactive/apim/portal/config/PortalAuthenticationFailureHandler.java index 7da961f..f2aa349 100644 --- a/src/main/java/com/eactive/apim/portal/config/PortalAuthenticationFailureHandler.java +++ b/src/main/java/com/eactive/apim/portal/config/PortalAuthenticationFailureHandler.java @@ -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);