Merge branch 'feature/session-timeout'
# Conflicts: # src/main/resources/static/css/main.css.map
This commit is contained in:
@@ -0,0 +1,36 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- 세션 타이머 / 유휴 자동 로그아웃 / 중복로그인 처리용 사용자 세션 추적 테이블
|
||||||
|
-- 대상: EMS 스키마 소유자(EMSAPP 등)로 접속하여 실행
|
||||||
|
-- 엔티티: com.eactive.apim.portal.apps.session.entity.UserSession
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE PTL_USER_SESSION
|
||||||
|
(
|
||||||
|
SESSION_ID VARCHAR2(128) NOT NULL,
|
||||||
|
USER_ID VARCHAR2(36) NOT NULL,
|
||||||
|
LOGIN_ID VARCHAR2(200) NOT NULL,
|
||||||
|
LOGIN_TIME TIMESTAMP(6) NOT NULL,
|
||||||
|
LAST_ACCESS_TIME TIMESTAMP(6) NOT NULL,
|
||||||
|
IP_ADDRESS VARCHAR2(45),
|
||||||
|
USER_AGENT VARCHAR2(500),
|
||||||
|
FORCE_LOGOUT CHAR(1) DEFAULT 'N' NOT NULL,
|
||||||
|
CONSTRAINT PK_PTL_USER_SESSION PRIMARY KEY (SESSION_ID)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 중복로그인 강제 로그아웃 / 활성 세션 조회는 LOGIN_ID 기준
|
||||||
|
CREATE INDEX IX_PTL_USER_SESSION_LOGIN ON PTL_USER_SESSION (LOGIN_ID);
|
||||||
|
|
||||||
|
COMMENT ON TABLE PTL_USER_SESSION IS '포털 사용자 세션 추적 (타이머/유휴 로그아웃/중복로그인)';
|
||||||
|
COMMENT ON COLUMN PTL_USER_SESSION.SESSION_ID IS 'HTTP 세션 ID (PK)';
|
||||||
|
COMMENT ON COLUMN PTL_USER_SESSION.USER_ID IS '포털 사용자 ID';
|
||||||
|
COMMENT ON COLUMN PTL_USER_SESSION.LOGIN_ID IS '로그인 ID (이메일, 소문자)';
|
||||||
|
COMMENT ON COLUMN PTL_USER_SESSION.LOGIN_TIME IS '로그인 시각';
|
||||||
|
COMMENT ON COLUMN PTL_USER_SESSION.LAST_ACCESS_TIME IS '마지막 접근 시각 (만료 판단 기준)';
|
||||||
|
COMMENT ON COLUMN PTL_USER_SESSION.IP_ADDRESS IS '접속 IP';
|
||||||
|
COMMENT ON COLUMN PTL_USER_SESSION.USER_AGENT IS 'User-Agent';
|
||||||
|
COMMENT ON COLUMN PTL_USER_SESSION.FORCE_LOGOUT IS '강제 로그아웃 플래그 (Y/N)';
|
||||||
|
|
||||||
|
-- (선택) 세션 타임아웃(분) 설정값을 미리 지정. 미삽입 시 최초 접근 때 기본값 15로 자동 생성됨.
|
||||||
|
-- INSERT INTO PTL_PROPERTY (PROPERTY_GROUP_NAME, PROPERTY_NAME, PROPERTY_VALUE, PROPERTY_DESC)
|
||||||
|
-- VALUES ('Portal', 'session.timeout.minutes', '15', '세션 타임아웃 시간 (분)');
|
||||||
|
-- COMMIT;
|
||||||
+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 org.springframework.web.bind.annotation.ModelAttribute;
|
||||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||||
import com.eactive.apim.portal.config.PortalProperties;
|
import com.eactive.apim.portal.config.PortalProperties;
|
||||||
|
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||||
|
|
||||||
@ControllerAdvice
|
@ControllerAdvice
|
||||||
public class GlobalControllerAdvice {
|
public class GlobalControllerAdvice {
|
||||||
@@ -21,6 +22,9 @@ public class GlobalControllerAdvice {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private PortalProperties portalProperties;
|
private PortalProperties portalProperties;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserSessionService userSessionService;
|
||||||
|
|
||||||
@ModelAttribute("breadcrumb")
|
@ModelAttribute("breadcrumb")
|
||||||
public List<Map> addBreadcrumbToModel(HttpServletRequest request) {
|
public List<Map> addBreadcrumbToModel(HttpServletRequest request) {
|
||||||
String currentPath = request.getRequestURI();
|
String currentPath = request.getRequestURI();
|
||||||
@@ -58,4 +62,12 @@ public class GlobalControllerAdvice {
|
|||||||
}
|
}
|
||||||
return null;
|
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;
|
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.repository.PortalOrgRepository;
|
||||||
import com.eactive.apim.portal.apps.user.service.PortalUserLogService;
|
import com.eactive.apim.portal.apps.user.service.PortalUserLogService;
|
||||||
import com.eactive.apim.portal.common.util.HttpRequestUtil;
|
import com.eactive.apim.portal.common.util.HttpRequestUtil;
|
||||||
@@ -49,6 +50,7 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
|
|||||||
private final MessageRequestRepository messageRequestRepository;
|
private final MessageRequestRepository messageRequestRepository;
|
||||||
private final UserInvitationRepository userInvitationRepository;
|
private final UserInvitationRepository userInvitationRepository;
|
||||||
private final PortalOrgRepository portalOrgRepository;
|
private final PortalOrgRepository portalOrgRepository;
|
||||||
|
private final UserSessionService userSessionService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
|
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);
|
logLoginSuccess(request, session, username);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.eactive.apim.portal.config;
|
package com.eactive.apim.portal.config;
|
||||||
|
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.apps.session.filter.SessionValidationFilter;
|
||||||
import com.navercorp.lucy.security.xss.servletfilter.XssEscapeServletFilter;
|
import com.navercorp.lucy.security.xss.servletfilter.XssEscapeServletFilter;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
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.builders.HttpSecurity;
|
||||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
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.SecurityFilterChain;
|
||||||
|
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||||
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
|
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
|
||||||
import org.springframework.security.web.session.HttpSessionEventPublisher;
|
import org.springframework.security.web.session.HttpSessionEventPublisher;
|
||||||
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
|
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
|
||||||
@@ -33,15 +33,30 @@ public class PortalConfigSecurity {
|
|||||||
|
|
||||||
private final PortalLogoutSuccessHandler logoutSuccessHandler;
|
private final PortalLogoutSuccessHandler logoutSuccessHandler;
|
||||||
|
|
||||||
|
private final SessionValidationFilter sessionValidationFilter;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public PortalConfigSecurity(PortalAuthenticationFailureHandler authenticationFailureHandler,
|
public PortalConfigSecurity(PortalAuthenticationFailureHandler authenticationFailureHandler,
|
||||||
PortalAuthenticationSuccessHandler authenticationSuccessHandler,
|
PortalAuthenticationSuccessHandler authenticationSuccessHandler,
|
||||||
PortalAuthenticationManager portalAuthenticationManager,
|
PortalAuthenticationManager portalAuthenticationManager,
|
||||||
PortalLogoutSuccessHandler logoutSuccessHandler) {
|
PortalLogoutSuccessHandler logoutSuccessHandler,
|
||||||
|
SessionValidationFilter sessionValidationFilter) {
|
||||||
this.authenticationFailureHandler = authenticationFailureHandler;
|
this.authenticationFailureHandler = authenticationFailureHandler;
|
||||||
this.authenticationSuccessHandler = authenticationSuccessHandler;
|
this.authenticationSuccessHandler = authenticationSuccessHandler;
|
||||||
this.portalAuthenticationManager = portalAuthenticationManager;
|
this.portalAuthenticationManager = portalAuthenticationManager;
|
||||||
this.logoutSuccessHandler = logoutSuccessHandler;
|
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
|
@Bean
|
||||||
@@ -79,17 +94,15 @@ public class PortalConfigSecurity {
|
|||||||
.csrf(csrf -> csrf
|
.csrf(csrf -> csrf
|
||||||
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
|
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
|
||||||
.ignoringRequestMatchers(new AntPathRequestMatcher("/_proxy/**/*"))
|
.ignoringRequestMatchers(new AntPathRequestMatcher("/_proxy/**/*"))
|
||||||
|
.ignoringRequestMatchers(new AntPathRequestMatcher("/api/session/check-duplicate"))
|
||||||
)
|
)
|
||||||
|
// 중복로그인/유휴 만료는 DB 기반 SessionValidationFilter가 처리 (maximumSessions 미사용)
|
||||||
.sessionManagement(session -> session
|
.sessionManagement(session -> session
|
||||||
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
|
.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()
|
.sessionFixation()
|
||||||
.changeSessionId()
|
.changeSessionId()
|
||||||
);
|
)
|
||||||
|
.addFilterBefore(sessionValidationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||||
|
|
||||||
return http.build();
|
return http.build();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -97,11 +110,6 @@ public class PortalConfigSecurity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
|
||||||
public SessionRegistry sessionRegistry() {
|
|
||||||
return new SessionRegistryImpl();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public HttpSessionEventPublisher httpSessionEventPublisher() {
|
public HttpSessionEventPublisher httpSessionEventPublisher() {
|
||||||
return new HttpSessionEventPublisher();
|
return new HttpSessionEventPublisher();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.eactive.apim.portal.config;
|
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.HttpRequestUtil;
|
||||||
import com.eactive.apim.portal.common.util.StringRepeatUtil;
|
import com.eactive.apim.portal.common.util.StringRepeatUtil;
|
||||||
import org.slf4j.Logger;
|
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 Logger sessionLogger = LoggerFactory.getLogger("eapim.portal.session");
|
||||||
private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
|
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
|
@Override
|
||||||
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response,
|
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response,
|
||||||
Authentication authentication) throws IOException, ServletException {
|
Authentication authentication) throws IOException, ServletException {
|
||||||
HttpSession session = request.getSession(false);
|
HttpSession session = request.getSession(false);
|
||||||
|
|
||||||
if (session != null) {
|
if (session != null) {
|
||||||
|
// DB 세션 레코드 정리 (중복세션 오탐 방지)
|
||||||
|
userSessionService.removeSession(session.getId());
|
||||||
|
|
||||||
StringBuilder logMessage = new StringBuilder();
|
StringBuilder logMessage = new StringBuilder();
|
||||||
logMessage.append("\n");
|
logMessage.append("\n");
|
||||||
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
||||||
|
|||||||
@@ -5310,6 +5310,37 @@ select.form-control {
|
|||||||
right: -20px;
|
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 {
|
.data-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: #FFFFFF;
|
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,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@
|
|||||||
@use 'components/cta' as *;
|
@use 'components/cta' as *;
|
||||||
@use 'components/password-popup' as *;
|
@use 'components/password-popup' as *;
|
||||||
@use 'components/header-auth' as *;
|
@use 'components/header-auth' as *;
|
||||||
|
@use 'components/session-timer' as *;
|
||||||
@use 'components/tables' as *;
|
@use 'components/tables' as *;
|
||||||
@use 'components/accordion' as *;
|
@use 'components/accordion' as *;
|
||||||
@use 'components/page-title-banner' as *;
|
@use 'components/page-title-banner' as *;
|
||||||
|
|||||||
@@ -19,6 +19,14 @@
|
|||||||
<i class="fas fa-info-circle"></i>
|
<i class="fas fa-info-circle"></i>
|
||||||
<span>로그아웃되었습니다.</span>
|
<span>로그아웃되었습니다.</span>
|
||||||
</div>
|
</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 -->
|
<!-- Login Form -->
|
||||||
<form id="loginForm" role="form" name="loginForm" th:action="@{/actionLogin.do}" method="post" class="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);
|
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() {
|
function fnInit() {
|
||||||
let form = document.getElementById('loginForm');
|
let form = document.getElementById('loginForm');
|
||||||
if (form) {
|
if (form) {
|
||||||
@@ -171,7 +209,7 @@
|
|||||||
$('#loginLoading').removeClass('active');
|
$('#loginLoading').removeClass('active');
|
||||||
customPopups.showAlert('[[#{login.passLengthShort}]]');
|
customPopups.showAlert('[[#{login.passLengthShort}]]');
|
||||||
} else {
|
} else {
|
||||||
form.submit();
|
checkDuplicateAndLogin(form);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
form.classList.add('was-validated');
|
form.classList.add('was-validated');
|
||||||
|
|||||||
@@ -64,6 +64,11 @@
|
|||||||
<!-- Logout State (Authenticated) -->
|
<!-- Logout State (Authenticated) -->
|
||||||
<div class="auth-group authenticated" sec:authorize="isAuthenticated()">
|
<div class="auth-group authenticated" sec:authorize="isAuthenticated()">
|
||||||
<div class="header-user-info">
|
<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">
|
<div class="user-identity">
|
||||||
<img th:src="@{/img/user_icon.svg}" alt="User" class="user-icon">
|
<img th:src="@{/img/user_icon.svg}" alt="User" class="user-icon">
|
||||||
<span class="user-name">[[${#authentication.principal.userName}]]님</span>
|
<span class="user-name">[[${#authentication.principal.userName}]]님</span>
|
||||||
@@ -102,6 +107,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mobile-right">
|
<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="메뉴">
|
<button class="mobile-menu-btn" id="mobileMenuToggle" aria-label="메뉴">
|
||||||
<span class="hamburger-line"></span>
|
<span class="hamburger-line"></span>
|
||||||
<span class="hamburger-line"></span>
|
<span class="hamburger-line"></span>
|
||||||
@@ -243,6 +252,148 @@
|
|||||||
|
|
||||||
<!-- Mobile Menu & Dropdown JavaScript -->
|
<!-- Mobile Menu & Dropdown JavaScript -->
|
||||||
<th:block th:fragment="headerScript">
|
<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>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
// Mobile Drawer
|
// Mobile Drawer
|
||||||
|
|||||||
Reference in New Issue
Block a user