Merge branch 'master' into design
# Conflicts: # src/main/resources/templates/views/apps/service/oauth2-guide.html
This commit is contained in:
+34
@@ -5,6 +5,7 @@ import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.http.ResponseEntity;
|
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.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
@@ -25,6 +26,8 @@ import java.util.Optional;
|
|||||||
* <li>GET /api/session/status - 잔여 시간/유효성 폴링 (인증 필요)</li>
|
* <li>GET /api/session/status - 잔여 시간/유효성 폴링 (인증 필요)</li>
|
||||||
* <li>POST /api/session/heartbeat - 세션 연장 (lastAccessTime 갱신)</li>
|
* <li>POST /api/session/heartbeat - 세션 연장 (lastAccessTime 갱신)</li>
|
||||||
* <li>POST /api/session/check-duplicate - 로그인 전 중복 세션 확인 (CSRF 예외)</li>
|
* <li>POST /api/session/check-duplicate - 로그인 전 중복 세션 확인 (CSRF 예외)</li>
|
||||||
|
* <li>GET /api/session/ping - 익명 세션 keepalive (로그인/회원가입 페이지)</li>
|
||||||
|
* <li>GET /api/session/csrf - 현재 CSRF 토큰 조회 (로그인 제출 직전 안전망)</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -109,6 +112,37 @@ public class SessionApiController {
|
|||||||
return ResponseEntity.ok(result);
|
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번째 옥텟을 ***로 치환)
|
* IP 주소 마스킹 (3번째 옥텟을 ***로 치환)
|
||||||
* 예: 192.168.240.178 → 192.168.***.178
|
* 예: 192.168.240.178 → 192.168.***.178
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ import java.util.Optional;
|
|||||||
public class UserSessionService {
|
public class UserSessionService {
|
||||||
|
|
||||||
private static final String PROPERTY_GROUP = "Portal";
|
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)에서 적용 */
|
/** 세션 유지(타임아웃 무시) 기능 활성화 여부 프로퍼티 (true/false). 비운영 전용 — prod 가드는 상위(GlobalControllerAdvice)에서 적용 */
|
||||||
private static final String KEEPALIVE_PROPERTY_NAME = "session.keepalive.enabled";
|
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() {
|
public int getSessionTimeoutMinutes() {
|
||||||
String value = portalPropertyService.getOrCreateProperty(
|
return SESSION_TIMEOUT_MINUTES;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ public class GlobalControllerAdvice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 화면 세션 타이머 기준이 되는 타임아웃(분). PortalProperty(Portal/session.timeout.minutes)에서 조회.
|
* 화면 세션 타이머 기준이 되는 타임아웃(분). 10분 고정 (UserSessionService.SESSION_TIMEOUT_MINUTES).
|
||||||
*/
|
*/
|
||||||
@ModelAttribute("sessionTimeoutMinutes")
|
@ModelAttribute("sessionTimeoutMinutes")
|
||||||
public int sessionTimeoutMinutes() {
|
public int sessionTimeoutMinutes() {
|
||||||
|
|||||||
+2
-2
@@ -122,8 +122,8 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
|
|||||||
userSessionService.registerSession(sessionId, String.valueOf(user.getId()), normalizedUsername,
|
userSessionService.registerSession(sessionId, String.valueOf(user.getId()), normalizedUsername,
|
||||||
clientIp, request.getHeader("User-Agent"));
|
clientIp, request.getHeader("User-Agent"));
|
||||||
|
|
||||||
// 물리 세션 타임아웃을 DB property(Portal/session.timeout.minutes)와 일치시킴.
|
// 물리 세션 타임아웃 10분 고정. yml(timeout: 10m)·weblogic.xml(timeout-secs 600)과 동일 값이지만
|
||||||
// yml/weblogic.xml 기본값을 이 세션에 대해 override → 물리=논리 단일화(CSRF 수명 포함).
|
// 컨테이너 설정(콘솔 override 등)과 무관하게 보장하기 위해 명시 적용 → 물리=논리 단일화(CSRF 수명 포함).
|
||||||
session.setMaxInactiveInterval(userSessionService.getSessionTimeoutMinutes() * 60);
|
session.setMaxInactiveInterval(userSessionService.getSessionTimeoutMinutes() * 60);
|
||||||
|
|
||||||
// 로그인 성공 시 세션 정보 로깅
|
// 로그인 성공 시 세션 정보 로깅
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ public class PortalConfigSecurity {
|
|||||||
// 운영(prod/eapim/devportal)은 동일 호스트(IP:PORT)에 여러 서비스가 떠 있어
|
// 운영(prod/eapim/devportal)은 동일 호스트(IP:PORT)에 여러 서비스가 떠 있어
|
||||||
// 쿠키가 호스트 단위로 공유·과포화되면서 XSRF-TOKEN 쿠키가 누락 → 로그인 403이 발생했다.
|
// 쿠키가 호스트 단위로 공유·과포화되면서 XSRF-TOKEN 쿠키가 누락 → 로그인 403이 발생했다.
|
||||||
// 기존 클라이언트(X-XSRF-TOKEN 헤더, _csrf 파라미터)와 호환되도록 헤더명을 고정한다.
|
// 기존 클라이언트(X-XSRF-TOKEN 헤더, _csrf 파라미터)와 호환되도록 헤더명을 고정한다.
|
||||||
|
// 세션에 저장되므로 CSRF 토큰 수명은 세션 타임아웃(10분)과 동일하다.
|
||||||
HttpSessionCsrfTokenRepository csrfTokenRepository = new HttpSessionCsrfTokenRepository();
|
HttpSessionCsrfTokenRepository csrfTokenRepository = new HttpSessionCsrfTokenRepository();
|
||||||
csrfTokenRepository.setHeaderName("X-XSRF-TOKEN");
|
csrfTokenRepository.setHeaderName("X-XSRF-TOKEN");
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,10 @@ server:
|
|||||||
servlet:
|
servlet:
|
||||||
context-path: /
|
context-path: /
|
||||||
session:
|
session:
|
||||||
# 물리 세션 타임아웃은 DB PortalProperty(Portal/session.timeout.minutes)로 관리한다.
|
# 세션 타임아웃 10분 고정 (DB property 관리 폐지).
|
||||||
# 로그인 성공 시 PortalAuthenticationSuccessHandler 가
|
# WebLogic 배포 시에는 weblogic.xml <timeout-secs>600 이 동일 값을 적용한다.
|
||||||
# session.setMaxInactiveInterval(session.timeout.minutes * 60) 으로 적용 → 물리=논리 일치.
|
# CSRF 토큰은 세션에 저장(HttpSessionCsrfTokenRepository)되므로 수명도 이 값과 동일하다.
|
||||||
# 익명/로그인 전 세션은 컨테이너 기본값으로 fallback (weblogic.xml <timeout-secs>1800).
|
timeout: 10m
|
||||||
# timeout: 10m
|
|
||||||
cookie:
|
cookie:
|
||||||
name: JSESSIONID_PORTAL
|
name: JSESSIONID_PORTAL
|
||||||
encoding:
|
encoding:
|
||||||
|
|||||||
@@ -115,6 +115,24 @@
|
|||||||
form.checkId.checked = ((form.id.value = getCookie('saveid')) !== null);
|
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) {
|
function checkDuplicateAndLogin(form) {
|
||||||
var loginId = form.id.value;
|
var loginId = form.id.value;
|
||||||
@@ -157,26 +175,8 @@
|
|||||||
|
|
||||||
$(function () {
|
$(function () {
|
||||||
|
|
||||||
// CSRF 토큰은 세션 기반 → 세션 만료(server.servlet.session.timeout=10m) 전에
|
// 세션 만료 대응은 head의 익명 keepalive ping(/api/session/ping)이 담당하고,
|
||||||
// 메인 페이지로 이동시켜 stale 토큰 제출(403) 방지. 이동은 서버 요청이라 세션 idle도 리셋됨.
|
// 제출 직전 refreshCsrfAndThen()이 CSRF 토큰 재발급 안전망 역할을 한다.
|
||||||
// 단, 입력 중에는 이탈 금지 → 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();
|
|
||||||
|
|
||||||
var successMsg = [[${success}]];
|
var successMsg = [[${success}]];
|
||||||
console.log("Success message:", successMsg);
|
console.log("Success message:", successMsg);
|
||||||
@@ -225,7 +225,9 @@
|
|||||||
$('#loginLoading').removeClass('active');
|
$('#loginLoading').removeClass('active');
|
||||||
customPopups.showAlert('[[#{login.passLengthShort}]]');
|
customPopups.showAlert('[[#{login.passLengthShort}]]');
|
||||||
} else {
|
} else {
|
||||||
checkDuplicateAndLogin(form);
|
refreshCsrfAndThen(form, function () {
|
||||||
|
checkDuplicateAndLogin(form);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
form.classList.add('was-validated');
|
form.classList.add('was-validated');
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<!doctype html>
|
<!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">
|
<head th:fragment="headFragment">
|
||||||
<title th:text="#{title.html}">개발자 포털</title>
|
<title th:text="#{title.html}">개발자 포털</title>
|
||||||
@@ -27,6 +28,23 @@
|
|||||||
<!-- CSRF 토큰 (세션 기반). 정적 JS/AJAX에서 토큰·헤더명을 읽어 사용한다. -->
|
<!-- CSRF 토큰 (세션 기반). 정적 JS/AJAX에서 토큰·헤더명을 읽어 사용한다. -->
|
||||||
<meta name="_csrf" th:content="${_csrf != null ? _csrf.token : ''}"/>
|
<meta name="_csrf" th:content="${_csrf != null ? _csrf.token : ''}"/>
|
||||||
<meta name="_csrf_header" th:content="${_csrf != null ? _csrf.headerName : 'X-XSRF-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="max-age=0, public" http-equiv="Cache-Control"/>
|
||||||
<meta content="index, follow" name="robots"/>
|
<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">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no">
|
||||||
|
|||||||
@@ -284,7 +284,7 @@
|
|||||||
var FORCE_LOGOUT_URL = /*[[@{/login?forceLogout=true}]]*/ '/login?forceLogout=true';
|
var FORCE_LOGOUT_URL = /*[[@{/login?forceLogout=true}]]*/ '/login?forceLogout=true';
|
||||||
var LOGOUT_URL = /*[[@{/actionLogout.do}]]*/ '/actionLogout.do';
|
var LOGOUT_URL = /*[[@{/actionLogout.do}]]*/ '/actionLogout.do';
|
||||||
|
|
||||||
var timeoutMinutes = /*[[${sessionTimeoutMinutes}]]*/ 15;
|
var timeoutMinutes = /*[[${sessionTimeoutMinutes}]]*/ 10;
|
||||||
var remainingSeconds = timeoutMinutes * 60;
|
var remainingSeconds = timeoutMinutes * 60;
|
||||||
var WARNING_SECONDS = 60; // 만료 60초 전 연장 확인 모달
|
var WARNING_SECONDS = 60; // 만료 60초 전 연장 확인 모달
|
||||||
var POLL_INTERVAL_MS = 30000; // 서버 잔여시간 동기화 주기
|
var POLL_INTERVAL_MS = 30000; // 서버 잔여시간 동기화 주기
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
</container-descriptor>
|
</container-descriptor>
|
||||||
|
|
||||||
<session-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>
|
<cookie-name>JSESSIONID_PORTAL</cookie-name>
|
||||||
<persistent-store-type>replicated_if_clustered</persistent-store-type>
|
<persistent-store-type>replicated_if_clustered</persistent-store-type>
|
||||||
</session-descriptor>
|
</session-descriptor>
|
||||||
|
|||||||
Reference in New Issue
Block a user