Merge branch 'feature/session-timeout'
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled

# Conflicts:
#	src/main/resources/static/css/main.css.map
This commit is contained in:
Rinjae
2026-06-19 18:09:47 +09:00
18 changed files with 849 additions and 18 deletions
@@ -0,0 +1,130 @@
package com.eactive.apim.portal.apps.session.controller;
import com.eactive.apim.portal.apps.session.entity.UserSession;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
/**
* 세션 타이머/유휴 로그아웃/중복로그인 처리용 REST API.
*
* <ul>
* <li>GET /api/session/status - 잔여 시간/유효성 폴링 (인증 필요)</li>
* <li>POST /api/session/heartbeat - 세션 연장 (lastAccessTime 갱신)</li>
* <li>POST /api/session/check-duplicate - 로그인 전 중복 세션 확인 (CSRF 예외)</li>
* </ul>
*/
@Slf4j
@RestController
@RequestMapping("/api/session")
@RequiredArgsConstructor
public class SessionApiController {
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final UserSessionService userSessionService;
/**
* 로그인 전 중복 세션 확인
*/
@PostMapping("/check-duplicate")
public ResponseEntity<Map<String, Object>> checkDuplicate(@RequestParam("loginId") String loginId) {
Map<String, Object> result = new HashMap<>();
String normalizedLoginId = loginId != null ? loginId.toLowerCase() : "";
Optional<UserSession> activeSession = userSessionService.getActiveSession(normalizedLoginId);
if (activeSession.isPresent()) {
UserSession session = activeSession.get();
result.put("duplicateSession", true);
result.put("ipAddress", maskIpAddress(session.getIpAddress()));
result.put("loginTime", session.getLoginTime().format(TIME_FORMATTER));
} else {
result.put("duplicateSession", false);
}
return ResponseEntity.ok(result);
}
/**
* 세션 상태 폴링 (인증 필요)
*/
@GetMapping("/status")
public ResponseEntity<Map<String, Object>> getSessionStatus(HttpServletRequest request) {
Map<String, Object> result = new HashMap<>();
HttpSession httpSession = request.getSession(false);
if (httpSession == null) {
result.put("valid", false);
result.put("remainingSeconds", 0);
return ResponseEntity.ok(result);
}
String sessionId = httpSession.getId();
boolean forceLoggedOut = userSessionService.isForceLoggedOut(sessionId);
if (forceLoggedOut) {
result.put("valid", false);
result.put("remainingSeconds", 0);
} else {
long remaining = userSessionService.getRemainingSeconds(sessionId);
result.put("valid", remaining > 0);
result.put("remainingSeconds", remaining);
}
return ResponseEntity.ok(result);
}
/**
* 하트비트 - lastAccessTime 갱신 (세션 연장)
*/
@PostMapping("/heartbeat")
public ResponseEntity<Map<String, Object>> heartbeat(HttpServletRequest request) {
Map<String, Object> result = new HashMap<>();
HttpSession httpSession = request.getSession(false);
if (httpSession == null) {
result.put("remainingSeconds", 0);
return ResponseEntity.ok(result);
}
String sessionId = httpSession.getId();
userSessionService.updateLastAccessTime(sessionId);
long remaining = userSessionService.getRemainingSeconds(sessionId);
result.put("remainingSeconds", remaining);
return ResponseEntity.ok(result);
}
/**
* IP 주소 마스킹 (3번째 옥텟을 ***로 치환)
* 예: 192.168.240.178 → 192.168.***.178
*/
private String maskIpAddress(String ip) {
if (ip == null || ip.isEmpty()) {
return "알 수 없음";
}
String[] parts = ip.split("\\.");
if (parts.length == 4) {
return parts[0] + "." + parts[1] + ".***." + parts[3];
}
// IPv6 등 다른 형식은 일부만 표시
if (ip.length() > 8) {
return ip.substring(0, 4) + "****" + ip.substring(ip.length() - 4);
}
return "***";
}
}
@@ -0,0 +1,54 @@
package com.eactive.apim.portal.apps.session.entity;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Table;
import java.time.LocalDateTime;
/**
* 사용자 세션 추적 엔티티 (EMS 스키마, PTL_USER_SESSION)
*
* <p>화면 세션 타이머/유휴 자동 로그아웃 및 중복로그인 강제 로그아웃에 사용된다.
* {@code lastAccessTime} + 타임아웃(분) 이 세션 만료의 기준이며, {@code forceLogout='Y'} 인 세션은
* 다음 요청 시 {@link com.eactive.apim.portal.apps.session.filter.SessionValidationFilter} 가 강제 로그아웃한다.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
@Table(name = "PTL_USER_SESSION")
public class UserSession {
@Id
@Column(name = "SESSION_ID", length = 128, nullable = false)
private String sessionId;
@Column(name = "USER_ID", length = 36, nullable = false)
private String userId;
@Column(name = "LOGIN_ID", length = 200, nullable = false)
private String loginId;
@Column(name = "LOGIN_TIME", nullable = false)
private LocalDateTime loginTime;
@Column(name = "LAST_ACCESS_TIME", nullable = false)
private LocalDateTime lastAccessTime;
@Column(name = "IP_ADDRESS", length = 45)
private String ipAddress;
@Column(name = "USER_AGENT", length = 500)
private String userAgent;
@Column(name = "FORCE_LOGOUT", length = 1, nullable = false)
@Builder.Default
private String forceLogout = "N";
}
@@ -0,0 +1,124 @@
package com.eactive.apim.portal.apps.session.filter;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
/**
* 인증 사용자 요청마다 DB 세션 상태를 검증한다.
*
* <ol>
* <li>강제 로그아웃(forceLogout='Y') 감지 시 세션 무효화 후 {@code /login?forceLogout=true}</li>
* <li>잔여 시간 만료 시 세션 무효화 후 {@code /login?expired=true}</li>
* <li>그 외에는 60초 스로틀로 lastAccessTime 갱신 (실제 활동 반영)</li>
* </ol>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SessionValidationFilter extends OncePerRequestFilter {
private static final String LAST_DB_UPDATE_ATTR = "SESSION_LAST_DB_UPDATE";
private static final long DB_UPDATE_THROTTLE_MS = 60_000; // 60초 스로틀링
private static final List<String> EXCLUDED_PREFIXES = Arrays.asList(
"/api/session/",
"/login",
"/actionLogin.do",
"/actionLogout.do",
"/css/",
"/js/",
"/img/",
"/plugins/",
"/fonts/",
"/favicon.ico",
"/error",
"/health"
);
private final UserSessionService userSessionService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String path = request.getRequestURI();
String contextPath = request.getContextPath();
if (contextPath != null && !contextPath.isEmpty()) {
path = path.substring(contextPath.length());
}
// 제외 경로 체크
if (isExcludedPath(path)) {
filterChain.doFilter(request, response);
return;
}
// 인증되지 않은 요청은 통과
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated() || "anonymousUser".equals(auth.getPrincipal())) {
filterChain.doFilter(request, response);
return;
}
HttpSession httpSession = request.getSession(false);
if (httpSession == null) {
filterChain.doFilter(request, response);
return;
}
String sessionId = httpSession.getId();
// 1. 강제 로그아웃 체크 (중복 로그인)
if (userSessionService.isForceLoggedOut(sessionId)) {
log.info("강제 로그아웃 감지 - sessionId: {}", sessionId);
userSessionService.removeSession(sessionId);
httpSession.invalidate();
SecurityContextHolder.clearContext();
response.sendRedirect(contextPath + "/login?forceLogout=true");
return;
}
// 2. 세션 만료 체크 (DB 레코드 없거나 잔여 0)
long remaining = userSessionService.getRemainingSeconds(sessionId);
if (remaining <= 0) {
userSessionService.removeSession(sessionId);
httpSession.invalidate();
SecurityContextHolder.clearContext();
response.sendRedirect(contextPath + "/login?expired=true");
return;
}
// 3. lastAccessTime 갱신 (60초 스로틀링)
Long lastUpdate = (Long) httpSession.getAttribute(LAST_DB_UPDATE_ATTR);
long now = System.currentTimeMillis();
if (lastUpdate == null || (now - lastUpdate) > DB_UPDATE_THROTTLE_MS) {
userSessionService.updateLastAccessTime(sessionId);
httpSession.setAttribute(LAST_DB_UPDATE_ATTR, now);
}
filterChain.doFilter(request, response);
}
private boolean isExcludedPath(String path) {
for (String prefix : EXCLUDED_PREFIXES) {
if (path.startsWith(prefix)) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,29 @@
package com.eactive.apim.portal.apps.session.repository;
import com.eactive.apim.portal.apps.session.entity.UserSession;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
@Repository
public interface UserSessionRepository extends JpaRepository<UserSession, String> {
List<UserSession> findByLoginId(String loginId);
@Modifying
@Query("UPDATE UserSession s SET s.forceLogout = 'Y' WHERE s.loginId = :loginId AND s.sessionId <> :currentSessionId")
int forceLogoutOtherSessions(@Param("loginId") String loginId, @Param("currentSessionId") String currentSessionId);
@Modifying
@Query("DELETE FROM UserSession s WHERE s.lastAccessTime < :cutoffTime")
int deleteExpiredSessions(@Param("cutoffTime") LocalDateTime cutoffTime);
@Modifying
@Query("UPDATE UserSession s SET s.lastAccessTime = :now WHERE s.sessionId = :sessionId")
int updateLastAccessTime(@Param("sessionId") String sessionId, @Param("now") LocalDateTime now);
}
@@ -0,0 +1,156 @@
package com.eactive.apim.portal.apps.session.service;
import com.eactive.apim.portal.apps.session.entity.UserSession;
import com.eactive.apim.portal.apps.session.repository.UserSessionRepository;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
@Slf4j
@Service
@RequiredArgsConstructor
public class UserSessionService {
private static final String PROPERTY_GROUP = "Portal";
private static final String PROPERTY_NAME = "session.timeout.minutes";
private static final String DEFAULT_TIMEOUT_MINUTES = "15";
private final UserSessionRepository userSessionRepository;
private final PortalPropertyService portalPropertyService;
/**
* 활성 세션 존재 여부 확인 (만료되지 않고 강제 로그아웃되지 않은 세션)
*/
@Transactional(readOnly = true)
public Optional<UserSession> getActiveSession(String loginId) {
int timeoutMinutes = getSessionTimeoutMinutes();
LocalDateTime cutoff = LocalDateTime.now().minusMinutes(timeoutMinutes);
List<UserSession> sessions = userSessionRepository.findByLoginId(loginId);
return sessions.stream()
.filter(s -> "N".equals(s.getForceLogout()))
.filter(s -> s.getLastAccessTime().isAfter(cutoff))
.findFirst();
}
/**
* 새 세션 등록
*/
@Transactional
public void registerSession(String sessionId, String userId, String loginId, String ipAddress, String userAgent) {
UserSession session = UserSession.builder()
.sessionId(sessionId)
.userId(userId)
.loginId(loginId)
.loginTime(LocalDateTime.now())
.lastAccessTime(LocalDateTime.now())
.ipAddress(ipAddress)
.userAgent(truncate(userAgent, 500))
.forceLogout("N")
.build();
userSessionRepository.save(session);
log.info("세션 등록 - loginId: {}, sessionId: {}, ip: {}", loginId, sessionId, ipAddress);
}
/**
* 다른 세션 강제 로그아웃 플래그 설정 (중복 로그인 방지)
*/
@Transactional
public void forceLogoutOtherSessions(String loginId, String currentSessionId) {
int count = userSessionRepository.forceLogoutOtherSessions(loginId, currentSessionId);
if (count > 0) {
log.info("강제 로그아웃 처리 - loginId: {}, 대상 세션 수: {}", loginId, count);
}
}
/**
* 현재 세션의 강제 로그아웃 여부 확인
*/
@Transactional(readOnly = true)
public boolean isForceLoggedOut(String sessionId) {
return userSessionRepository.findById(sessionId)
.map(s -> "Y".equals(s.getForceLogout()))
.orElse(false);
}
/**
* 세션 삭제
*/
@Transactional
public void removeSession(String sessionId) {
if (userSessionRepository.existsById(sessionId)) {
userSessionRepository.deleteById(sessionId);
log.debug("세션 삭제 - sessionId: {}", sessionId);
}
}
/**
* 마지막 접근 시간 갱신 (세션 연장)
*/
@Transactional
public void updateLastAccessTime(String sessionId) {
userSessionRepository.updateLastAccessTime(sessionId, LocalDateTime.now());
}
/**
* DB(PortalProperty)에서 세션 타임아웃 값 조회 (분)
*/
public int getSessionTimeoutMinutes() {
String value = portalPropertyService.getOrCreateProperty(
PROPERTY_GROUP,
PROPERTY_NAME,
DEFAULT_TIMEOUT_MINUTES,
"세션 타임아웃 시간 (분)"
);
try {
return Integer.parseInt(value.trim());
} catch (NumberFormatException e) {
log.warn("세션 타임아웃 값 파싱 실패: {}, 기본값 {}분 사용", value, DEFAULT_TIMEOUT_MINUTES);
return Integer.parseInt(DEFAULT_TIMEOUT_MINUTES);
}
}
/**
* 세션의 잔여 시간(초) 계산. 세션 레코드가 없으면 0.
*/
@Transactional(readOnly = true)
public long getRemainingSeconds(String sessionId) {
int timeoutMinutes = getSessionTimeoutMinutes();
return userSessionRepository.findById(sessionId)
.map(s -> {
LocalDateTime expireTime = s.getLastAccessTime().plusMinutes(timeoutMinutes);
long remaining = Duration.between(LocalDateTime.now(), expireTime).getSeconds();
return Math.max(0, remaining);
})
.orElse(0L);
}
/**
* 만료 세션 정리 (5분 주기). 타임아웃의 2배 이상 지난 세션 삭제 (안전 마진).
*/
@Scheduled(fixedRate = 300000)
@Transactional
public void cleanupExpiredSessions() {
int timeoutMinutes = getSessionTimeoutMinutes();
LocalDateTime cutoff = LocalDateTime.now().minusMinutes((long) timeoutMinutes * 2);
int deleted = userSessionRepository.deleteExpiredSessions(cutoff);
if (deleted > 0) {
log.info("만료 세션 정리 - 삭제 수: {}", deleted);
}
}
private String truncate(String value, int maxLength) {
if (value == null) {
return null;
}
return value.length() > maxLength ? value.substring(0, maxLength) : value;
}
}
@@ -8,6 +8,7 @@ import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ModelAttribute;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import com.eactive.apim.portal.config.PortalProperties;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
@ControllerAdvice
public class GlobalControllerAdvice {
@@ -21,6 +22,9 @@ public class GlobalControllerAdvice {
@Autowired
private PortalProperties portalProperties;
@Autowired
private UserSessionService userSessionService;
@ModelAttribute("breadcrumb")
public List<Map> addBreadcrumbToModel(HttpServletRequest request) {
String currentPath = request.getRequestURI();
@@ -58,4 +62,12 @@ public class GlobalControllerAdvice {
}
return null;
}
/**
* 화면 세션 타이머 기준이 되는 타임아웃(분). PortalProperty(Portal/session.timeout.minutes)에서 조회.
*/
@ModelAttribute("sessionTimeoutMinutes")
public int sessionTimeoutMinutes() {
return userSessionService.getSessionTimeoutMinutes();
}
}
@@ -1,5 +1,6 @@
package com.eactive.apim.portal.config;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
import com.eactive.apim.portal.apps.user.service.PortalUserLogService;
import com.eactive.apim.portal.common.util.HttpRequestUtil;
@@ -49,6 +50,7 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
private final MessageRequestRepository messageRequestRepository;
private final UserInvitationRepository userInvitationRepository;
private final PortalOrgRepository portalOrgRepository;
private final UserSessionService userSessionService;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
@@ -112,6 +114,12 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
}
}
// 중복 로그인 방지: 기존 세션 강제 로그아웃 플래그 설정 + 현재 세션 등록
String clientIp = HttpRequestUtil.getClientIpAddress(request);
userSessionService.forceLogoutOtherSessions(normalizedUsername, sessionId);
userSessionService.registerSession(sessionId, String.valueOf(user.getId()), normalizedUsername,
clientIp, request.getHeader("User-Agent"));
// 로그인 성공 시 세션 정보 로깅
logLoginSuccess(request, session, username);
@@ -1,6 +1,7 @@
package com.eactive.apim.portal.config;
import com.eactive.apim.portal.apps.session.filter.SessionValidationFilter;
import com.navercorp.lucy.security.xss.servletfilter.XssEscapeServletFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
@@ -10,9 +11,8 @@ import org.springframework.security.config.annotation.method.configuration.Enabl
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.session.SessionRegistry;
import org.springframework.security.core.session.SessionRegistryImpl;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.session.HttpSessionEventPublisher;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@@ -33,15 +33,30 @@ public class PortalConfigSecurity {
private final PortalLogoutSuccessHandler logoutSuccessHandler;
private final SessionValidationFilter sessionValidationFilter;
@Autowired
public PortalConfigSecurity(PortalAuthenticationFailureHandler authenticationFailureHandler,
PortalAuthenticationSuccessHandler authenticationSuccessHandler,
PortalAuthenticationManager portalAuthenticationManager,
PortalLogoutSuccessHandler logoutSuccessHandler) {
PortalLogoutSuccessHandler logoutSuccessHandler,
SessionValidationFilter sessionValidationFilter) {
this.authenticationFailureHandler = authenticationFailureHandler;
this.authenticationSuccessHandler = authenticationSuccessHandler;
this.portalAuthenticationManager = portalAuthenticationManager;
this.logoutSuccessHandler = logoutSuccessHandler;
this.sessionValidationFilter = sessionValidationFilter;
}
/**
* {@link SessionValidationFilter}가 Spring Security 필터 체인에서만 동작하도록
* 서블릿 컨테이너의 자동 등록을 비활성화한다.
*/
@Bean
public FilterRegistrationBean<SessionValidationFilter> sessionValidationFilterRegistration(SessionValidationFilter filter) {
FilterRegistrationBean<SessionValidationFilter> registration = new FilterRegistrationBean<>(filter);
registration.setEnabled(false);
return registration;
}
@Bean
@@ -79,17 +94,15 @@ public class PortalConfigSecurity {
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.ignoringRequestMatchers(new AntPathRequestMatcher("/_proxy/**/*"))
.ignoringRequestMatchers(new AntPathRequestMatcher("/api/session/check-duplicate"))
)
// 중복로그인/유휴 만료는 DB 기반 SessionValidationFilter가 처리 (maximumSessions 미사용)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.maximumSessions(1) // Allow only one session per user
.maxSessionsPreventsLogin(false) // Prevent new login when session limit is reached
.expiredUrl("/login?expired") // Redirect when session expires
.sessionRegistry(sessionRegistry()) // Session registry bean to track sessions
.and()
.sessionFixation()
.changeSessionId()
);
)
.addFilterBefore(sessionValidationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
} catch (Exception e) {
@@ -97,11 +110,6 @@ public class PortalConfigSecurity {
}
}
@Bean
public SessionRegistry sessionRegistry() {
return new SessionRegistryImpl();
}
@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
return new HttpSessionEventPublisher();
@@ -1,5 +1,6 @@
package com.eactive.apim.portal.config;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
import com.eactive.apim.portal.common.util.HttpRequestUtil;
import com.eactive.apim.portal.common.util.StringRepeatUtil;
import org.slf4j.Logger;
@@ -26,12 +27,21 @@ public class PortalLogoutSuccessHandler implements LogoutSuccessHandler {
private static final Logger sessionLogger = LoggerFactory.getLogger("eapim.portal.session");
private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
private final UserSessionService userSessionService;
public PortalLogoutSuccessHandler(UserSessionService userSessionService) {
this.userSessionService = userSessionService;
}
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
HttpSession session = request.getSession(false);
if (session != null) {
// DB 세션 레코드 정리 (중복세션 오탐 방지)
userSessionService.removeSession(session.getId());
StringBuilder logMessage = new StringBuilder();
logMessage.append("\n");
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
+31
View File
@@ -5310,6 +5310,37 @@ select.form-control {
right: -20px;
}
}
.session-timer {
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
font-size: 14px;
font-weight: 600;
color: #64748B;
}
.session-timer .session-timer-icon {
font-size: 12px;
color: #64748B;
}
.session-timer .session-timer-text {
font-variant-numeric: tabular-nums;
min-width: 40px;
text-align: center;
}
.session-timer .session-timer-text.session-timer-warning {
color: #e03131;
font-weight: 700;
}
.session-timer-mobile {
margin-right: 8px;
font-size: 12px;
}
.session-timer-mobile .session-timer-text {
min-width: 34px;
}
.data-table {
width: 100%;
background: #FFFFFF;
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
@@ -0,0 +1,43 @@
@use '../abstracts/variables' as *;
// -----------------------------------------------------------------------------
// Session Timer Component
// 인증 사용자 헤더에 남은 세션 시간을 표시. 만료 임박(<=60s) 시 경고색.
// -----------------------------------------------------------------------------
.session-timer {
display: inline-flex;
align-items: center;
gap: $spacing-xs;
white-space: nowrap;
font-size: $font-size-sm;
font-weight: $font-weight-semibold;
color: $text-gray;
.session-timer-icon {
font-size: $font-size-xs;
color: $text-gray;
}
.session-timer-text {
// 카운트다운 중 폭 흔들림 방지
font-variant-numeric: tabular-nums;
min-width: 40px;
text-align: center;
&.session-timer-warning {
color: #e03131;
font-weight: $font-weight-bold;
}
}
}
// 모바일 헤더(햄버거 버튼 좌측)용 타이머
.session-timer-mobile {
margin-right: $spacing-sm;
font-size: $font-size-xs;
.session-timer-text {
min-width: 34px;
}
}
+1
View File
@@ -35,6 +35,7 @@
@use 'components/cta' as *;
@use 'components/password-popup' as *;
@use 'components/header-auth' as *;
@use 'components/session-timer' as *;
@use 'components/tables' as *;
@use 'components/accordion' as *;
@use 'components/page-title-banner' as *;
@@ -19,6 +19,14 @@
<i class="fas fa-info-circle"></i>
<span>로그아웃되었습니다.</span>
</div>
<div th:if="${param.expired}" class="login-alert alert-info">
<i class="fas fa-info-circle"></i>
<span>장시간 미사용으로 세션이 만료되어 로그아웃되었습니다. 다시 로그인해주세요.</span>
</div>
<div th:if="${param.forceLogout}" class="login-alert alert-info">
<i class="fas fa-info-circle"></i>
<span>다른 기기 또는 브라우저에서 로그인되어 현재 세션이 종료되었습니다.</span>
</div>
<!-- Login Form -->
<form id="loginForm" role="form" name="loginForm" th:action="@{/actionLogin.do}" method="post" class="login-form">
@@ -107,6 +115,36 @@
form.checkId.checked = ((form.id.value = getCookie('saveid')) !== null);
}
// 로그인 전 중복 접속 확인 → 중복 시 기존 세션 강제 로그아웃 여부 질의
function checkDuplicateAndLogin(form) {
var loginId = form.id.value;
$.ajax({
url: /*[[@{/api/session/check-duplicate}]]*/ '/api/session/check-duplicate',
type: 'POST',
data: { loginId: loginId },
success: function (data) {
if (data && data.duplicateSession) {
$('#loginLoading').removeClass('active');
var msg = '해당 계정은 이미 다른 곳(' + data.ipAddress + ')에서 접속 중입니다.<br>'
+ '접속 시각: ' + data.loginTime + '<br><br>'
+ '기존 접속을 해제하고 로그인하시겠습니까?';
customPopups.showConfirm(msg, function (confirmed) {
if (confirmed) {
$('#loginLoading').addClass('active');
form.submit();
}
});
} else {
form.submit();
}
},
error: function () {
// 중복 체크 실패 시 로그인은 그대로 진행
form.submit();
}
});
}
function fnInit() {
let form = document.getElementById('loginForm');
if (form) {
@@ -171,7 +209,7 @@
$('#loginLoading').removeClass('active');
customPopups.showAlert('[[#{login.passLengthShort}]]');
} else {
form.submit();
checkDuplicateAndLogin(form);
}
}
form.classList.add('was-validated');
@@ -64,6 +64,11 @@
<!-- Logout State (Authenticated) -->
<div class="auth-group authenticated" sec:authorize="isAuthenticated()">
<div class="header-user-info">
<span class="session-timer" id="sessionTimer" title="남은 세션 시간">
<i class="fas fa-clock session-timer-icon"></i>
<span class="session-timer-text" id="sessionRemainingTime">--:--</span>
</span>
<span class="divider"></span>
<div class="user-identity">
<img th:src="@{/img/user_icon.svg}" alt="User" class="user-icon">
<span class="user-name">[[${#authentication.principal.userName}]]님</span>
@@ -102,6 +107,10 @@
</div>
<div class="mobile-right">
<span class="session-timer session-timer-mobile" sec:authorize="isAuthenticated()" title="남은 세션 시간">
<i class="fas fa-clock session-timer-icon"></i>
<span class="session-timer-text">--:--</span>
</span>
<button class="mobile-menu-btn" id="mobileMenuToggle" aria-label="메뉴">
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
@@ -243,6 +252,148 @@
<!-- Mobile Menu & Dropdown JavaScript -->
<th:block th:fragment="headerScript">
<!-- 세션 타이머 / 유휴 자동 로그아웃 / 연장 확인 (인증 사용자만) -->
<script sec:authorize="isAuthenticated()" th:inline="javascript">
(function () {
var STATUS_URL = /*[[@{/api/session/status}]]*/ '/api/session/status';
var HEARTBEAT_URL = /*[[@{/api/session/heartbeat}]]*/ '/api/session/heartbeat';
var EXPIRED_URL = /*[[@{/login?expired=true}]]*/ '/login?expired=true';
var FORCE_LOGOUT_URL = /*[[@{/login?forceLogout=true}]]*/ '/login?forceLogout=true';
var LOGOUT_URL = /*[[@{/actionLogout.do}]]*/ '/actionLogout.do';
var timeoutMinutes = /*[[${sessionTimeoutMinutes}]]*/ 15;
var remainingSeconds = timeoutMinutes * 60;
var WARNING_SECONDS = 60; // 만료 60초 전 연장 확인 모달
var POLL_INTERVAL_MS = 30000; // 서버 잔여시간 동기화 주기
// 데스크톱·모바일 타이머 텍스트를 모두 갱신 (id 대신 클래스 사용)
var timerTexts = document.querySelectorAll('.session-timer-text');
var countdownTimer = null;
var promptShown = false; // 연장 확인 모달 중복 표시 방지
var redirecting = false;
function getCookie(name) {
var m = document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)');
return m ? decodeURIComponent(m.pop()) : '';
}
function csrfHeaders() {
var token = getCookie('XSRF-TOKEN');
return token ? { 'X-XSRF-TOKEN': token } : {};
}
function formatTime(sec) {
if (sec < 0) sec = 0;
var m = Math.floor(sec / 60);
var s = sec % 60;
return (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s;
}
function render() {
if (!timerTexts.length) return;
var label = formatTime(remainingSeconds);
var warn = remainingSeconds <= WARNING_SECONDS;
for (var i = 0; i < timerTexts.length; i++) {
timerTexts[i].textContent = label;
if (warn) {
timerTexts[i].classList.add('session-timer-warning');
} else {
timerTexts[i].classList.remove('session-timer-warning');
}
}
}
function goTo(url) {
if (redirecting) return;
redirecting = true;
if (countdownTimer) clearInterval(countdownTimer);
window.location.href = url;
}
function extendSession() {
$.ajax({
url: HEARTBEAT_URL,
type: 'POST',
headers: csrfHeaders(),
success: function (data) {
if (data && data.remainingSeconds > 0) {
remainingSeconds = data.remainingSeconds;
promptShown = false;
render();
} else {
goTo(EXPIRED_URL);
}
},
error: function () {
// 연장 실패 시 만료로 처리
goTo(EXPIRED_URL);
}
});
}
function showExtendConfirm() {
if (typeof customPopups === 'undefined' || !customPopups.showConfirm) {
return; // 팝업 미로딩 시 카운트다운만으로 만료 처리
}
var $yes = $('#customConfirmYesButton');
var $no = $('#customConfirmNoButton');
var prevYes = $yes.text();
var prevNo = $no.text();
$yes.text('연장하기');
$no.text('로그아웃');
customPopups.showConfirm('세션이 곧 만료됩니다. 로그인 상태를 연장하시겠습니까?', function (extend) {
// 버튼 라벨 원복 (다른 confirm 팝업에 영향 없도록)
$yes.text(prevYes);
$no.text(prevNo);
if (extend) {
extendSession();
} else {
goTo(LOGOUT_URL);
}
});
}
function tick() {
remainingSeconds--;
if (remainingSeconds <= 0) {
goTo(EXPIRED_URL);
return;
}
if (remainingSeconds <= WARNING_SECONDS && !promptShown) {
promptShown = true;
showExtendConfirm();
}
render();
}
function pollStatus() {
$.ajax({
url: STATUS_URL,
type: 'GET',
success: function (data) {
if (!data || !data.valid) {
goTo(FORCE_LOGOUT_URL);
return;
}
remainingSeconds = data.remainingSeconds;
if (remainingSeconds > WARNING_SECONDS) {
promptShown = false; // 다른 탭/활동으로 연장된 경우 모달 재무장
}
render();
},
error: function () {
// 폴링 실패 시 로컬 카운트다운 유지
}
});
}
// 초기화
render();
countdownTimer = setInterval(tick, 1000);
setInterval(pollStatus, POLL_INTERVAL_MS);
})();
</script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Mobile Drawer