diff --git a/src/main/java/com/eactive/apim/portal/apps/session/controller/SessionApiController.java b/src/main/java/com/eactive/apim/portal/apps/session/controller/SessionApiController.java index 60dc7ce..c83b133 100644 --- a/src/main/java/com/eactive/apim/portal/apps/session/controller/SessionApiController.java +++ b/src/main/java/com/eactive/apim/portal/apps/session/controller/SessionApiController.java @@ -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; *
  • GET /api/session/status - 잔여 시간/유효성 폴링 (인증 필요)
  • *
  • POST /api/session/heartbeat - 세션 연장 (lastAccessTime 갱신)
  • *
  • POST /api/session/check-duplicate - 로그인 전 중복 세션 확인 (CSRF 예외)
  • + *
  • GET /api/session/ping - 익명 세션 keepalive (로그인/회원가입 페이지)
  • + *
  • GET /api/session/csrf - 현재 CSRF 토큰 조회 (로그인 제출 직전 안전망)
  • * */ @Slf4j @@ -109,6 +112,37 @@ public class SessionApiController { return ResponseEntity.ok(result); } + /** + * 익명(비로그인) 페이지용 세션 keepalive ping. + * 요청이 기존 세션에 접근하는 것만으로 컨테이너의 세션 비활성 타이머가 리셋되어 + * 익명 세션(세션 저장 CSRF 토큰, 회원가입 본인인증 상태 포함)이 유지된다. + * 세션이 없으면 새로 만들지 않는다. + */ + @GetMapping("/ping") + public ResponseEntity ping(HttpServletRequest request) { + request.getSession(false); + return ResponseEntity.noContent().build(); + } + + /** + * 현재 CSRF 토큰 조회 (로그인 제출 직전 안전망). + * 세션 만료로 토큰이 사라진 경우 CsrfFilter가 새 토큰을 생성하고, + * 이 핸들러가 토큰 값을 읽는 시점에 새 세션에 저장된다(LazyCsrfTokenRepository). + * 회원가입 절차는 세션에 본인인증 상태를 들고 있어 토큰 재발급만으로는 복구가 안 되므로 + * 로그인 페이지 안전망으로만 사용한다. + */ + @GetMapping("/csrf") + public ResponseEntity> csrfToken(HttpServletRequest request) { + CsrfToken token = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); + Map 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 diff --git a/src/main/java/com/eactive/apim/portal/apps/session/service/UserSessionService.java b/src/main/java/com/eactive/apim/portal/apps/session/service/UserSessionService.java index d8fb0c0..980c66f 100644 --- a/src/main/java/com/eactive/apim/portal/apps/session/service/UserSessionService.java +++ b/src/main/java/com/eactive/apim/portal/apps/session/service/UserSessionService.java @@ -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; } /** diff --git a/src/main/java/com/eactive/apim/portal/common/breadcrumb/GlobalControllerAdvice.java b/src/main/java/com/eactive/apim/portal/common/breadcrumb/GlobalControllerAdvice.java index b883699..6f9ab52 100644 --- a/src/main/java/com/eactive/apim/portal/common/breadcrumb/GlobalControllerAdvice.java +++ b/src/main/java/com/eactive/apim/portal/common/breadcrumb/GlobalControllerAdvice.java @@ -62,7 +62,7 @@ public class GlobalControllerAdvice { } /** - * 화면 세션 타이머 기준이 되는 타임아웃(분). PortalProperty(Portal/session.timeout.minutes)에서 조회. + * 화면 세션 타이머 기준이 되는 타임아웃(분). 10분 고정 (UserSessionService.SESSION_TIMEOUT_MINUTES). */ @ModelAttribute("sessionTimeoutMinutes") public int sessionTimeoutMinutes() { diff --git a/src/main/java/com/eactive/apim/portal/config/PortalAuthenticationSuccessHandler.java b/src/main/java/com/eactive/apim/portal/config/PortalAuthenticationSuccessHandler.java index 83a4910..1bbbcee 100644 --- a/src/main/java/com/eactive/apim/portal/config/PortalAuthenticationSuccessHandler.java +++ b/src/main/java/com/eactive/apim/portal/config/PortalAuthenticationSuccessHandler.java @@ -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); // 로그인 성공 시 세션 정보 로깅 diff --git a/src/main/java/com/eactive/apim/portal/config/PortalConfigSecurity.java b/src/main/java/com/eactive/apim/portal/config/PortalConfigSecurity.java index 8011c89..2dcb3b2 100644 --- a/src/main/java/com/eactive/apim/portal/config/PortalConfigSecurity.java +++ b/src/main/java/com/eactive/apim/portal/config/PortalConfigSecurity.java @@ -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"); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7c3608c..4efcaa8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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 1800). - # timeout: 10m + # 세션 타임아웃 10분 고정 (DB property 관리 폐지). + # WebLogic 배포 시에는 weblogic.xml 600 이 동일 값을 적용한다. + # CSRF 토큰은 세션에 저장(HttpSessionCsrfTokenRepository)되므로 수명도 이 값과 동일하다. + timeout: 10m cookie: name: JSESSIONID_PORTAL encoding: diff --git a/src/main/resources/templates/views/apps/login/login.html b/src/main/resources/templates/views/apps/login/login.html index a5dcc96..9b82315 100644 --- a/src/main/resources/templates/views/apps/login/login.html +++ b/src/main/resources/templates/views/apps/login/login.html @@ -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'); diff --git a/src/main/resources/templates/views/apps/service/oauth2-guide.html b/src/main/resources/templates/views/apps/service/oauth2-guide.html index 0a96db3..ad127ba 100644 --- a/src/main/resources/templates/views/apps/service/oauth2-guide.html +++ b/src/main/resources/templates/views/apps/service/oauth2-guide.html @@ -81,7 +81,7 @@ 3

    Scope 확인

    -

    호출하려는 API에 필요한 scope

    +

    scope 는 고정값 "api" 사용

    권한이 부여됐는지 확인합니다.

    @@ -116,12 +116,12 @@ ① POST /dj/oauth/token - grant_type=client_credentials, client_id, client_secret, scope + grant_type=client_credentials, client_id, client_secret, scope=api ② access_token 발급 - { access_token, token_type:"bearer", expires_in:86400, scope, jti } + { access_token, token_type:"bearer", expires_in:86400, scope:"api", jti } ③ GET /api/v1/... · X-AUTH-TOKEN: Bearer <access_token> diff --git a/src/main/resources/templates/views/fragment/djbank/head.html b/src/main/resources/templates/views/fragment/djbank/head.html index 499ee69..4ef5f79 100644 --- a/src/main/resources/templates/views/fragment/djbank/head.html +++ b/src/main/resources/templates/views/fragment/djbank/head.html @@ -1,5 +1,6 @@ - + 개발자 포털 @@ -27,6 +28,23 @@ + + + diff --git a/src/main/resources/templates/views/fragment/djbank/header_container.html b/src/main/resources/templates/views/fragment/djbank/header_container.html index c8972fd..903150c 100644 --- a/src/main/resources/templates/views/fragment/djbank/header_container.html +++ b/src/main/resources/templates/views/fragment/djbank/header_container.html @@ -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; // 서버 잔여시간 동기화 주기 diff --git a/src/main/resources/weblogic.xml b/src/main/resources/weblogic.xml index a1fa47c..3b54024 100644 --- a/src/main/resources/weblogic.xml +++ b/src/main/resources/weblogic.xml @@ -14,7 +14,8 @@ - 1800 + + 600 JSESSIONID_PORTAL replicated_if_clustered