diff --git a/README.md b/README.md index 325110b0..9835cf70 100644 --- a/README.md +++ b/README.md @@ -5,26 +5,19 @@ Flyway는 소비자에게 합리적인 항공권 가격을 제공하기 위해 투명한 동적 가격 모델과 가격 변동 이력 기능을 구현한 실시간 항공권 예매 시스템입니다. -또한 실제 서비스 수준에서 요구되는 핵심 문제인 **좌석 점유 동시성 제어**, **예약 상태 관리**, **결제 안정성 확보**를 중심으로 설계 및 구현했습니다. +또한 실제 서비스 수준에서 요구되는 핵심 문제인 좌석 점유 동시성 제어, 예약 상태 관리, 결제 안정성 확보를 중심으로 설계 및 구현했습니다. 약 20만 건 규모의 대량 데이터를 직접 생성하여 운영 환경을 가정한 기능 검증과 시스템 테스트까지 수행했습니다.
-### 🚀 배포 -🔗 [https://flyway.kr](https://flyway.kr) +**배포**: [https://flyway.kr](https://flyway.kr) -
- -### 🗓️ 개발 기간 - -📌 **2026.01.07 ~ 진행 중** - -
+**개발 기간**: 2026.01.07 ~ 2026.02.13 -### 👥 팀원 소개 +### 팀원 @@ -33,9 +26,9 @@ Flyway는 소비자에게 합리적인 항공권 가격을 제공하기 위해 - - - + + + @@ -69,9 +62,9 @@ Flyway는 소비자에게 합리적인 항공권 가격을 제공하기 위해 - - - + + + @@ -120,24 +113,19 @@ Flyway는 소비자에게 합리적인 항공권 가격을 제공하기 위해

2. 서비스 기획 배경

