- 세션 타임아웃 10분 고정: 서버/DB 설정 통일 및 관리 단순화
eapim-portal CI / build (push) Waiting to run
eapim-portal Test / test (push) Waiting to run

- CSRF 토큰 조회/재발급 API 추가 - 익명 세션 유지 핑 로직 구현
- OAuth2 가이드 scope 고정값 "api"로 수정
This commit is contained in:
Rinjae
2026-07-23 18:05:57 +09:00
parent c865359819
commit f221c20ece
11 changed files with 95 additions and 50 deletions
@@ -5,6 +5,7 @@ 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.security.web.csrf.CsrfToken;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -25,6 +26,8 @@ import java.util.Optional;
* <li>GET /api/session/status - 잔여 시간/유효성 폴링 (인증 필요)</li>
* <li>POST /api/session/heartbeat - 세션 연장 (lastAccessTime 갱신)</li>
* <li>POST /api/session/check-duplicate - 로그인 전 중복 세션 확인 (CSRF 예외)</li>
* <li>GET /api/session/ping - 익명 세션 keepalive (로그인/회원가입 페이지)</li>
* <li>GET /api/session/csrf - 현재 CSRF 토큰 조회 (로그인 제출 직전 안전망)</li>
* </ul>
*/
@Slf4j
@@ -109,6 +112,37 @@ public class SessionApiController {
return ResponseEntity.ok(result);
}
/**
* 익명(비로그인) 페이지용 세션 keepalive ping.
* 요청이 기존 세션에 접근하는 것만으로 컨테이너의 세션 비활성 타이머가 리셋되어
* 익명 세션(세션 저장 CSRF 토큰, 회원가입 본인인증 상태 포함)이 유지된다.
* 세션이 없으면 새로 만들지 않는다.
*/
@GetMapping("/ping")
public ResponseEntity<Void> ping(HttpServletRequest request) {
request.getSession(false);
return ResponseEntity.noContent().build();
}
/**
* 현재 CSRF 토큰 조회 (로그인 제출 직전 안전망).
* 세션 만료로 토큰이 사라진 경우 CsrfFilter가 새 토큰을 생성하고,
* 이 핸들러가 토큰 값을 읽는 시점에 새 세션에 저장된다(LazyCsrfTokenRepository).
* 회원가입 절차는 세션에 본인인증 상태를 들고 있어 토큰 재발급만으로는 복구가 안 되므로
* 로그인 페이지 안전망으로만 사용한다.
*/
@GetMapping("/csrf")
public ResponseEntity<Map<String, String>> csrfToken(HttpServletRequest request) {
CsrfToken token = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
Map<String, String> result = new HashMap<>();
if (token != null) {
result.put("headerName", token.getHeaderName());
result.put("parameterName", token.getParameterName());
result.put("token", token.getToken());
}
return ResponseEntity.ok(result);
}
/**
* IP 주소 마스킹 (3번째 옥텟을 ***로 치환)
* 예: 192.168.240.178 → 192.168.***.178
@@ -20,8 +20,9 @@ import java.util.Optional;
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";
/** 세션 타임아웃(분) 고정값. application.yml(timeout: 10m)·weblogic.xml(timeout-secs 600)과 동일하게 유지한다. */
public static final int SESSION_TIMEOUT_MINUTES = 10;
/** 세션 유지(타임아웃 무시) 기능 활성화 여부 프로퍼티 (true/false). 비운영 전용 — prod 가드는 상위(GlobalControllerAdvice)에서 적용 */
private static final String KEEPALIVE_PROPERTY_NAME = "session.keepalive.enabled";
@@ -120,21 +121,10 @@ public class UserSessionService {
}
/**
* DB(PortalProperty)에서 세션 타임아웃 값 조회 (분)
* 세션 타임아웃(분). {@value #SESSION_TIMEOUT_MINUTES}분 고정 (DB property 관리 폐지).
*/
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);
}
return SESSION_TIMEOUT_MINUTES;
}
/**
@@ -62,7 +62,7 @@ public class GlobalControllerAdvice {
}
/**
* 화면 세션 타이머 기준이 되는 타임아웃(분). PortalProperty(Portal/session.timeout.minutes)에서 조회.
* 화면 세션 타이머 기준이 되는 타임아웃(분). 10분 고정 (UserSessionService.SESSION_TIMEOUT_MINUTES).
*/
@ModelAttribute("sessionTimeoutMinutes")
public int sessionTimeoutMinutes() {
@@ -122,8 +122,8 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
userSessionService.registerSession(sessionId, String.valueOf(user.getId()), normalizedUsername,
clientIp, request.getHeader("User-Agent"));
// 물리 세션 타임아웃을 DB property(Portal/session.timeout.minutes)와 일치시킴.
// yml/weblogic.xml 기본값을 이 세션에 대해 override → 물리=논리 단일화(CSRF 수명 포함).
// 물리 세션 타임아웃 10분 고정. yml(timeout: 10m)·weblogic.xml(timeout-secs 600)과 동일 값이지만
// 컨테이너 설정(콘솔 override 등)과 무관하게 보장하기 위해 명시 적용 → 물리=논리 단일화(CSRF 수명 포함).
session.setMaxInactiveInterval(userSessionService.getSessionTimeoutMinutes() * 60);
// 로그인 성공 시 세션 정보 로깅
@@ -86,6 +86,7 @@ public class PortalConfigSecurity {
// 운영(prod/eapim/devportal)은 동일 호스트(IP:PORT)에 여러 서비스가 떠 있어
// 쿠키가 호스트 단위로 공유·과포화되면서 XSRF-TOKEN 쿠키가 누락 → 로그인 403이 발생했다.
// 기존 클라이언트(X-XSRF-TOKEN 헤더, _csrf 파라미터)와 호환되도록 헤더명을 고정한다.
// 세션에 저장되므로 CSRF 토큰 수명은 세션 타임아웃(10분)과 동일하다.
HttpSessionCsrfTokenRepository csrfTokenRepository = new HttpSessionCsrfTokenRepository();
csrfTokenRepository.setHeaderName("X-XSRF-TOKEN");
+4 -5
View File
@@ -2,11 +2,10 @@ server:
servlet:
context-path: /
session:
# 물리 세션 타임아웃은 DB PortalProperty(Portal/session.timeout.minutes)로 관리한다.
# 로그인 성공 시 PortalAuthenticationSuccessHandler 가
# session.setMaxInactiveInterval(session.timeout.minutes * 60) 으로 적용 → 물리=논리 일치.
# 익명/로그인 전 세션은 컨테이너 기본값으로 fallback (weblogic.xml <timeout-secs>1800).
# timeout: 10m
# 세션 타임아웃 10분 고정 (DB property 관리 폐지).
# WebLogic 배포 시에는 weblogic.xml <timeout-secs>600 이 동일 값을 적용한다.
# CSRF 토큰은 세션에 저장(HttpSessionCsrfTokenRepository)되므로 수명도 이 값과 동일하다.
timeout: 10m
cookie:
name: JSESSIONID_PORTAL
encoding:
@@ -115,6 +115,24 @@
form.checkId.checked = ((form.id.value = getCookie('saveid')) !== null);
}
// CSRF 토큰 재발급 안전망: keepalive ping이 끊긴 경우(절전·네트워크 단절 등)
// 세션이 만료됐어도 제출 직전 새 토큰을 받아 403(만료) 대신 정상 로그인되게 한다.
// 재발급 실패 시에는 기존 토큰으로 그대로 제출한다.
function refreshCsrfAndThen(form, next) {
$.ajax({
url: /*[[@{/api/session/csrf}]]*/ '/api/session/csrf',
type: 'GET',
success: function (data) {
if (data && data.token) {
if (form['_csrf']) { form['_csrf'].value = data.token; }
var meta = document.querySelector('meta[name="_csrf"]');
if (meta) { meta.content = data.token; }
}
},
complete: function () { next(); }
});
}
// 로그인 전 중복 접속 확인 → 중복 시 기존 세션 강제 로그아웃 여부 질의
function checkDuplicateAndLogin(form) {
var loginId = form.id.value;
@@ -157,26 +175,8 @@
$(function () {
// CSRF 토큰은 세션 기반 → 세션 만료(server.servlet.session.timeout=10m) 전에
// 메인 페이지로 이동시켜 stale 토큰 제출(403) 방지. 이동은 서버 요청이라 세션 idle도 리셋됨.
// 단, 입력 중에는 이탈 금지 → idle 타이머(활동 시 리셋, 입력값/포커스 있으면 보류).
// 만료 시간은 하드코딩하지 않고 서버 세션 타임아웃(초)을 렌더 시점에 읽어 파생 → yml 변경 시 자동 반영.
var SESSION_TIMEOUT_SEC = /*[[${#request.session.maxInactiveInterval}]]*/ 600;
var LOGIN_IDLE_LIMIT_MS = Math.max(60, SESSION_TIMEOUT_SEC - 120) * 1000; // 세션 만료 2분 전
var loginIdleTimer;
function scheduleIdleRedirect() {
clearTimeout(loginIdleTimer);
loginIdleTimer = setTimeout(function () {
var idEl = document.getElementById('id');
var pwEl = document.getElementById('password');
var busy = document.activeElement === idEl || document.activeElement === pwEl
|| (idEl && idEl.value) || (pwEl && pwEl.value);
if (busy) { scheduleIdleRedirect(); return; } // 입력 중/입력값 있음 → 이탈 보류
window.location.href = /*[[@{/}]]*/ '/';
}, LOGIN_IDLE_LIMIT_MS);
}
$('#id, #password, #checkId').on('input keydown focus click', scheduleIdleRedirect);
scheduleIdleRedirect();
// 세션 만료 대응은 head의 익명 keepalive ping(/api/session/ping)이 담당하고,
// 제출 직전 refreshCsrfAndThen()이 CSRF 토큰 재발급 안전망 역할을 한다.
var successMsg = [[${success}]];
console.log("Success message:", successMsg);
@@ -225,7 +225,9 @@
$('#loginLoading').removeClass('active');
customPopups.showAlert('[[#{login.passLengthShort}]]');
} else {
checkDuplicateAndLogin(form);
refreshCsrfAndThen(form, function () {
checkDuplicateAndLogin(form);
});
}
}
form.classList.add('was-validated');
@@ -81,7 +81,7 @@
<span class="oauth2-2legged__prereq-num">3</span>
<div class="oauth2-2legged__prereq-body">
<h3 class="oauth2-2legged__prereq-title">Scope 확인</h3>
<p>호출하려는 API에 필요한 scope</p>
<p>scope 는 고정값 "api" 사용</p>
<p>권한이 부여됐는지 확인합니다.</p>
</div>
</article>
@@ -116,12 +116,12 @@
</g>
<text x="560" y="102" text-anchor="middle" font-size="12" font-weight="700" fill="#0049b4">① POST /dj/oauth/token</text>
<text x="560" y="118" text-anchor="middle" font-size="11" font-weight="500" fill="#64748B">grant_type=client_credentials, client_id, client_secret, scope</text>
<text x="560" y="118" text-anchor="middle" font-size="11" font-weight="500" fill="#64748B">grant_type=client_credentials, client_id, client_secret, scope=api</text>
<line x1="240" y1="128" x2="880" y2="128" stroke="#0049b4" stroke-width="2" marker-end="url(#o2leg-arrow-primary)"/>
<text x="560" y="160" text-anchor="middle" font-size="12" font-weight="700" fill="#64748B">② access_token 발급</text>
<line x1="880" y1="170" x2="240" y2="170" stroke="#64748B" stroke-width="2" marker-end="url(#o2leg-arrow-gray)"/>
<text x="560" y="188" text-anchor="middle" font-size="11" font-weight="500" fill="#64748B">{ access_token, token_type:"bearer", expires_in:86400, scope, jti }</text>
<text x="560" y="188" text-anchor="middle" font-size="11" font-weight="500" fill="#64748B">{ access_token, token_type:"bearer", expires_in:86400, scope:"api", jti }</text>
<text x="560" y="216" text-anchor="middle" font-size="12" font-weight="700" fill="#0049b4">③ GET /api/v1/... · X-AUTH-TOKEN: Bearer &lt;access_token&gt;</text>
<line x1="240" y1="226" x2="880" y2="226" stroke="#0049b4" stroke-width="2" marker-end="url(#o2leg-arrow-primary)"/>
@@ -1,5 +1,6 @@
<!doctype html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head th:fragment="headFragment">
<title th:text="#{title.html}">개발자 포털</title>
@@ -27,6 +28,23 @@
<!-- CSRF 토큰 (세션 기반). 정적 JS/AJAX에서 토큰·헤더명을 읽어 사용한다. -->
<meta name="_csrf" th:content="${_csrf != null ? _csrf.token : ''}"/>
<meta name="_csrf_header" th:content="${_csrf != null ? _csrf.headerName : 'X-XSRF-TOKEN'}"/>
<!-- 익명(비로그인) 세션 keepalive: 로그인/회원가입 등에서 페이지에 머무는 동안 주기적 ping으로
세션 비활성 타이머를 리셋해 세션 저장 CSRF 토큰·회원가입 본인인증 상태의 만료(10분)를 방지한다.
탭을 닫으면 ping이 멈춰 정상 만료. 인증 사용자는 헤더의 세션 타이머/heartbeat가 대신 처리한다. -->
<script sec:authorize="isAnonymous()" th:inline="javascript">
(function () {
var PING_URL = /*[[@{/api/session/ping}]]*/ '/api/session/ping';
var PING_INTERVAL_MS = 4 * 60 * 1000; // 세션 타임아웃(10분)의 절반 이하
setInterval(function () {
try {
var xhr = new XMLHttpRequest();
xhr.open('GET', PING_URL, true);
xhr.send();
} catch (ignore) { /* keepalive 실패는 화면 동작에 영향 없음 */ }
}, PING_INTERVAL_MS);
})();
</script>
<meta content="max-age=0, public" http-equiv="Cache-Control"/>
<meta content="index, follow" name="robots"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no">
@@ -272,7 +272,7 @@
var FORCE_LOGOUT_URL = /*[[@{/login?forceLogout=true}]]*/ '/login?forceLogout=true';
var LOGOUT_URL = /*[[@{/actionLogout.do}]]*/ '/actionLogout.do';
var timeoutMinutes = /*[[${sessionTimeoutMinutes}]]*/ 15;
var timeoutMinutes = /*[[${sessionTimeoutMinutes}]]*/ 10;
var remainingSeconds = timeoutMinutes * 60;
var WARNING_SECONDS = 60; // 만료 60초 전 연장 확인 모달
var POLL_INTERVAL_MS = 30000; // 서버 잔여시간 동기화 주기
+2 -1
View File
@@ -14,7 +14,8 @@
</container-descriptor>
<session-descriptor>
<timeout-secs>1800</timeout-secs>
<!-- 세션 타임아웃 10분 고정 (application.yml server.servlet.session.timeout=10m 과 동일 값 유지) -->
<timeout-secs>600</timeout-secs>
<cookie-name>JSESSIONID_PORTAL</cookie-name>
<persistent-store-type>replicated_if_clustered</persistent-store-type>
</session-descriptor>