- 세션 타이머·유휴 자동 로그아웃 기능 추가
- 만료 직전 연장 확인 모달, PortalProperty로 시간 설정 - PTL_USER_SESSION 기반 세션 검증 필터/API 도입 - 로그인 시 중복 세션 강제 로그아웃 처리 - 세션 테이블 DDL 및 로그인 안내 메시지 추가 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+130
@@ -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";
|
||||
}
|
||||
+124
@@ -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;
|
||||
}
|
||||
}
|
||||
+29
@@ -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");
|
||||
|
||||
@@ -5310,6 +5310,29 @@ 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;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
background: #FFFFFF;
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
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,33 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -242,6 +247,143 @@
|
||||
|
||||
<!-- 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; // 서버 잔여시간 동기화 주기
|
||||
|
||||
var timerText = document.getElementById('sessionRemainingTime');
|
||||
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 (!timerText) return;
|
||||
timerText.textContent = formatTime(remainingSeconds);
|
||||
if (remainingSeconds <= WARNING_SECONDS) {
|
||||
timerText.classList.add('session-timer-warning');
|
||||
} else {
|
||||
timerText.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
|
||||
|
||||
Reference in New Issue
Block a user