-### ✅ 문제 정의 - -기존 항공권 예매 시장은 가격 산정 기준이 공개되지 않아 -사용자가 **가격 변동 이유와 현재 가격의 적정성**을 판단하기 어렵습니다. -또한 좌석 점유율과 인기 항공편 정보가 명확한 근거 없이 제공되어 -사용자 의사결정 과정에서 **정보 비대칭 문제**가 발생합니다. +### 문제 정의 -
+기존 항공권 예매 시장은 가격 산정 기준이 공개되지 않아 사용자가 **가격 변동 이유와 현재 가격의 적정성**을 판단하기 어렵습니다. +또한 좌석 점유율과 인기 항공편 정보가 명확한 근거 없이 제공되어 사용자 의사결정 과정에서 **정보 비대칭 문제**가 발생합니다. -### ✨ Flyway 차별점 +### Flyway 차별점 -Flyway는 항공권 가격이 불투명하게 결정되는 문제를 해결하기 위해 -**가격 산정 기준을 공개하는 투명한 예매 서비스**를 제공합니다. +Flyway는 항공권 가격이 불투명하게 결정되는 문제를 해결하기 위해 **가격 산정 기준을 공개하는 투명한 예매 서비스**를 제공합니다. 가격은 다음 요소를 기반으로 산정됩니다. -- **M_time** : 출발일까지 남은 시간 -- **M_load** : 좌석 점유율(수요) -- **alpha** : 가격 변동 완화 계수 +- **M_time** : 출발일까지 남은 시간 +- **M_load** : 좌석 점유율(수요) +- **alpha** : 가격 변동 완화 계수 또한 사용자가 합리적으로 예매할 수 있도록 다음 정보를 제공합니다. @@ -150,8 +138,121 @@ Flyway는 항공권 가격이 불투명하게 결정되는 문제를 해결하
+## 3. 주요 기능 +
👩🏻‍💻 강희민
(@kkhhmm3103)
👩🏻‍💻 김민서
(@minseokim0113)
👩🏻‍💻 박수진
(@cl-o-lc)
강희민
(@kkhhmm3103)
김민서
(@minseokim0113)
박수진
(@cl-o-lc)
🧑🏻‍💻 오찬혁
(@ochanhyeok)
👩🏻‍💻 [팀장] 이가은
(@gaeunnlee)
🧑🏻‍💻 한재훈
(@hjh79gw)
오찬혁
(@ochanhyeok)
[팀장] 이가은
(@gaeunnlee)
한재훈
(@hjh79gw)
+ + + + + + + + +
회원관리자
+ + + +
+ + +
+ + + +## 4. 기술 스택 (Tech Stack) + +| 구분 | 기술 | 구분 | 기술 | +| -------- | ----------------------------------------- | -------- | ----------------------------------------------------- | +| Backend | Spring Framework 5.3.31
MyBatis 3.5.13 | Security | Spring Security 5.8.13
JWT (jjwt 0.12.6)
BCrypt | +| Database | MariaDB (AWS RDS) | Frontend | JSP
Tailwind CSS
Chart.js | +| 실시간 | Spring WebSocket + SockJS | 결제 | 토스페이먼츠 API | +| 인증 | Kakao OAuth 2.0 | 배포 | AWS EC2
Tomcat | + +
+ + + +## 5. 시스템 아키텍처 +Image + +
+ + + +## 6. ERD + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
항공편공항관리자예약(1)
+ + + + + + + +
예약(2)항공편 가격회원
+ + + + + + + +
+ +
+ +## 7. 개발 계획 및 협업 +* WBS 기반 일정 수립을 통해 전체 기능을 도메인 단위로 분해하고, 스프린트 단위로 개발 계획을 구체화 +* 평일 오전 9시 데일리 스크럼 작성 후 9시 30분 대면 회의 진행 +* Jira 이슈 기반 개발 프로세스를 적용하여 기능 단위를 명확히 정의하고, PR 단위로 작업을 관리 +* Notion을 통해 설계 문서, 트러블슈팅, 도메인 지식을 공유하며 팀 전체의 이해도를 통일 +* Discord 알림 연동을 통해 PR/이슈 상황을 실시간 공유하며 협업 속도 개선 + + + + + + + + + +
WBS(1)WBS(2)
+ image + + image +
+image + +
-## 3. 주요 화면 +## 8. 주요 화면 @@ -188,7 +289,7 @@ Flyway는 항공권 가격이 불투명하게 결정되는 문제를 해결하
메인
- +
더보기 @@ -336,136 +437,16 @@ Flyway는 항공권 가격이 불투명하게 결정되는 문제를 해결하 -
- - -## 4. 주요 기능 - - - - - - - - - -
회원관리자
- - - -
- - -
- - - -## 5. 기술 스택 (Tech Stack) - -| 구분 | 기술 | 구분 | 기술 | -| -------- | ----------------------------------------- | -------- | ----------------------------------------------------- | -| Backend | Spring Framework 5.3.31
MyBatis 3.5.13 | Security | Spring Security 5.8.13
JWT (jjwt 0.12.6)
BCrypt | -| Database | MariaDB (AWS RDS) | Frontend | JSP
Tailwind CSS
Chart.js | -| 실시간 | Spring WebSocket + SockJS | 결제 | 토스페이먼츠 API | -| 인증 | Kakao OAuth 2.0 | 배포 | AWS EC2
Tomcat | - -
- - - -## 6. 시스템 아키텍처 -Image -
- - -## 7. ERD - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
항공편공항관리자예약(1)
- - - - - - - -
예약(2)항공편 가격회원
- - - - - - - -
- -
- - - -## 8. 개발 계획 및 협업 -* **WBS 기반 일정 수립**을 통해 전체 기능을 도메인 단위로 분해하고, 스프린트 단위로 개발 계획을 구체화 -* 평일 오전 9시 **데일리 스크럼** 작성 후 9시 30분 대면 회의 진행 -* **Jira 이슈 기반 개발 프로세스**를 적용하여 기능 단위를 명확히 정의하고, PR 단위로 작업을 관리 -* Notion을 통해 설계 문서, 트러블슈팅, 도메인 지식을 공유하며 팀 전체의 이해도를 통일 -* Discord 알림 연동을 통해 PR/이슈 상황을 실시간 공유하며 협업 속도 개선 - - - - - - - - - -
WBS(1)WBS(2)
- image - - image -
-image -
## 9. 핵심 설계 -Flyway는 항공권 예매 과정에서 발생하는 인증 보안 문제, 좌석 중복 예약 문제, 불투명한 가격 정책으로 인한 정보 비대칭 문제를 해결하기 위해 다음 4가지 핵심 설계를 중심으로 구현했습니다. - ### 1) 인증/보안 설계 (JWT + Refresh + CSRF + Cookie) - -Flyway는 HttpOnly Cookie 기반 JWT 인증 방식을 적용하여 토큰 탈취 위험을 줄였습니다. - -또한 Refresh Token 회전(Rotation) 전략을 통해 Access Token 만료 시 자동 재발급이 가능하도록 구현하여 로그인 유지 경험을 개선했습니다. - -소셜 로그인 사용자를 고려하여 OAuth 2.0 기반 로그인(Kakao) 흐름을 적용하였으며, OAuth 인증 성공 이후에도 서비스 내부에서는 JWT를 발급하여 인증 방식을 일관되게 통합했습니다. - -API 요청은 JWT 기반으로 인증되며, 상태 변경 요청(POST/PUT/PATCH/DELETE)은 CSRF 토큰 검증을 통해 보호합니다. +- HttpOnly Cookie 기반 JWT 인증 방식을 적용하여 토큰 탈취 위험을 줄였습니다. +- Refresh Token 회전 전략을 통해 Access Token 만료 시 자동 재발급이 가능하도록 구현하여 로그인 유지 경험을 개선했습니다. +- 소셜 로그인 사용자를 고려하여 OAuth 2.0 기반 로그인(Kakao) 흐름을 적용했습니다. +- OAuth 인증 성공 이후에도 서비스 내부에서는 JWT를 발급하여 인증 방식을 일관되게 통합했습니다. +- API 요청은 JWT 기반으로 인증되며, 상태 변경 요청은 CSRF 토큰 검증을 통해 보호합니다. 이를 통해 브라우저 환경에서의 안정성과 보안성을 강화하고, 확장 가능한 인증 구조를 구축했습니다. @@ -481,18 +462,16 @@ API 요청은 JWT 기반으로 인증되며, 상태 변경 요청(POST/PUT/PATCH OAuth 시퀀스 다이어그램 - kakao_oauth_3 -kakao_oauth_2 kakao_oauth_1 +kakao_oauth_2 +kakao_oauth_3
-### 2) 좌석 동시성 제어 (HOLD → PAYING → CONFIRMED)/> - +### 2) 좌석 동시성 제어 (HOLD → PAYING → CONFIRMED) -Flyway는 동일 좌석에 대한 중복 예약을 방지하기 위해 좌석을 단순 조회 데이터가 아닌 -공유 자원(Concurrency Resource) 으로 정의하고, 상태 기반 점유 모델을 설계했습니다. +동일 좌석에 대한 중복 예약을 방지하기 위해 좌석을 단순 조회 데이터가 아닌 공유 자원으로 정의하고, 상태 기반 점유 모델을 설계했습니다. - 좌석 선택 시 `AVAILABLE → HELD`로 변경하여 임시 점유 처리 - 결제 진행 시 `HELD → PAYING → CONFIRMED` 상태 전이로 경쟁 상태 방지 @@ -503,29 +482,21 @@ Flyway는 동일 좌석에 대한 중복 예약을 방지하기 위해 좌석을 ### 3) 동적 가격 투명성 모델 (M_time, M_load, alpha) -기존 항공권 예매 서비스는 가격 변동 기준이 공개되지 않아 사용자가 가격의 적정성을 판단하기 어렵습니다. +기존 항공권 예매 서비스는 가격 변동 기준이 공개되지 않아 사용자가 가격의 적정성을 판단하기 어렵습니다. Flyway는 이러한 정보 비대칭 문제를 해결하기 위해 **가격 산정 기준을 공개하는 동적 가격 모델**을 적용했습니다. 가격은 다음 요소를 기반으로 산정됩니다. -Flyway는 이러한 정보 비대칭 문제를 해결하기 위해 **가격 산정 기준을 공개하는 동적 가격 모델**을 적용했습니다. - -가격은 다음 요소를 기반으로 산정됩니다. - -- **M_time** : 출발일까지 남은 시간 -- **M_load** : 좌석 점유율 기반 수요 지표 -- **alpha** : 가격 변동 완화 계수 +- M_time : 출발일까지 남은 시간 +- M_load : 좌석 점유율 기반 수요 지표 +- alpha : 가격 변동 완화 계수 또한 사용자에게 가격 변동 이력을 제공하여 합리적인 예매 판단이 가능하도록 설계했습니다. ### 4) 예약 상태 및 결제 흐름 설계 (예약 세그먼트/승객/부가서비스 + 동시성 연계) -Flyway는 항공권 예매가 단순 결제 처리로 끝나지 않고, - -**예약 정보 + 탑승자 정보 + 부가서비스 + 결제 상태**가 유기적으로 연결되는 복합 도메인임을 고려하여 - -예약 흐름을 상태 기반으로 관리했습니다. 트랜잭션과 상태 기반 관리를 통한 동시성 제어로 오버 부킹 상황을 사전에 방지했습니다. +Flyway는 항공권 예매가 단순 결제 처리로 끝나지 않고, 예약 정보 + 탑승자 정보 + 부가서비스 + 결제 상태가 유기적으로 연결되는 복합 도메인임을 고려하여 예약 흐름을 상태 기반으로 관리했습니다. 트랜잭션과 상태 기반 관리를 통한 동시성 제어로 오버 부킹 상황을 사전에 방지했습니다. -- 예약 상태 변경 과정에서 **좌석 동시성 제어 로직(HOLD/PAYING/CONFIRMED)** 이 함께 적용되어 -동일 좌석 중복 예약이 발생하지 않도록 설계했습니다. +- 예약 상태 변경 과정에서 좌석 동시성 제어 로직(HOLD/PAYING/CONFIRMED)이 함께 적용되어 + 동일 좌석 중복 예약이 발생하지 않도록 설계했습니다. - 예약은 해당 항공편을 선택하여 동의 페이지로 이동하기 전 트랜잭션과 잔여석 행에 대한 비관적 락을 통해 잔여석을 인원수 만큼 차감하고 예약자의 상태 칼럼을 `HELD`로 변경합니다. 정보 입력 후 결제를 완료하면 `HELD → CONFIRMED` 흐름으로 상태가 변경되며, 결제 확정 처리됩니다. - 예약 세그먼트(왕복/경유) 구조를 반영하여 다구간 예약을 관리할 수 있도록 설계했습니다. - 탑승자(Passenger) 정보와 좌석(Seat) 선택이 예약과 강하게 연결되도록 구성했습니다. @@ -549,7 +520,7 @@ Flyway는 관리자가 서비스 현황을 실시간으로 모니터링할 수 - **강희민**
어려운 일들이 많았지만 하나씩 해결해 가면서 많이 성장할 수 있었습니다! 이번 프로젝트를 통해 단순히 기능이 동작하는 것보다 현실적인 운영 구조를 데이터 모델에 어떻게 녹여내는지가 훨씬 중요하다는 점을 배웠습니다! - **김민서**
- 다양한 변수로 일정이 지연되었으나, Jira로 계획의 우선순위를 조정하며 단계적으로 해결하였습니다. 이를 통해 계획의 유연성과 일정 관리의 중요성을 배웠습니다. + 다양한 변수로 일정이 지연되었으나, Jira로 계획의 우선순위를 조정하며 단계적으로 해결하였습니다. 이를 통해 계획의 유연성과 일정 관리의 중요성을 배웠습니다. - **박수진**
초기에 시행착오를 겪으며, 우리 팀에게 필요한 소통 방식을 찾아가던 과정이 인상 깊습니다. 매일 9:30 데일리 스크럼을 진행한 것이 큰 도움이 되었습니다. - **오찬혁**
diff --git a/src/main/java/com/flyway/security/config/SecurityConfigApi.java b/src/main/java/com/flyway/security/config/SecurityConfigApi.java index 895c01a4..1c00e68b 100644 --- a/src/main/java/com/flyway/security/config/SecurityConfigApi.java +++ b/src/main/java/com/flyway/security/config/SecurityConfigApi.java @@ -70,7 +70,6 @@ protected void configure(HttpSecurity http) throws Exception { .csrf(csrf -> csrf .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) - // 로그인 전/토큰 재발급 같은 엔드포인트는 "의도적으로" 예외 가능 .ignoringRequestMatchers( new AntPathRequestMatcher("/api/auth/loginProc", "POST"), new AntPathRequestMatcher("/api/auth/refresh", "POST"), diff --git a/src/main/java/com/flyway/security/config/SecurityConfigWeb.java b/src/main/java/com/flyway/security/config/SecurityConfigWeb.java index ceb0c500..977d886b 100644 --- a/src/main/java/com/flyway/security/config/SecurityConfigWeb.java +++ b/src/main/java/com/flyway/security/config/SecurityConfigWeb.java @@ -159,6 +159,9 @@ protected void configure(HttpSecurity http) throws Exception { .logout() .logoutUrl("/auth/logout") .addLogoutHandler(jwtCookieLogoutHandler()) + .invalidateHttpSession(true) + .clearAuthentication(true) + .deleteCookies("JSESSIONID") .logoutSuccessUrl("/login") .permitAll() .and() diff --git a/src/main/java/com/flyway/security/filter/OriginRefererCheckFilter.java b/src/main/java/com/flyway/security/filter/OriginRefererCheckFilter.java index 19b8fb8c..21f4eb54 100644 --- a/src/main/java/com/flyway/security/filter/OriginRefererCheckFilter.java +++ b/src/main/java/com/flyway/security/filter/OriginRefererCheckFilter.java @@ -10,6 +10,8 @@ import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -122,10 +124,63 @@ private boolean isAllowedReferer(String referer) { if (normalizedReferer.equals(allowedOrigin) || normalizedReferer.startsWith(allowedOrigin + "/")) { return true; } + if (isLocalhostSchemeVariant(normalizedReferer, allowedOrigin)) { + return true; + } } return false; } + private boolean isLocalhostSchemeVariant(String refererUrl, String allowedOrigin) { + OriginParts refererOrigin = extractOrigin(refererUrl); + OriginParts allowed = extractOrigin(allowedOrigin); + if (refererOrigin == null || allowed == null) return false; + + if (!isLocalhostHost(refererOrigin.host) || !isLocalhostHost(allowed.host)) return false; + if (refererOrigin.port != allowed.port) return false; + + boolean refererHttp = "http".equals(refererOrigin.scheme); + boolean refererHttps = "https".equals(refererOrigin.scheme); + boolean allowedHttp = "http".equals(allowed.scheme); + boolean allowedHttps = "https".equals(allowed.scheme); + return (refererHttp && allowedHttps) || (refererHttps && allowedHttp); + } + + private boolean isLocalhostHost(String host) { + if (!StringUtils.hasText(host)) return false; + String h = host.toLowerCase(Locale.ROOT); + return "localhost".equals(h) || "127.0.0.1".equals(h) || "::1".equals(h) || "[::1]".equals(h); + } + + private OriginParts extractOrigin(String url) { + try { + URI uri = new URI(url); + String scheme = trimToNull(uri.getScheme()); + String host = trimToNull(uri.getHost()); + if (!StringUtils.hasText(scheme) || !StringUtils.hasText(host)) return null; + + int port = uri.getPort(); + if (port < 0) { + port = "https".equalsIgnoreCase(scheme) ? 443 : 80; + } + return new OriginParts(scheme.toLowerCase(Locale.ROOT), host.toLowerCase(Locale.ROOT), port); + } catch (URISyntaxException e) { + return null; + } + } + + private static final class OriginParts { + private final String scheme; + private final String host; + private final int port; + + private OriginParts(String scheme, String host, int port) { + this.scheme = scheme; + this.host = host; + this.port = port; + } + } + private boolean isIncludedPath(String path) { for (String basePath : includeBasePaths) { if (matchesBasePath(path, basePath)) return true; diff --git a/src/main/java/com/flyway/template/controller/ErrorController.java b/src/main/java/com/flyway/template/controller/ErrorController.java index e2cc58cb..6ab6d205 100644 --- a/src/main/java/com/flyway/template/controller/ErrorController.java +++ b/src/main/java/com/flyway/template/controller/ErrorController.java @@ -2,8 +2,8 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Controller; -import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.GetMapping; import javax.servlet.RequestDispatcher; import javax.servlet.http.HttpServletRequest; @@ -13,7 +13,7 @@ @RequestMapping("/error") public class ErrorController { - @GetMapping + @RequestMapping public String handleError(HttpServletRequest request) { Object status = request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE); String requestUri = (String) request.getAttribute(RequestDispatcher.ERROR_REQUEST_URI); diff --git a/src/main/webapp/WEB-INF/views/common/header.jsp b/src/main/webapp/WEB-INF/views/common/header.jsp index 0beb3956..f9d429e1 100644 --- a/src/main/webapp/WEB-INF/views/common/header.jsp +++ b/src/main/webapp/WEB-INF/views/common/header.jsp @@ -92,6 +92,7 @@ diff --git a/src/main/webapp/resources/common/js/csrfFetch.js b/src/main/webapp/resources/common/js/csrfFetch.js index 9924051e..a762d5ed 100644 --- a/src/main/webapp/resources/common/js/csrfFetch.js +++ b/src/main/webapp/resources/common/js/csrfFetch.js @@ -104,12 +104,6 @@ function withCsrfHeader(init = {}) { return { ...init, headers }; } -/** - * csrfFetch - * - GET/HEAD/OPTIONS: 그냥 fetch - * - POST/PUT/PATCH/DELETE: CSRF 쿠키 보장 + 헤더 자동첨부 - * - 401 처리/refresh/redirect 절대 안 함 - */ export async function csrfFetch(input, init = {}) { const merged = { credentials: "same-origin", ...init }; diff --git a/src/test/java/com/flyway/security/filter/LogoutFlowTest.java b/src/test/java/com/flyway/security/filter/LogoutFlowTest.java new file mode 100644 index 00000000..85f7c97e --- /dev/null +++ b/src/test/java/com/flyway/security/filter/LogoutFlowTest.java @@ -0,0 +1,103 @@ +package com.flyway.security.filter; + +import com.flyway.auth.service.AuthTokenService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.security.web.authentication.logout.CookieClearingLogoutHandler; +import org.springframework.security.web.authentication.logout.LogoutFilter; +import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler; +import org.springframework.security.web.csrf.CookieCsrfTokenRepository; +import org.springframework.security.web.csrf.CsrfFilter; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.stereotype.Controller; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.annotation.GetMapping; + +import javax.servlet.http.Cookie; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class LogoutFlowTest { + + private MockMvc mockMvc; + private AuthTokenService authTokenService; + private CookieCsrfTokenRepository csrfTokenRepository; + + @BeforeEach + void setUp() { + authTokenService = mock(AuthTokenService.class); + csrfTokenRepository = CookieCsrfTokenRepository.withHttpOnlyFalse(); + + CsrfFilter csrfFilter = new CsrfFilter(csrfTokenRepository); + + SecurityContextLogoutHandler contextLogoutHandler = new SecurityContextLogoutHandler(); + contextLogoutHandler.setInvalidateHttpSession(true); + contextLogoutHandler.setClearAuthentication(true); + + CookieClearingLogoutHandler cookieClearingLogoutHandler = + new CookieClearingLogoutHandler("JSESSIONID"); + + LogoutFilter logoutFilter = new LogoutFilter("/login", + (request, response, authentication) -> authTokenService.logout(request, response), + contextLogoutHandler, + cookieClearingLogoutHandler + ); + logoutFilter.setLogoutRequestMatcher(new AntPathRequestMatcher("/auth/logout", "POST")); + + mockMvc = MockMvcBuilders.standaloneSetup(new NoopController()) + .addFilters(csrfFilter, logoutFilter) + .build(); + } + + @Test + @DisplayName("CSRF 토큰 없이 POST /auth/logout 요청하면 403을 반환한다") + void logout_withoutCsrfToken_returnsForbidden() throws Exception { + mockMvc.perform(post("/auth/logout")) + .andExpect(status().isForbidden()); + + verify(authTokenService, never()).logout(any(), any()); + } + + @Test + @DisplayName("유효한 CSRF 쿠키/헤더로 POST /auth/logout 요청하면 /login으로 리다이렉트되고 로그아웃 핸들러가 호출된다") + void logout_withValidCsrfToken_redirectsToLogin() throws Exception { + String token = newTokenValue(); + MockHttpSession session = new MockHttpSession(); + + MvcResult result = mockMvc.perform(post("/auth/logout") + .session(session) + .cookie(new Cookie("XSRF-TOKEN", token)) + .header("X-XSRF-TOKEN", token)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/login")) + .andReturn(); + + verify(authTokenService).logout(any(), any()); + assertThat(session.isInvalid()).isTrue(); + + List setCookies = result.getResponse().getHeaders(HttpHeaders.SET_COOKIE); + assertThat(setCookies).anyMatch(v -> v.startsWith("JSESSIONID=") && v.contains("Max-Age=0")); + } + + private String newTokenValue() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/auth/csrf"); + CsrfToken token = csrfTokenRepository.generateToken(request); + return token.getToken(); + } + + /* 필터 체인 검증용 플레이스홀더(standaloneSetup은 최소 1개의 컨트롤러 필요) */ + @Controller + static class NoopController {} +} diff --git a/src/test/java/com/flyway/template/controller/ErrorControllerTest.java b/src/test/java/com/flyway/template/controller/ErrorControllerTest.java new file mode 100644 index 00000000..e89481ea --- /dev/null +++ b/src/test/java/com/flyway/template/controller/ErrorControllerTest.java @@ -0,0 +1,44 @@ +package com.flyway.template.controller; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import javax.servlet.RequestDispatcher; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; + +class ErrorControllerTest { + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(new ErrorController()).build(); + } + + @Test + @DisplayName("GET /error 요청에 403 상태가 전달되면 403 에러 페이지를 렌더링한다") + void getError_with403_rendersForbiddenPage() throws Exception { + mockMvc.perform(get("/error") + .requestAttr(RequestDispatcher.ERROR_STATUS_CODE, 403) + .requestAttr(RequestDispatcher.ERROR_REQUEST_URI, "/auth/logout")) + .andExpect(status().isOk()) + .andExpect(view().name("error/403")); + } + + @Test + @DisplayName("POST /error 요청에 403 상태가 전달되어도 403 에러 페이지를 렌더링한다") + void postError_with403_rendersForbiddenPage() throws Exception { + mockMvc.perform(post("/error") + .requestAttr(RequestDispatcher.ERROR_STATUS_CODE, 403) + .requestAttr(RequestDispatcher.ERROR_REQUEST_URI, "/auth/logout")) + .andExpect(status().isOk()) + .andExpect(view().name("error/403")); + } +}