From a8dc54a61191cbdf6820cf3563a8571248b4aa49 Mon Sep 17 00:00:00 2001 From: gaeunnlee Date: Sun, 8 Feb 2026 21:04:26 +0900 Subject: [PATCH 01/18] =?UTF-8?q?FLYW-140=20feat:=20refreshToken=20?= =?UTF-8?q?=EB=B6=88=EC=9D=BC=EC=B9=98=20=EC=8B=9C=20=EA=B0=95=EC=A0=9C=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83=20=EB=B0=8F=20WEB=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=20=EB=8F=99=EA=B8=B0=ED=99=94=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=20=EC=B6=94=EA=B0=80=20(#100)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - refresh token 검증 실패/만료/재사용/레이스(이미 회전됨) 등 예외 케이스에서 단순 에러 반환 대신 forceLogout()로 보안 컨텍스트/세션/쿠키를 일괄 정리하도록 개선 - refreshToken 쿠키 path를 '/'로 통일하고, 과거 '/auth' 경로로 남아있는 레거시 쿠키도 함께 삭제 (deleteRefreshCookies로 두 경로 동시 정리) - WEB(SecurityConfigWeb) 체인에 RefreshTokenSessionSyncFilter를 추가하여, 로그인 상태인데 refresh token 쿠키/DB 상태가 불일치할 경우 즉시 강제 로그아웃 후 /login 리다이렉트 - 필터 적용 범위에서 정적 리소스/퍼블릭 엔드포인트(/error 포함)는 제외하여 불필요한 로그아웃 방지 Motivation: - 하이브리드(Session + JWT Cookie) 구조에서 refresh token 유실/폐기 상태로 인증 컨텍스트만 남는 경우 처리 --- .../flyway/auth/service/AuthTokenService.java | 5 + .../auth/service/AuthTokenServiceImpl.java | 40 +++++- .../security/config/SecurityConfigWeb.java | 32 ++++- .../filter/RefreshTokenSessionSyncFilter.java | 133 ++++++++++++++++++ 4 files changed, 205 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/flyway/security/filter/RefreshTokenSessionSyncFilter.java diff --git a/src/main/java/com/flyway/auth/service/AuthTokenService.java b/src/main/java/com/flyway/auth/service/AuthTokenService.java index 05d98792..c0effb73 100644 --- a/src/main/java/com/flyway/auth/service/AuthTokenService.java +++ b/src/main/java/com/flyway/auth/service/AuthTokenService.java @@ -21,6 +21,11 @@ public interface AuthTokenService { */ void logout(HttpServletRequest request, HttpServletResponse response); + /** + * 강제 로그아웃: 세션/보안 컨텍스트 정리 + 쿠키 삭제 + */ + void forceLogout(HttpServletRequest request, HttpServletResponse response); + /** * 토큰 폐기 */ diff --git a/src/main/java/com/flyway/auth/service/AuthTokenServiceImpl.java b/src/main/java/com/flyway/auth/service/AuthTokenServiceImpl.java index 9f14dd91..3af13b5b 100644 --- a/src/main/java/com/flyway/auth/service/AuthTokenServiceImpl.java +++ b/src/main/java/com/flyway/auth/service/AuthTokenServiceImpl.java @@ -12,13 +12,16 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseCookie; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; +import org.springframework.security.core.context.SecurityContextHolder; import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; import java.security.SecureRandom; import java.time.LocalDateTime; import java.util.Base64; @@ -32,7 +35,8 @@ public class AuthTokenServiceImpl implements AuthTokenService { private static final String ACCESS_COOKIE = "accessToken"; private static final String REFRESH_COOKIE = "refreshToken"; private static final String ACCESS_COOKIE_PATH = "/"; - private static final String REFRESH_COOKIE_PATH = "/auth"; + private static final String REFRESH_COOKIE_PATH = "/"; + private static final String LEGACY_REFRESH_COOKIE_PATH = "/auth"; private final JwtProvider jwtProvider; private final JwtProperties jwtProperties; @@ -77,23 +81,27 @@ public void refresh(HttpServletRequest request, HttpServletResponse response) { String refreshRaw = readCookie(request, REFRESH_COOKIE); if (!StringUtils.hasText(refreshRaw)) { + forceLogout(request, response); throw new BusinessException(ErrorCode.AUTH_REFRESH_TOKEN_MISSING); } String hash = tokenHasher.hash(refreshRaw); RefreshToken stored = refreshTokenRepository.findByTokenHash(hash); if (stored == null) { + forceLogout(request, response); throw new BusinessException(ErrorCode.AUTH_REFRESH_TOKEN_INVALID); } /* 만료/폐기 체크 */ if (stored.getRevokedAt() != null || !stored.getExpiresAt().isAfter(now)) { + forceLogout(request, response); throw new BusinessException(ErrorCode.AUTH_REFRESH_TOKEN_EXPIRED); } /* 재사용 탐지 */ if (stored.getRotatedAt() != null) { refreshTokenRepository.revokeAllByUserId(stored.getUserId(), now); + forceLogout(request, response); throw new BusinessException(ErrorCode.AUTH_REFRESH_TOKEN_REUSED); } @@ -123,6 +131,7 @@ public void refresh(HttpServletRequest request, HttpServletResponse response) { /* 동시 요청/레이스: 이미 회전됐거나 revoke인 경우 */ if (rotated == 0) { + forceLogout(request, response); throw new BusinessException(ErrorCode.AUTH_REFRESH_TOKEN_ALREADY_USED); } @@ -147,7 +156,29 @@ public void logout(HttpServletRequest request, HttpServletResponse response) { } deleteCookie(response, ACCESS_COOKIE, ACCESS_COOKIE_PATH); - deleteCookie(response, REFRESH_COOKIE, REFRESH_COOKIE_PATH); + deleteRefreshCookies(response); + } + + @Override + public void forceLogout(HttpServletRequest request, HttpServletResponse response) { + try { + logout(request, response); + } catch (Exception e) { + log.warn("[AUTH] force logout - token cleanup failed", e); + deleteCookie(response, ACCESS_COOKIE, ACCESS_COOKIE_PATH); + deleteRefreshCookies(response); + } + + try { + HttpSession session = request.getSession(false); + if (session != null) { + session.invalidate(); + } + } catch (Exception e) { + log.warn("[AUTH] force logout - session invalidate failed", e); + } + + SecurityContextHolder.clearContext(); } @Transactional @@ -195,6 +226,11 @@ private void deleteCookie(HttpServletResponse response, String name, String path response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); } + private void deleteRefreshCookies(HttpServletResponse response) { + deleteCookie(response, REFRESH_COOKIE, REFRESH_COOKIE_PATH); + deleteCookie(response, REFRESH_COOKIE, LEGACY_REFRESH_COOKIE_PATH); + } + private String readCookie(HttpServletRequest request, String name) { Cookie[] cookies = request.getCookies(); if (cookies == null) return null; diff --git a/src/main/java/com/flyway/security/config/SecurityConfigWeb.java b/src/main/java/com/flyway/security/config/SecurityConfigWeb.java index 99655eca..92d0bc1b 100644 --- a/src/main/java/com/flyway/security/config/SecurityConfigWeb.java +++ b/src/main/java/com/flyway/security/config/SecurityConfigWeb.java @@ -1,7 +1,10 @@ package com.flyway.security.config; import com.flyway.auth.service.AuthTokenService; +import com.flyway.auth.repository.RefreshTokenRepository; +import com.flyway.auth.util.TokenHasher; import com.flyway.security.filter.OnboardingAccessFilter; +import com.flyway.security.filter.RefreshTokenSessionSyncFilter; import com.flyway.security.handler.JwtAuthenticationEntryPoint; import com.flyway.security.handler.LoginSuccessHandler; import com.flyway.security.jwt.JwtProvider; @@ -21,6 +24,10 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.logout.LogoutHandler; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + @Slf4j @Configuration @Order(3) @@ -32,7 +39,7 @@ public class SecurityConfigWeb extends WebSecurityConfigurerAdapter { private static final String[] PUBLIC_ENDPOINTS = { "/", "/login", "/loginProc", "/signup", "/auth/**", "/search/**", - "/payments/success", "/payments/fail", "/payments/complete", "/api/sms/**" + "/payments/success", "/payments/fail", "/payments/complete", "/api/sms/**", "/error", "/error/**", }; private final JwtProvider jwtProvider; @@ -42,6 +49,8 @@ public class SecurityConfigWeb extends WebSecurityConfigurerAdapter { private final UserDetailsService userIdUserDetailsService; private final UserDetailsService emailUserDetailsService; private final AuthTokenService authTokenService; + private final RefreshTokenRepository refreshTokenRepository; + private final TokenHasher tokenHasher; public SecurityConfigWeb( JwtProvider jwtProvider, @@ -49,6 +58,8 @@ public SecurityConfigWeb( PasswordEncoder passwordEncoder, LoginSuccessHandler loginSuccessHandler, AuthTokenService authTokenService, + RefreshTokenRepository refreshTokenRepository, + TokenHasher tokenHasher, @Qualifier("userIdUserDetailsService") UserDetailsService userIdUserDetailsService, @Qualifier("emailUserDetailsService") UserDetailsService emailUserDetailsService ) { @@ -57,6 +68,8 @@ public SecurityConfigWeb( this.passwordEncoder = passwordEncoder; this.loginSuccessHandler = loginSuccessHandler; this.authTokenService = authTokenService; + this.refreshTokenRepository = refreshTokenRepository; + this.tokenHasher = tokenHasher; this.userIdUserDetailsService = userIdUserDetailsService; this.emailUserDetailsService = emailUserDetailsService; } @@ -70,6 +83,19 @@ public JwtWebAuthFilter jwtWebAuthFilter() { ); } + @Bean + public RefreshTokenSessionSyncFilter refreshTokenSessionSyncFilter() { + List excludes = new ArrayList<>(); + excludes.addAll(Arrays.asList(STATIC_RESOURCES)); + excludes.addAll(Arrays.asList(PUBLIC_ENDPOINTS)); + return new RefreshTokenSessionSyncFilter( + refreshTokenRepository, + tokenHasher, + authTokenService, + excludes + ); + } + @Bean public AuthenticationFailureHandler loginFailureHandler() { return (req, res, ex) -> { @@ -117,7 +143,8 @@ protected void configure(HttpSecurity http) throws Exception { .and() .addFilterBefore(jwtWebAuthFilter(), UsernamePasswordAuthenticationFilter.class) - .addFilterAfter(new OnboardingAccessFilter(), JwtWebAuthFilter.class); + .addFilterAfter(refreshTokenSessionSyncFilter(), JwtWebAuthFilter.class) + .addFilterAfter(new OnboardingAccessFilter(), RefreshTokenSessionSyncFilter.class); } @Bean @@ -125,4 +152,3 @@ public LogoutHandler jwtCookieLogoutHandler() { return (request, response, authentication) -> authTokenService.logout(request, response); } } - diff --git a/src/main/java/com/flyway/security/filter/RefreshTokenSessionSyncFilter.java b/src/main/java/com/flyway/security/filter/RefreshTokenSessionSyncFilter.java new file mode 100644 index 00000000..53c11118 --- /dev/null +++ b/src/main/java/com/flyway/security/filter/RefreshTokenSessionSyncFilter.java @@ -0,0 +1,133 @@ +package com.flyway.security.filter; + +import com.flyway.auth.domain.RefreshToken; +import com.flyway.auth.repository.RefreshTokenRepository; +import com.flyway.auth.service.AuthTokenService; +import com.flyway.auth.util.TokenHasher; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.lang.NonNull; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.List; + +@Slf4j +@RequiredArgsConstructor +public class RefreshTokenSessionSyncFilter extends OncePerRequestFilter { + + private static final String REFRESH_COOKIE = "refreshToken"; + + private final RefreshTokenRepository refreshTokenRepository; + private final TokenHasher tokenHasher; + private final AuthTokenService authTokenService; + private final List excludePatterns; + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain + ) throws ServletException, IOException { + String path = resolvePath(request); + + /* 필터 적용 제외 경로: 검증 없이 통과 */ + if (isExcluded(path)) { + filterChain.doFilter(request, response); + return; + } + + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (!isAuthenticated(auth)) { + filterChain.doFilter(request, response); + return; + } + + String refreshRaw = readCookie(request, REFRESH_COOKIE); + if (!StringUtils.hasText(refreshRaw)) { + forceLogoutAndRedirect(request, response, "missing_refresh"); + return; + } + + String hash = tokenHasher.hash(refreshRaw); + RefreshToken stored = refreshTokenRepository.findByTokenHash(hash); + if (isInvalid(stored)) { + forceLogoutAndRedirect(request, response, "invalid_refresh"); + return; + } + + filterChain.doFilter(request, response); + } + + private boolean isInvalid(RefreshToken stored) { + if (stored == null) return true; + LocalDateTime now = LocalDateTime.now(); + return stored.getRevokedAt() != null + || stored.getRotatedAt() != null + || stored.getExpiresAt() == null + || !stored.getExpiresAt().isAfter(now); + } + + private void forceLogoutAndRedirect( + HttpServletRequest request, + HttpServletResponse response, + String reason + ) throws IOException { + log.debug("[AUTH] force logout by refresh sync. reason={}, uri={}", reason, request.getRequestURI()); + authTokenService.forceLogout(request, response); + response.sendRedirect(request.getContextPath() + "/login"); + } + + private boolean isAuthenticated(Authentication auth) { + return auth != null + && auth.isAuthenticated() + && !(auth instanceof AnonymousAuthenticationToken); + } + + /* 요청 경로가 excludePatterns에 포함되는 경우 필터를 적용하지 않음 */ + private boolean isExcluded(String path) { + for (String pattern : excludePatterns) { + if (matches(path, pattern)) { + return true; + } + } + return false; + } + + private boolean matches(String path, String pattern) { + if (pattern == null || pattern.isEmpty()) return false; + if (pattern.endsWith("/**")) { + String prefix = pattern.substring(0, pattern.length() - 3); + return path.startsWith(prefix); + } + return path.equals(pattern); + } + + private String readCookie(HttpServletRequest request, String name) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) return null; + for (Cookie c : cookies) { + if (name.equals(c.getName())) return c.getValue(); + } + return null; + } + + private String resolvePath(HttpServletRequest request) { + String path = request.getRequestURI(); + String contextPath = request.getContextPath(); + if (contextPath != null && !contextPath.isEmpty() && path.startsWith(contextPath)) { + return path.substring(contextPath.length()); + } + return path; + } +} From 366cc1b4533290f7778e853ffba52c62f708eeef Mon Sep 17 00:00:00 2001 From: gaeunnlee Date: Mon, 9 Feb 2026 00:50:07 +0900 Subject: [PATCH 02/18] =?UTF-8?q?FLYW-140=20fix:=20JwtApiAuthFilter?= =?UTF-8?q?=EC=97=90=20JWT=20=EC=9D=B8=EC=A6=9D=20=EB=A7=88=EC=BB=A4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=A4=91=EB=B3=B5=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EB=B0=A9=EC=A7=80=20(#100)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API 요청에서 JWT로 인증된 요청인지 명확히 구분하기 위해 `JWT_AUTHENTICATED_ATTR` 마커를 도입 - `authenticate()` 시점에 - `req.setAttribute(JWT_AUTHENTICATED_ATTR, true)`로 **요청 단위** 마킹 - `auth.setDetails(JWT_AUTHENTICATED_ATTR)`로 **SecurityContext 인증 객체**에도 마킹 - 기존 `isAlreadyAuthenticated()`(Anonymous 제외) 방식 대신, - “이미 JWT로 인증된 요청”만 스킵하도록 `isJwtAuthenticatedRequest()`로 로직 변경 --- .../flyway/security/jwt/JwtApiAuthFilter.java | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/flyway/security/jwt/JwtApiAuthFilter.java b/src/main/java/com/flyway/security/jwt/JwtApiAuthFilter.java index 288bc127..623c80ea 100644 --- a/src/main/java/com/flyway/security/jwt/JwtApiAuthFilter.java +++ b/src/main/java/com/flyway/security/jwt/JwtApiAuthFilter.java @@ -4,7 +4,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.lang.NonNull; -import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; @@ -28,6 +27,7 @@ public class JwtApiAuthFilter extends OncePerRequestFilter { private static final String ACCESS_TOKEN_COOKIE_NAME = "accessToken"; private static final int TOKEN_LOG_PREFIX_LEN = 20; + public static final String JWT_AUTHENTICATED_ATTR = "JWT_AUTHENTICATED"; private final JwtProvider jwtProvider; private final JwtAuthenticationEntryPoint authenticationEntryPoint; @@ -62,14 +62,14 @@ protected void doFilterInternal( return; } - if (isAlreadyAuthenticated()) { - log.debug("[JWT][API] already authenticated. uri={}", uri); + if (isJwtAuthenticatedRequest(req)) { + log.debug("[JWT][API] already jwt-authenticated. uri={}", uri); chain.doFilter(req, res); return; } try { - authenticate(token); + authenticate(token, req); log.debug("[JWT][API] authenticated. uri={}, tokenPrefix={}", uri, safePrefix(token, TOKEN_LOG_PREFIX_LEN)); chain.doFilter(req, res); @@ -85,15 +85,17 @@ protected void doFilterInternal( } } - private void authenticate(String token) { + private void authenticate(String token, HttpServletRequest req) { String userId = jwtProvider.getSubjectOrThrow(token); UserDetails userDetails = userIdUserDetailsService.loadUserByUsername(userId); - Authentication auth = new UsernamePasswordAuthenticationToken( + UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities() ); + auth.setDetails(JWT_AUTHENTICATED_ATTR); SecurityContextHolder.getContext().setAuthentication(auth); + req.setAttribute(JWT_AUTHENTICATED_ATTR, Boolean.TRUE); } private String extractAccessTokenFromCookie(HttpServletRequest req) { @@ -108,11 +110,17 @@ private String extractAccessTokenFromCookie(HttpServletRequest req) { return null; } - private boolean isAlreadyAuthenticated() { + private boolean isJwtAuthenticatedRequest(HttpServletRequest req) { + Object flag = req.getAttribute(JWT_AUTHENTICATED_ATTR); + if (Boolean.TRUE.equals(flag)) { + return true; + } Authentication existing = SecurityContextHolder.getContext().getAuthentication(); - return existing != null - && existing.isAuthenticated() - && !(existing instanceof AnonymousAuthenticationToken); + if (existing == null || !existing.isAuthenticated()) { + return false; + } + Object details = existing.getDetails(); + return JWT_AUTHENTICATED_ATTR.equals(details) || Boolean.TRUE.equals(details); } private String resolvePath(HttpServletRequest request) { From 4a11841cecc0ff0883991100ba4a13c3c4a05054 Mon Sep 17 00:00:00 2001 From: gaeunnlee Date: Mon, 9 Feb 2026 09:24:58 +0900 Subject: [PATCH 03/18] =?UTF-8?q?FLYW-140=20feat:=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20returnUrl=20=ED=8C=8C?= =?UTF-8?q?=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EC=97=B0=EB=8F=99=20(#100)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /login 요청에서 returnUrl 파라미터를 Model에 전달하도록 수정 - returnUrl sanitize 처리로 외부 URL(Open Redirect) 위험 차단 - login.jsp에 returnUrl hidden input 추가하여 로그인 요청 시 전달 --- .../auth/controller/AuthViewController.java | 20 ++++++++++++++++++- src/main/webapp/WEB-INF/views/login.jsp | 2 ++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/flyway/auth/controller/AuthViewController.java b/src/main/java/com/flyway/auth/controller/AuthViewController.java index 8df045bb..187e53dc 100644 --- a/src/main/java/com/flyway/auth/controller/AuthViewController.java +++ b/src/main/java/com/flyway/auth/controller/AuthViewController.java @@ -7,6 +7,7 @@ import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; import javax.servlet.http.HttpSession; @@ -17,7 +18,12 @@ public class AuthViewController { private static final String OAUTH_SIGNUP_EMAIL_ATTR = "OAUTH_SIGNUP_EMAIL"; @GetMapping("/login") - public String loginView() { + public String loginView(@RequestParam(value = "returnUrl", required = false) String returnUrl, + Model model) { + String safeReturnUrl = sanitizeReturnUrl(returnUrl); + if (safeReturnUrl != null) { + model.addAttribute("returnUrl", safeReturnUrl); + } return "login"; } @@ -57,4 +63,16 @@ public String signupView( return "signup"; } + private String sanitizeReturnUrl(String raw) { + if (raw == null) return null; + String path = raw.trim(); + if (path.isEmpty()) return null; + if (!path.startsWith("/")) return null; + if (path.startsWith("//") || path.startsWith("/\\")) return null; + String lower = path.toLowerCase(); + if (lower.startsWith("/http")) return null; + if (path.contains("://")) return null; + return path; + } + } diff --git a/src/main/webapp/WEB-INF/views/login.jsp b/src/main/webapp/WEB-INF/views/login.jsp index a9bd71ea..9ddc9601 100644 --- a/src/main/webapp/WEB-INF/views/login.jsp +++ b/src/main/webapp/WEB-INF/views/login.jsp @@ -1,5 +1,6 @@ <%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> +<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %> @@ -28,6 +29,7 @@
+
From ff042c67ccbea69c0b28ca0c2cafcfe8891a5b8b Mon Sep 17 00:00:00 2001 From: gaeunnlee Date: Mon, 9 Feb 2026 09:26:21 +0900 Subject: [PATCH 04/18] =?UTF-8?q?FLYW-140=20feat:=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EC=84=B1=EA=B3=B5=20=EC=8B=9C=20returnUrl=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EB=A6=AC=EB=8B=A4=EC=9D=B4=EB=A0=89?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(#100)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 로그인 성공 시 returnUrl 파라미터가 존재하면 우선적으로 redirect 처리 - returnUrl sanitize 검증 추가하여 악성 redirect 방지 - 기존 request attribute 기반 redirect 로직과 병행 처리 --- .../security/handler/LoginSuccessHandler.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/main/java/com/flyway/security/handler/LoginSuccessHandler.java b/src/main/java/com/flyway/security/handler/LoginSuccessHandler.java index 3a236f0a..f15e507e 100644 --- a/src/main/java/com/flyway/security/handler/LoginSuccessHandler.java +++ b/src/main/java/com/flyway/security/handler/LoginSuccessHandler.java @@ -56,6 +56,10 @@ private void redirectToTarget(HttpServletRequest request, HttpServletResponse re } private String resolveTargetPath(HttpServletRequest request) { + String returnUrl = sanitizeReturnUrl(request.getParameter("returnUrl")); + if (returnUrl != null) { + return returnUrl; + } Object attribute = request.getAttribute(REDIRECT_PATH_ATTRIBUTE); if (attribute instanceof String) { String path = ((String) attribute).trim(); @@ -65,4 +69,16 @@ private String resolveTargetPath(HttpServletRequest request) { } return "/"; } + + private String sanitizeReturnUrl(String raw) { + if (raw == null) return null; + String path = raw.trim(); + if (path.isEmpty()) return null; + if (!path.startsWith("/")) return null; + if (path.startsWith("//") || path.startsWith("/\\")) return null; + String lower = path.toLowerCase(); + if (lower.startsWith("/http")) return null; + if (path.contains("://")) return null; + return path; + } } From a085a02b1b198844442eb49dbb3def09d30e8e68 Mon Sep 17 00:00:00 2001 From: gaeunnlee Date: Mon, 9 Feb 2026 09:27:38 +0900 Subject: [PATCH 05/18] =?UTF-8?q?FLYW-140=20feat:=20=EC=9B=B9=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20login=20redirect?= =?UTF-8?q?=20entrypoint=20=EC=A0=81=EC=9A=A9=20(#100)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WebLoginRedirectEntryPoint 추가하여 인증 실패 시 /login?returnUrl=... redirect 처리 - SecurityConfigWeb exceptionHandling에 entrypoint 등록 - /login 요청에 대한 무한 redirect 방지 로직 포함 --- .../security/config/SecurityConfigWeb.java | 8 +++ .../handler/WebLoginRedirectEntryPoint.java | 58 +++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 src/main/java/com/flyway/security/handler/WebLoginRedirectEntryPoint.java diff --git a/src/main/java/com/flyway/security/config/SecurityConfigWeb.java b/src/main/java/com/flyway/security/config/SecurityConfigWeb.java index 92d0bc1b..f332f1ae 100644 --- a/src/main/java/com/flyway/security/config/SecurityConfigWeb.java +++ b/src/main/java/com/flyway/security/config/SecurityConfigWeb.java @@ -7,6 +7,7 @@ import com.flyway.security.filter.RefreshTokenSessionSyncFilter; import com.flyway.security.handler.JwtAuthenticationEntryPoint; import com.flyway.security.handler.LoginSuccessHandler; +import com.flyway.security.handler.WebLoginRedirectEntryPoint; import com.flyway.security.jwt.JwtProvider; import com.flyway.security.jwt.JwtWebAuthFilter; import lombok.extern.slf4j.Slf4j; @@ -46,6 +47,7 @@ public class SecurityConfigWeb extends WebSecurityConfigurerAdapter { private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; private final PasswordEncoder passwordEncoder; private final LoginSuccessHandler loginSuccessHandler; + private final WebLoginRedirectEntryPoint webLoginRedirectEntryPoint; private final UserDetailsService userIdUserDetailsService; private final UserDetailsService emailUserDetailsService; private final AuthTokenService authTokenService; @@ -57,6 +59,7 @@ public SecurityConfigWeb( JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint, PasswordEncoder passwordEncoder, LoginSuccessHandler loginSuccessHandler, + WebLoginRedirectEntryPoint webLoginRedirectEntryPoint, AuthTokenService authTokenService, RefreshTokenRepository refreshTokenRepository, TokenHasher tokenHasher, @@ -67,6 +70,7 @@ public SecurityConfigWeb( this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint; this.passwordEncoder = passwordEncoder; this.loginSuccessHandler = loginSuccessHandler; + this.webLoginRedirectEntryPoint = webLoginRedirectEntryPoint; this.authTokenService = authTokenService; this.refreshTokenRepository = refreshTokenRepository; this.tokenHasher = tokenHasher; @@ -125,6 +129,10 @@ protected void configure(HttpSecurity http) throws Exception { .antMatchers(PUBLIC_ENDPOINTS).permitAll().anyRequest().authenticated() .and() + .exceptionHandling() + .authenticationEntryPoint(webLoginRedirectEntryPoint) + .and() + .formLogin() .loginPage("/login") .loginProcessingUrl("/loginProc") diff --git a/src/main/java/com/flyway/security/handler/WebLoginRedirectEntryPoint.java b/src/main/java/com/flyway/security/handler/WebLoginRedirectEntryPoint.java new file mode 100644 index 00000000..ef0286a5 --- /dev/null +++ b/src/main/java/com/flyway/security/handler/WebLoginRedirectEntryPoint.java @@ -0,0 +1,58 @@ +package com.flyway.security.handler; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +@Slf4j +@Component +public class WebLoginRedirectEntryPoint implements AuthenticationEntryPoint { + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException + ) throws IOException { + String path = resolvePath(request); + + /* 무한 루프 방지 */ + if ("/login".equals(path) || "/login/".equals(path)) { + response.sendRedirect(request.getContextPath() + "/login"); + return; + } + + String returnUrl = buildReturnUrl(request); + String encoded = URLEncoder.encode(returnUrl, StandardCharsets.UTF_8); + response.sendRedirect(request.getContextPath() + "/login?returnUrl=" + encoded); + } + + private String resolvePath(HttpServletRequest request) { + String uri = request.getRequestURI(); + String ctx = request.getContextPath(); + return (ctx != null && !ctx.isEmpty() && uri.startsWith(ctx)) ? uri.substring(ctx.length()) : uri; + } + + private String buildReturnUrl(HttpServletRequest request) { + String method = request.getMethod(); + if (method != null && !method.equalsIgnoreCase("GET")) { + return "/"; + } + String path = resolvePath(request); + String query = request.getQueryString(); + String raw = (query != null && !query.isBlank()) ? path + "?" + query : path; + if (raw == null || raw.isBlank()) return "/"; + if (!raw.startsWith("/")) return "/"; + if (raw.startsWith("//") || raw.startsWith("/\\")) return "/"; + String lower = raw.toLowerCase(); + if (lower.startsWith("/http") || raw.contains("://")) return "/"; + return raw; + } +} From b5c109e13497c85ca690b7b9c4a5791faa63e9e8 Mon Sep 17 00:00:00 2001 From: gaeunnlee Date: Mon, 9 Feb 2026 09:28:33 +0900 Subject: [PATCH 06/18] =?UTF-8?q?FLYW-140=20fix:=20JWT/refresh=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20returnUrl=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20redirect=20=EC=B2=98=EB=A6=AC=20(#100)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - JwtWebAuthFilter에서 인증 실패 시 entrypoint 대신 login redirect 처리 - RefreshTokenSessionSyncFilter 강제 로그아웃 시 returnUrl 포함 redirect 적용 - GET 요청에 한해 returnUrl 생성, 외부 redirect 방지 검증 로직 포함 - /login 경로는 JwtWebAuthFilter에서 제외하여 무한 루프 방지 --- .../filter/RefreshTokenSessionSyncFilter.java | 22 +++++++++- .../flyway/security/jwt/JwtWebAuthFilter.java | 41 +++++++++++++++---- 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/flyway/security/filter/RefreshTokenSessionSyncFilter.java b/src/main/java/com/flyway/security/filter/RefreshTokenSessionSyncFilter.java index 53c11118..5bb11f44 100644 --- a/src/main/java/com/flyway/security/filter/RefreshTokenSessionSyncFilter.java +++ b/src/main/java/com/flyway/security/filter/RefreshTokenSessionSyncFilter.java @@ -19,6 +19,8 @@ import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; import java.util.List; @@ -85,7 +87,9 @@ private void forceLogoutAndRedirect( ) throws IOException { log.debug("[AUTH] force logout by refresh sync. reason={}, uri={}", reason, request.getRequestURI()); authTokenService.forceLogout(request, response); - response.sendRedirect(request.getContextPath() + "/login"); + String returnUrl = buildReturnUrl(request); + String encoded = URLEncoder.encode(returnUrl, StandardCharsets.UTF_8); + response.sendRedirect(request.getContextPath() + "/login?returnUrl=" + encoded); } private boolean isAuthenticated(Authentication auth) { @@ -130,4 +134,20 @@ private String resolvePath(HttpServletRequest request) { } return path; } + + private String buildReturnUrl(HttpServletRequest request) { + String method = request.getMethod(); + if (method != null && !method.equalsIgnoreCase("GET")) { + return "/"; + } + String path = resolvePath(request); + String query = request.getQueryString(); + String raw = (query != null && !query.isBlank()) ? path + "?" + query : path; + if (raw == null || raw.isBlank()) return "/"; + if (!raw.startsWith("/")) return "/"; + if (raw.startsWith("//") || raw.startsWith("/\\")) return "/"; + String lower = raw.toLowerCase(); + if (lower.startsWith("/http") || raw.contains("://")) return "/"; + return raw; + } } diff --git a/src/main/java/com/flyway/security/jwt/JwtWebAuthFilter.java b/src/main/java/com/flyway/security/jwt/JwtWebAuthFilter.java index 810ed6de..117f0136 100644 --- a/src/main/java/com/flyway/security/jwt/JwtWebAuthFilter.java +++ b/src/main/java/com/flyway/security/jwt/JwtWebAuthFilter.java @@ -5,7 +5,6 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.lang.NonNull; import org.springframework.security.authentication.AnonymousAuthenticationToken; -import org.springframework.security.authentication.AuthenticationServiceException; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; @@ -21,6 +20,8 @@ import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; @Slf4j @RequiredArgsConstructor @@ -28,6 +29,7 @@ public class JwtWebAuthFilter extends OncePerRequestFilter { private static final String ACCESS_TOKEN_COOKIE_NAME = "accessToken"; private static final String ADMIN_PREFIX = "/admin/"; + public static final String JWT_AUTHENTICATED_ATTR = "JWT_AUTHENTICATED"; private final JwtProvider jwtProvider; private final JwtAuthenticationEntryPoint entryPoint; @@ -47,6 +49,12 @@ protected void doFilterInternal( @NonNull FilterChain filterChain ) throws ServletException, IOException { + String path = resolvePath(request); + if (path.startsWith("/login")) { + filterChain.doFilter(request, response); + return; + } + try { if (!isAuthenticated()) { String token = extractAccessTokenFromCookie(request); @@ -62,6 +70,7 @@ protected void doFilterInternal( ); SecurityContextHolder.getContext().setAuthentication(auth); + request.setAttribute(JWT_AUTHENTICATED_ATTR, Boolean.TRUE); log.debug("[JWT][WEB] authenticated. uri={}, userId={}", request.getRequestURI(), userId); @@ -74,15 +83,11 @@ protected void doFilterInternal( SecurityContextHolder.clearContext(); log.warn("[JWT][WEB] bad credentials. uri={}, msg={}", request.getRequestURI(), e.getMessage()); - entryPoint.commence(request, response, e); + redirectToLogin(request, response); } catch (Exception e) { SecurityContextHolder.clearContext(); log.error("[JWT][WEB] unexpected exception. uri={}", request.getRequestURI(), e); - entryPoint.commence( - request, - response, - new AuthenticationServiceException("JWT authentication failed", e) - ); + redirectToLogin(request, response); } } @@ -110,4 +115,26 @@ private String resolvePath(HttpServletRequest request) { String ctx = request.getContextPath(); return (ctx != null && !ctx.isEmpty() && uri.startsWith(ctx)) ? uri.substring(ctx.length()) : uri; } + + private void redirectToLogin(HttpServletRequest request, HttpServletResponse response) throws IOException { + String returnUrl = buildReturnUrl(request); + String encoded = URLEncoder.encode(returnUrl, StandardCharsets.UTF_8); + response.sendRedirect(request.getContextPath() + "/login?returnUrl=" + encoded); + } + + private String buildReturnUrl(HttpServletRequest request) { + String method = request.getMethod(); + if (method != null && !method.equalsIgnoreCase("GET")) { + return "/"; + } + String path = resolvePath(request); + String query = request.getQueryString(); + String raw = (query != null && !query.isBlank()) ? path + "?" + query : path; + if (raw == null || raw.isBlank()) return "/"; + if (!raw.startsWith("/")) return "/"; + if (raw.startsWith("//") || raw.startsWith("/\\")) return "/"; + String lower = raw.toLowerCase(); + if (lower.startsWith("/http") || raw.contains("://")) return "/"; + return raw; + } } From 8acb0444ff0ef84643a0b774faa976fe9a5aa9cc Mon Sep 17 00:00:00 2001 From: gaeunnlee Date: Mon, 9 Feb 2026 12:36:19 +0900 Subject: [PATCH 07/18] =?UTF-8?q?FLYW-140=20feat:=20SecurityConfigWeb=20CS?= =?UTF-8?q?RF=20=ED=99=9C=EC=84=B1=ED=99=94=20=EB=B0=8F=20form=20=ED=83=9C?= =?UTF-8?q?=EA=B7=B8=20csrfInput=20=EC=B6=94=EA=B0=80=20(#100)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/flyway/security/config/SecurityConfigWeb.java | 2 -- src/main/webapp/WEB-INF/views/common/header.jsp | 1 + src/main/webapp/WEB-INF/views/home.jsp | 2 ++ src/main/webapp/WEB-INF/views/login.jsp | 2 ++ src/main/webapp/WEB-INF/views/reservations/agreement.jsp | 4 +++- src/main/webapp/WEB-INF/views/reservations/booking.jsp | 4 +++- src/main/webapp/WEB-INF/views/signup.jsp | 2 ++ 7 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/flyway/security/config/SecurityConfigWeb.java b/src/main/java/com/flyway/security/config/SecurityConfigWeb.java index f332f1ae..9edd951b 100644 --- a/src/main/java/com/flyway/security/config/SecurityConfigWeb.java +++ b/src/main/java/com/flyway/security/config/SecurityConfigWeb.java @@ -122,8 +122,6 @@ protected void configure(AuthenticationManagerBuilder auth) throws Exception { @Override protected void configure(HttpSecurity http) throws Exception { http - .csrf().disable() - .authorizeRequests() .antMatchers(STATIC_RESOURCES).permitAll() .antMatchers(PUBLIC_ENDPOINTS).permitAll().anyRequest().authenticated() diff --git a/src/main/webapp/WEB-INF/views/common/header.jsp b/src/main/webapp/WEB-INF/views/common/header.jsp index dd63918a..0beb3956 100644 --- a/src/main/webapp/WEB-INF/views/common/header.jsp +++ b/src/main/webapp/WEB-INF/views/common/header.jsp @@ -53,6 +53,7 @@ 마이페이지 +
+
diff --git a/src/main/webapp/WEB-INF/views/reservations/agreement.jsp b/src/main/webapp/WEB-INF/views/reservations/agreement.jsp index 750427e6..82d7e261 100644 --- a/src/main/webapp/WEB-INF/views/reservations/agreement.jsp +++ b/src/main/webapp/WEB-INF/views/reservations/agreement.jsp @@ -1,6 +1,7 @@ <%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%> <%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt"%> +<%@ taglib prefix="sec" uri="http://www.springframework.org/security/tags" %> @@ -197,6 +198,7 @@ + @@ -347,4 +349,4 @@ - \ No newline at end of file + diff --git a/src/main/webapp/WEB-INF/views/reservations/booking.jsp b/src/main/webapp/WEB-INF/views/reservations/booking.jsp index bc0e9252..6b29ee3d 100644 --- a/src/main/webapp/WEB-INF/views/reservations/booking.jsp +++ b/src/main/webapp/WEB-INF/views/reservations/booking.jsp @@ -2,6 +2,7 @@ <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%> <%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt"%> <%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %> +<%@ taglib prefix="sec" uri="http://www.springframework.org/security/tags" %> @@ -324,6 +325,7 @@ +

탑승자 ${st.index + 1}

@@ -1372,4 +1374,4 @@ - \ No newline at end of file + diff --git a/src/main/webapp/WEB-INF/views/signup.jsp b/src/main/webapp/WEB-INF/views/signup.jsp index b69548c3..efa765c4 100644 --- a/src/main/webapp/WEB-INF/views/signup.jsp +++ b/src/main/webapp/WEB-INF/views/signup.jsp @@ -1,5 +1,6 @@ <%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> +<%@ taglib prefix="sec" uri="http://www.springframework.org/security/tags" %> @@ -92,6 +93,7 @@ +
From e7ec217ce44a56b1f0abcc479f0fa0f0716f4b0e Mon Sep 17 00:00:00 2001 From: gaeunnlee Date: Tue, 10 Feb 2026 16:26:57 +0900 Subject: [PATCH 08/18] =?UTF-8?q?FLYW-140=20feat:=20SecurityConfigApi=20CS?= =?UTF-8?q?RF=20=ED=99=9C=EC=84=B1=ED=99=94=20=EB=B0=8F=20csrfFetch=20?= =?UTF-8?q?=EC=9C=A0=ED=8B=B8=20=EC=B6=94=EA=B0=80=20(#100)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 5 + .../security/config/SecurityConfigApi.java | 12 ++- .../webapp/resources/common/js/authFetch.js | 32 +++---- .../webapp/resources/common/js/csrfFetch.js | 96 +++++++++++++++++++ 4 files changed, 126 insertions(+), 19 deletions(-) create mode 100644 src/main/webapp/resources/common/js/csrfFetch.js diff --git a/src/main/java/com/flyway/auth/controller/AuthController.java b/src/main/java/com/flyway/auth/controller/AuthController.java index bfaeac7b..fea1cf99 100644 --- a/src/main/java/com/flyway/auth/controller/AuthController.java +++ b/src/main/java/com/flyway/auth/controller/AuthController.java @@ -116,6 +116,11 @@ public ResponseEntity refresh( } } + @GetMapping("/auth/csrf") + public ResponseEntity csrf() { + return ResponseEntity.noContent().build(); + } + private void autoLoginByEmail(String email, HttpServletRequest req, HttpServletResponse res) { UserDetails userDetails = emailUserDetailsService.loadUserByUsername(email); authenticateAndSave(userDetails, req, res); diff --git a/src/main/java/com/flyway/security/config/SecurityConfigApi.java b/src/main/java/com/flyway/security/config/SecurityConfigApi.java index 2ed11b06..640a0b99 100644 --- a/src/main/java/com/flyway/security/config/SecurityConfigApi.java +++ b/src/main/java/com/flyway/security/config/SecurityConfigApi.java @@ -16,6 +16,8 @@ import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.csrf.CookieCsrfTokenRepository; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; @Configuration @EnableWebSecurity @@ -56,7 +58,15 @@ protected void configure(HttpSecurity http) throws Exception { .sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() - .csrf().disable() + .csrf(csrf -> csrf + .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) + // 로그인 전/토큰 재발급 같은 엔드포인트는 "의도적으로" 예외 가능 + .ignoringRequestMatchers( + new AntPathRequestMatcher("/api/auth/loginProc", "POST"), + new AntPathRequestMatcher("/api/auth/refresh", "POST"), + new AntPathRequestMatcher("/api/auth/logout", "POST") + ) + ) .formLogin().disable() .httpBasic().disable() diff --git a/src/main/webapp/resources/common/js/authFetch.js b/src/main/webapp/resources/common/js/authFetch.js index 3f7b59d9..a6c3124a 100644 --- a/src/main/webapp/resources/common/js/authFetch.js +++ b/src/main/webapp/resources/common/js/authFetch.js @@ -1,3 +1,5 @@ +import { csrfFetch } from "./csrfFetch.js"; + const CONFIG = { REFRESH_URL: "/auth/refresh", LOGIN_URL: "/login", @@ -8,50 +10,45 @@ let refreshPromise = null; function getBasePath() { return window.APP?.contextPath ?? ""; } - function isAbsoluteHttpUrl(url) { return typeof url === "string" && /^https?:\/\//i.test(url); } - function joinBasePath(base, path) { if (!base) return path; const b = base.endsWith("/") ? base.slice(0, -1) : base; const p = path.startsWith("/") ? path : `/${path}`; return `${b}${p}`; } - function normalizeInputToUrl(input) { if (input instanceof Request) return input.url; if (typeof input === "string") return input; return String(input); } - function toFetchUrl(input) { const raw = normalizeInputToUrl(input); if (isAbsoluteHttpUrl(raw)) return raw; - - const base = getBasePath(); - return joinBasePath(base, raw); + return joinBasePath(getBasePath(), raw); } +/** + * 인증 필요 API 전용 + * - 401이면 refresh 시도 후 재요청 + * - refresh 실패/재시도 후에도 401이면 /login 이동 + */ export async function fetchWithRefresh(input, init = {}) { - const doFetch = async () => - fetch(toFetchUrl(input), { - credentials: "same-origin", - ...init, - }); + const doFetch = async (overrideInit = {}) => { + const merged = { ...init, ...overrideInit }; + // csrfFetch가 GET/POST 알아서 처리함 + return csrfFetch(input, merged); + }; let res = await doFetch(); - if (res.status !== 401) return res; if (!refreshPromise) { refreshPromise = (async () => { try { - const refreshRes = await fetch(toFetchUrl(CONFIG.REFRESH_URL), { - method: "POST", - credentials: "same-origin", - }); + const refreshRes = await csrfFetch(CONFIG.REFRESH_URL, { method: "POST" }); return refreshRes.ok; } catch (e) { return false; @@ -68,7 +65,6 @@ export async function fetchWithRefresh(input, init = {}) { throw new Error("Unauthorized (refresh failed)"); } - // refresh 성공 → 재시도 res = await doFetch(); if (res.status === 401) { diff --git a/src/main/webapp/resources/common/js/csrfFetch.js b/src/main/webapp/resources/common/js/csrfFetch.js new file mode 100644 index 00000000..603e55bb --- /dev/null +++ b/src/main/webapp/resources/common/js/csrfFetch.js @@ -0,0 +1,96 @@ +const CONFIG = { + CSRF_COOKIE_NAME: "XSRF-TOKEN", + CSRF_HEADER_NAME: "X-XSRF-TOKEN", + CSRF_BOOTSTRAP_URL: "/auth/csrf", +}; + +function getBasePath() { + return window.APP?.contextPath ?? ""; +} + +function isAbsoluteHttpUrl(url) { + return typeof url === "string" && /^https?:\/\//i.test(url); +} + +function joinBasePath(base, path) { + if (!base) return path; + const b = base.endsWith("/") ? base.slice(0, -1) : base; + const p = path.startsWith("/") ? path : `/${path}`; + return `${b}${p}`; +} + +function normalizeInputToUrl(input) { + if (input instanceof Request) return input.url; + if (typeof input === "string") return input; + return String(input); +} + +function toFetchUrl(input) { + const raw = normalizeInputToUrl(input); + if (isAbsoluteHttpUrl(raw)) return raw; + return joinBasePath(getBasePath(), raw); +} + +function readCookieRaw(name) { + const found = document.cookie + .split("; ") + .find((row) => row.startsWith(name + "=")); + if (!found) return null; + const idx = found.indexOf("="); + return found.substring(idx + 1); +} + +function readCookie(name) { + const raw = readCookieRaw(name); + return raw ? decodeURIComponent(raw) : null; +} + +function isStateChanging(method) { + const m = (method || "GET").toUpperCase(); + return ["POST", "PUT", "PATCH", "DELETE"].includes(m); +} + +async function ensureCsrfCookie() { + const existing = readCookie(CONFIG.CSRF_COOKIE_NAME); + if (existing) return existing; + + try { + await fetch(toFetchUrl(CONFIG.CSRF_BOOTSTRAP_URL), { + method: "GET", + credentials: "same-origin", + cache: "no-store", + }); + } catch (e) {} + + return readCookie(CONFIG.CSRF_COOKIE_NAME); +} + +function withCsrfHeader(init = {}) { + const method = (init.method || "GET").toUpperCase(); + if (!isStateChanging(method)) return init; + + const headers = new Headers(init.headers || {}); + if (!headers.has(CONFIG.CSRF_HEADER_NAME)) { + const token = readCookie(CONFIG.CSRF_COOKIE_NAME); + if (token) headers.set(CONFIG.CSRF_HEADER_NAME, token); + } + + 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 }; + + if (isStateChanging(merged.method)) { + const token = await ensureCsrfCookie(); + if (!token) throw new Error("CSRF token cookie not found (bootstrap failed)"); + } + + return fetch(toFetchUrl(input), withCsrfHeader(merged)); +} From 12310cb6704878536f699830c188a625e4e98549 Mon Sep 17 00:00:00 2001 From: gaeunnlee Date: Tue, 10 Feb 2026 17:09:55 +0900 Subject: [PATCH 09/18] =?UTF-8?q?FLYW-140=20feat:=20=ED=95=AD=EA=B3=B5?= =?UTF-8?q?=EA=B6=8C=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20POST=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20CSRF=20=EC=9C=A0=ED=8B=B8=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20(#100)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/FlightApiController.java | 4 ++-- .../search/dto/FlightSearchRequest.java | 3 +++ src/main/webapp/resources/search/js/search.js | 19 +++++++++++-------- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/flyway/search/controller/FlightApiController.java b/src/main/java/com/flyway/search/controller/FlightApiController.java index 8805e8cb..596a69c8 100644 --- a/src/main/java/com/flyway/search/controller/FlightApiController.java +++ b/src/main/java/com/flyway/search/controller/FlightApiController.java @@ -39,8 +39,8 @@ public List airline(Airline vo) { } // 검색 - @PostMapping("/api/public/flights/search") - public SearchResultDto search(@RequestBody FlightSearchRequest dto) { + @GetMapping("/api/public/flights/search") + public SearchResultDto search(@ModelAttribute FlightSearchRequest dto) { return service.search(dto); } diff --git a/src/main/java/com/flyway/search/dto/FlightSearchRequest.java b/src/main/java/com/flyway/search/dto/FlightSearchRequest.java index 189f5684..eac52ade 100644 --- a/src/main/java/com/flyway/search/dto/FlightSearchRequest.java +++ b/src/main/java/com/flyway/search/dto/FlightSearchRequest.java @@ -1,6 +1,7 @@ package com.flyway.search.dto; import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; import java.time.LocalDate; @@ -9,7 +10,9 @@ public class FlightSearchRequest { private String tripType; private String from; private String to; + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) private LocalDate dateStart; + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) private LocalDate dateEnd; private Integer passengers; private String cabinClass; diff --git a/src/main/webapp/resources/search/js/search.js b/src/main/webapp/resources/search/js/search.js index 4bccd7d9..0aeefa11 100644 --- a/src/main/webapp/resources/search/js/search.js +++ b/src/main/webapp/resources/search/js/search.js @@ -3,6 +3,7 @@ let ARR_AIRPORTS = []; let allOptions = []; let displayedOptions = []; let details = {}; +const csrfFetch = window.csrfFetch || ((input, init = {}) => fetch(input, { credentials: "same-origin", ...init })); async function loadDepAirports() { const res = await fetch(`${CONTEXT_PATH}/api/public/depAirports`); @@ -785,14 +786,16 @@ async function executeSearch() { }; try { - // 3) 검색 API 호출 (POST) - const res = await fetch(`${CONTEXT_PATH}/api/public/flights/search`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Accept": "application/json" - }, - body: JSON.stringify(payload) + const paramsBuilder = new URLSearchParams(); + Object.entries(payload).forEach(([key, value]) => { + if (value === null || value === undefined || value === "") return; + paramsBuilder.append(key, value); + }); + const params = paramsBuilder.toString(); + // 3) 검색 API 호출 (GET) + const res = await csrfFetch(`${CONTEXT_PATH}/api/public/flights/search?${params}`, { + method: "GET", + headers: { "Accept": "application/json" } }); const json = await res.json(); From 98b9998890b59af9530371d3917c6d77e99a9de2 Mon Sep 17 00:00:00 2001 From: gaeunnlee Date: Wed, 11 Feb 2026 15:27:54 +0900 Subject: [PATCH 10/18] =?UTF-8?q?FLYW-140=20feat:=20SecurityConfigWeb=20cs?= =?UTF-8?q?rf=20=ED=99=9C=EC=84=B1=ED=99=94=20(#100)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/flyway/security/config/SecurityConfigWeb.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/com/flyway/security/config/SecurityConfigWeb.java b/src/main/java/com/flyway/security/config/SecurityConfigWeb.java index 9edd951b..d8952f44 100644 --- a/src/main/java/com/flyway/security/config/SecurityConfigWeb.java +++ b/src/main/java/com/flyway/security/config/SecurityConfigWeb.java @@ -24,6 +24,7 @@ import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.logout.LogoutHandler; +import org.springframework.security.web.csrf.CookieCsrfTokenRepository; import java.util.ArrayList; import java.util.Arrays; @@ -122,6 +123,10 @@ protected void configure(AuthenticationManagerBuilder auth) throws Exception { @Override protected void configure(HttpSecurity http) throws Exception { http + .csrf() + .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) + .and() + .authorizeRequests() .antMatchers(STATIC_RESOURCES).permitAll() .antMatchers(PUBLIC_ENDPOINTS).permitAll().anyRequest().authenticated() From 7fabc303ab941b5aed273427b3c3c66dd9d33dc1 Mon Sep 17 00:00:00 2001 From: gaeunnlee Date: Wed, 11 Feb 2026 15:33:25 +0900 Subject: [PATCH 11/18] =?UTF-8?q?FLYW-140=20feat:=20fetch=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=20csrfFetch=20=EC=9D=BC=EA=B4=84=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=20(#100)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/webapp/WEB-INF/views/auth/include/head.jsp | 4 ++++ src/main/webapp/WEB-INF/views/common/head.jsp | 4 ++++ src/main/webapp/WEB-INF/views/payment/refund-test.jsp | 7 +++++-- src/main/webapp/WEB-INF/views/seat/seat-assets-js.jspf | 4 ++++ src/main/webapp/resources/auth/signup.js | 4 ++-- src/main/webapp/resources/main/js/main.js | 2 +- src/main/webapp/resources/main/js/promotion.js | 4 ++-- src/main/webapp/resources/mypage/js/render/bookings.js | 3 ++- src/main/webapp/resources/mypage/js/render/dashboard.js | 3 ++- src/main/webapp/resources/mypage/js/render/detail.js | 3 ++- src/main/webapp/resources/search/js/details.js | 4 ++-- src/main/webapp/resources/search/js/filtering.js | 4 ++-- src/main/webapp/resources/search/js/flight.js | 8 ++++---- src/main/webapp/resources/search/js/search.js | 6 ++---- src/main/webapp/resources/seat/js/seat-api.js | 7 ++++--- src/main/webapp/resources/signup/js/signup.js | 8 ++++---- 16 files changed, 46 insertions(+), 29 deletions(-) diff --git a/src/main/webapp/WEB-INF/views/auth/include/head.jsp b/src/main/webapp/WEB-INF/views/auth/include/head.jsp index a87ef49f..c78ce231 100644 --- a/src/main/webapp/WEB-INF/views/auth/include/head.jsp +++ b/src/main/webapp/WEB-INF/views/auth/include/head.jsp @@ -29,6 +29,10 @@ + diff --git a/src/main/webapp/WEB-INF/views/common/head.jsp b/src/main/webapp/WEB-INF/views/common/head.jsp index 96c8ca34..0e182698 100644 --- a/src/main/webapp/WEB-INF/views/common/head.jsp +++ b/src/main/webapp/WEB-INF/views/common/head.jsp @@ -26,3 +26,7 @@ + diff --git a/src/main/webapp/WEB-INF/views/payment/refund-test.jsp b/src/main/webapp/WEB-INF/views/payment/refund-test.jsp index 27bb8953..e92df750 100644 --- a/src/main/webapp/WEB-INF/views/payment/refund-test.jsp +++ b/src/main/webapp/WEB-INF/views/payment/refund-test.jsp @@ -37,6 +37,10 @@ cursor: not-allowed; } +
@@ -105,7 +109,7 @@ } if (confirm('결제 ID: ' + paymentId + '\n환불 사유: ' + reason + '\n\n정말로 환불을 요청하시겠습니까?')) { - fetch(contextPath + '/api/payments/' + paymentId + '/refund',{ + csrfFetch(contextPath + '/api/payments/' + paymentId + '/refund',{ method: 'POST', headers: { 'Content-Type': 'application/json', @@ -140,4 +144,3 @@ - diff --git a/src/main/webapp/WEB-INF/views/seat/seat-assets-js.jspf b/src/main/webapp/WEB-INF/views/seat/seat-assets-js.jspf index be420c94..4619abba 100644 --- a/src/main/webapp/WEB-INF/views/seat/seat-assets-js.jspf +++ b/src/main/webapp/WEB-INF/views/seat/seat-assets-js.jspf @@ -1,6 +1,10 @@ <%-- SweetAlert2 --%> + <%-- Seat Selection --%> diff --git a/src/main/webapp/resources/auth/signup.js b/src/main/webapp/resources/auth/signup.js index c09120b2..843981fe 100644 --- a/src/main/webapp/resources/auth/signup.js +++ b/src/main/webapp/resources/auth/signup.js @@ -40,7 +40,7 @@ setText(sendStatus, "인증메일을 발송 중입니다...", true); try { - const res = await fetch(base + "/api/auth/email/issue", { + const res = await csrfFetch(base + "/api/auth/email/issue", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8" @@ -89,7 +89,7 @@ email: email, attemptId: attemptIdHidden.value }); - const res = await fetch(base + "/api/auth/email/status?" + query.toString()); + const res = await csrfFetch(base + "/api/auth/email/status?" + query.toString()); if (!res.ok) { setText(verifyStatus, "인증 확인에 실패했습니다.", false); diff --git a/src/main/webapp/resources/main/js/main.js b/src/main/webapp/resources/main/js/main.js index 2a10a78f..ef221bf9 100644 --- a/src/main/webapp/resources/main/js/main.js +++ b/src/main/webapp/resources/main/js/main.js @@ -5,7 +5,7 @@ document.addEventListener("DOMContentLoaded", () => { async function loadHotSixAirport() { try { - const res = await fetch(`${CONTEXT_PATH}/api/public/rank/realtime`); + const res = await csrfFetch(`${CONTEXT_PATH}/api/public/rank/realtime`); if (!res.ok) { throw new Error(`HTTP ${res.status}`); } diff --git a/src/main/webapp/resources/main/js/promotion.js b/src/main/webapp/resources/main/js/promotion.js index 813c3770..e3e174c3 100644 --- a/src/main/webapp/resources/main/js/promotion.js +++ b/src/main/webapp/resources/main/js/promotion.js @@ -67,7 +67,7 @@ function moveNext() { // api 호출 async function loadPromotionCard() { try { - const res = await fetch(`${CONTEXT_PATH}/api/public/promotions`); + const res = await csrfFetch(`${CONTEXT_PATH}/api/public/promotions`); if(!res.ok) { throw new Error(`HTTP ${res.status}`); } @@ -246,4 +246,4 @@ function goToBooking(item) { // 폼 제출 → /reservations/draft → 동의 페이지로 redirect document.getElementById("reservationForm").submit(); -} \ No newline at end of file +} diff --git a/src/main/webapp/resources/mypage/js/render/bookings.js b/src/main/webapp/resources/mypage/js/render/bookings.js index f26b246d..c6b1ad58 100644 --- a/src/main/webapp/resources/mypage/js/render/bookings.js +++ b/src/main/webapp/resources/mypage/js/render/bookings.js @@ -12,6 +12,7 @@ import { formatReservationId, getContextPath, } from "../utils.js"; +import { csrfFetch } from "../../../common/js/csrfFetch.js"; let airlineMapPromise = null; @@ -26,7 +27,7 @@ function toAssetUrl(path) { async function getAirlineMap() { if (airlineMapPromise) return airlineMapPromise; const url = `${getContextPath()}/resources/mypage/json/airline.json`; - airlineMapPromise = fetch(url, { headers: { Accept: "application/json" } }) + airlineMapPromise = csrfFetch(url, { headers: { Accept: "application/json" } }) .then((res) => (res.ok ? res.json() : {})) .catch(() => ({})); return airlineMapPromise; diff --git a/src/main/webapp/resources/mypage/js/render/dashboard.js b/src/main/webapp/resources/mypage/js/render/dashboard.js index daa62fc5..53c52486 100644 --- a/src/main/webapp/resources/mypage/js/render/dashboard.js +++ b/src/main/webapp/resources/mypage/js/render/dashboard.js @@ -9,6 +9,7 @@ import { formatReservationId, getContextPath, } from "../utils.js"; +import { csrfFetch } from "../../../common/js/csrfFetch.js"; export function updateDashboardProfile(profile) { const name = profile?.name || ""; @@ -113,7 +114,7 @@ function toAssetUrl(path) { function getAirlineMap() { const url = `${getContextPath()}/resources/mypage/json/airline.json`; - return fetch(url, { headers: { Accept: "application/json" } }) + return csrfFetch(url, { headers: { Accept: "application/json" } }) .then((res) => (res.ok ? res.json() : {})) .catch(() => ({})); } diff --git a/src/main/webapp/resources/mypage/js/render/detail.js b/src/main/webapp/resources/mypage/js/render/detail.js index 553ecc2f..d62f86d5 100644 --- a/src/main/webapp/resources/mypage/js/render/detail.js +++ b/src/main/webapp/resources/mypage/js/render/detail.js @@ -11,6 +11,7 @@ import { getContextPath, } from "../utils.js"; import { fetchJson } from "../api.js"; +import { csrfFetch } from "../../../common/js/csrfFetch.js"; let airlineMapPromise = null; @@ -25,7 +26,7 @@ function toAssetUrl(path) { async function getAirlineMap() { if (airlineMapPromise) return airlineMapPromise; const url = `${getContextPath()}/resources/mypage/json/airline.json`; - airlineMapPromise = fetch(url, { headers: { Accept: "application/json" } }) + airlineMapPromise = csrfFetch(url, { headers: { Accept: "application/json" } }) .then((res) => (res.ok ? res.json() : {})) .catch(() => ({})); return airlineMapPromise; diff --git a/src/main/webapp/resources/search/js/details.js b/src/main/webapp/resources/search/js/details.js index aae166f5..95028c35 100644 --- a/src/main/webapp/resources/search/js/details.js +++ b/src/main/webapp/resources/search/js/details.js @@ -27,7 +27,7 @@ async function openDetailPage(index) { const requestSeq = ++detailRequestSeq; try { - const res = await fetch(`${CONTEXT_PATH}/api/public/flights/details?cabinClass=${cabinClass}&routeType=${routeType}`); + const res = await csrfFetch(`${CONTEXT_PATH}/api/public/flights/details?cabinClass=${cabinClass}&routeType=${routeType}`); if (res.ok) { const details = await res.json(); @@ -143,4 +143,4 @@ function formatDate(dateTime) { const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2,"0"); return `${year}-${month}-${day}`; -} \ No newline at end of file +} diff --git a/src/main/webapp/resources/search/js/filtering.js b/src/main/webapp/resources/search/js/filtering.js index 2d55d8f1..09ece2dd 100644 --- a/src/main/webapp/resources/search/js/filtering.js +++ b/src/main/webapp/resources/search/js/filtering.js @@ -20,7 +20,7 @@ document.addEventListener("DOMContentLoaded", async () => { }); async function loadAirlines() { - const res = await fetch(`${CONTEXT_PATH}/api/public/airlines`); + const res = await csrfFetch(`${CONTEXT_PATH}/api/public/airlines`); const data = await res.json(); AIRLINES = data.map(a => ({ @@ -466,4 +466,4 @@ function resetFilters() { }); closeAllFilterPanels(); -} \ No newline at end of file +} diff --git a/src/main/webapp/resources/search/js/flight.js b/src/main/webapp/resources/search/js/flight.js index 3ceacff3..31ebc69b 100644 --- a/src/main/webapp/resources/search/js/flight.js +++ b/src/main/webapp/resources/search/js/flight.js @@ -285,7 +285,7 @@ async function fetchPriceHistory({ flightId, cabinClassCode, from, to }) { const url = `${CONTEXT_PATH}/api/public/flights/price-history?${params.toString()}`; - const res = await fetch(url, { headers: { "Accept": "application/json" } }); + const res = await csrfFetch(url, { headers: { "Accept": "application/json" } }); if (!res.ok) throw new Error("price-history fetch failed"); return await res.json(); // { points: [{t, price, type}, ...] } } @@ -683,10 +683,10 @@ document.getElementById("resultList").addEventListener("click", async (e) => { params.set("inFlightId", inId); } - const response = await fetch(`${CONTEXT_PATH}/api/public/flights/prices?${params}`, { + const response = await csrfFetch(`${CONTEXT_PATH}/api/public/flights/prices?${params}`, { method: 'GET', headers: { - 'Content-Type': 'application/json' + 'Accept': 'application/json' } }); @@ -719,4 +719,4 @@ document.getElementById("resultList").addEventListener("click", async (e) => { document.getElementById("reservationForm").submit(); return; } -}); \ No newline at end of file +}); diff --git a/src/main/webapp/resources/search/js/search.js b/src/main/webapp/resources/search/js/search.js index 0aeefa11..2343f128 100644 --- a/src/main/webapp/resources/search/js/search.js +++ b/src/main/webapp/resources/search/js/search.js @@ -3,10 +3,8 @@ let ARR_AIRPORTS = []; let allOptions = []; let displayedOptions = []; let details = {}; -const csrfFetch = window.csrfFetch || ((input, init = {}) => fetch(input, { credentials: "same-origin", ...init })); - async function loadDepAirports() { - const res = await fetch(`${CONTEXT_PATH}/api/public/depAirports`); + const res = await csrfFetch(`${CONTEXT_PATH}/api/public/depAirports`); const data = await res.json(); DEP_AIRPORTS = data.map(a => ({ code: a.airportId, @@ -241,7 +239,7 @@ function setFieldText(fieldName, mainText, hintText = "") { async function loadArrAirports(depCode) { if (!depCode) return; - const res = await fetch( + const res = await csrfFetch( `${CONTEXT_PATH}/api/public/arrAirports?depAirport=${depCode}` ); const data = await res.json(); diff --git a/src/main/webapp/resources/seat/js/seat-api.js b/src/main/webapp/resources/seat/js/seat-api.js index f6a35b0c..892a27b0 100644 --- a/src/main/webapp/resources/seat/js/seat-api.js +++ b/src/main/webapp/resources/seat/js/seat-api.js @@ -1,4 +1,5 @@ (function (global) { + async function safeJson(res) { const ct = res.headers.get("content-type") || ""; const text = await res.text(); @@ -16,13 +17,13 @@ } function fetchSeatMap(ctx, reservationId, segmentId) { - return fetch(`${ctx}/api/seats/reservations/${encodeURIComponent(reservationId)}/segments/${encodeURIComponent(segmentId)}`, { + return csrfFetch(`${ctx}/api/seats/reservations/${encodeURIComponent(reservationId)}/segments/${encodeURIComponent(segmentId)}`, { credentials: 'include' // 쿠키 포함 (JWT 인증용) }).then(safeJson).then(d => d?.data ?? []); } function holdSeat(ctx, reservationId, segmentId, body) { - return fetch(`${ctx}/api/seats/reservations/${encodeURIComponent(reservationId)}/segments/${encodeURIComponent(segmentId)}/hold`, + return csrfFetch(`${ctx}/api/seats/reservations/${encodeURIComponent(reservationId)}/segments/${encodeURIComponent(segmentId)}/hold`, { method: "POST", headers: { "Content-Type": "application/json", "Accept": "application/json" }, @@ -31,7 +32,7 @@ }).then(safeJson); } function releaseHold(ctx, reservationId, segmentId, passengerId) { - return fetch(`${ctx}/api/seats/reservations/${encodeURIComponent(reservationId)}/segments/${encodeURIComponent(segmentId)}/hold/${encodeURIComponent(passengerId)}`, { + return csrfFetch(`${ctx}/api/seats/reservations/${encodeURIComponent(reservationId)}/segments/${encodeURIComponent(segmentId)}/hold/${encodeURIComponent(passengerId)}`, { method: "DELETE", headers: { "Accept": "application/json" }, credentials: 'include' diff --git a/src/main/webapp/resources/signup/js/signup.js b/src/main/webapp/resources/signup/js/signup.js index 3804f56e..9069dbe0 100644 --- a/src/main/webapp/resources/signup/js/signup.js +++ b/src/main/webapp/resources/signup/js/signup.js @@ -243,7 +243,7 @@ if (verifySentBox) verifySentBox.classList.add("hidden"); try { - const res = await fetch(`${contextPath}/api/auth/email/issue`, { + const res = await csrfFetch(`${contextPath}/api/auth/email/issue`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", @@ -321,7 +321,7 @@ email, attemptId: attemptIdHidden?.value || "", }); - const res = await fetch(`${contextPath}/api/auth/email/status?${query.toString()}`); + const res = await csrfFetch(`${contextPath}/api/auth/email/status?${query.toString()}`); if (!res.ok) { if (verifyErrorStatus) { @@ -507,7 +507,7 @@ if (smsErrorStatus) smsErrorStatus.classList.add("hidden"); try { - const res = await fetch(`${contextPath}/api/sms/send?phoneNumber=${encodeURIComponent(phone)}`, { + const res = await csrfFetch(`${contextPath}/api/sms/send?phoneNumber=${encodeURIComponent(phone)}`, { method: "POST" }); const data = await res.json(); @@ -560,7 +560,7 @@ try { const params = new URLSearchParams({ phoneNumber: phone, code: code }); - const res = await fetch(`${contextPath}/api/sms/verify?${params.toString()}`, { + const res = await csrfFetch(`${contextPath}/api/sms/verify?${params.toString()}`, { method: "POST" }); const data = await res.json(); From 8a0d02fd22b561b3dbb0c89465813c3a605cb891 Mon Sep 17 00:00:00 2001 From: gaeunnlee Date: Wed, 11 Feb 2026 16:28:33 +0900 Subject: [PATCH 12/18] =?UTF-8?q?FLYW-140=20feat:=20OriginRefererCheckFilt?= =?UTF-8?q?er=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20=EC=A0=81=EC=9A=A9=20(#10?= =?UTF-8?q?0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/config/SecurityConfigApi.java | 3 + .../security/config/SecurityConfigWeb.java | 3 + .../filter/OriginRefererCheckFilter.java | 203 ++++++++++++++++++ .../filter/OriginRefererCheckFilterTest.java | 159 ++++++++++++++ 4 files changed, 368 insertions(+) create mode 100644 src/main/java/com/flyway/security/filter/OriginRefererCheckFilter.java create mode 100644 src/test/java/com/flyway/security/filter/OriginRefererCheckFilterTest.java diff --git a/src/main/java/com/flyway/security/config/SecurityConfigApi.java b/src/main/java/com/flyway/security/config/SecurityConfigApi.java index 640a0b99..3c026a65 100644 --- a/src/main/java/com/flyway/security/config/SecurityConfigApi.java +++ b/src/main/java/com/flyway/security/config/SecurityConfigApi.java @@ -3,6 +3,7 @@ import com.flyway.security.handler.JwtAccessDeniedHandler; import com.flyway.security.handler.JwtAuthenticationEntryPoint; import com.flyway.security.filter.OnboardingAccessFilter; +import com.flyway.security.filter.OriginRefererCheckFilter; import com.flyway.security.jwt.JwtApiAuthFilter; import com.flyway.security.jwt.JwtProvider; import org.springframework.beans.factory.annotation.Qualifier; @@ -16,6 +17,7 @@ import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.csrf.CsrfFilter; import org.springframework.security.web.csrf.CookieCsrfTokenRepository; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; @@ -89,6 +91,7 @@ protected void configure(HttpSecurity http) throws Exception { .anyRequest().authenticated() .and() + .addFilterBefore(OriginRefererCheckFilter.forApi(), CsrfFilter.class) .addFilterBefore(jwtApiAuthFilter(), UsernamePasswordAuthenticationFilter.class) .addFilterAfter(new OnboardingAccessFilter(), JwtApiAuthFilter.class); } diff --git a/src/main/java/com/flyway/security/config/SecurityConfigWeb.java b/src/main/java/com/flyway/security/config/SecurityConfigWeb.java index d8952f44..97d6b0d4 100644 --- a/src/main/java/com/flyway/security/config/SecurityConfigWeb.java +++ b/src/main/java/com/flyway/security/config/SecurityConfigWeb.java @@ -4,6 +4,7 @@ import com.flyway.auth.repository.RefreshTokenRepository; import com.flyway.auth.util.TokenHasher; import com.flyway.security.filter.OnboardingAccessFilter; +import com.flyway.security.filter.OriginRefererCheckFilter; import com.flyway.security.filter.RefreshTokenSessionSyncFilter; import com.flyway.security.handler.JwtAuthenticationEntryPoint; import com.flyway.security.handler.LoginSuccessHandler; @@ -24,6 +25,7 @@ import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.logout.LogoutHandler; +import org.springframework.security.web.csrf.CsrfFilter; import org.springframework.security.web.csrf.CookieCsrfTokenRepository; import java.util.ArrayList; @@ -153,6 +155,7 @@ protected void configure(HttpSecurity http) throws Exception { .permitAll() .and() + .addFilterBefore(OriginRefererCheckFilter.forWeb(), CsrfFilter.class) .addFilterBefore(jwtWebAuthFilter(), UsernamePasswordAuthenticationFilter.class) .addFilterAfter(refreshTokenSessionSyncFilter(), JwtWebAuthFilter.class) .addFilterAfter(new OnboardingAccessFilter(), RefreshTokenSessionSyncFilter.class); diff --git a/src/main/java/com/flyway/security/filter/OriginRefererCheckFilter.java b/src/main/java/com/flyway/security/filter/OriginRefererCheckFilter.java new file mode 100644 index 00000000..e853c2f6 --- /dev/null +++ b/src/main/java/com/flyway/security/filter/OriginRefererCheckFilter.java @@ -0,0 +1,203 @@ +package com.flyway.security.filter; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.lang.NonNull; +import org.springframework.util.StringUtils; +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 java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +@Slf4j +public class OriginRefererCheckFilter extends OncePerRequestFilter { + + private static final Set STATE_CHANGING_METHODS = + new LinkedHashSet<>(Arrays.asList("POST", "PUT", "PATCH", "DELETE")); + + private static final List DEFAULT_ALLOWED_ORIGINS = Arrays.asList( + "https://flyway.kr", + "http://localhost:8080" + ); + + private final Set allowedOrigins; + private final List includeBasePaths; + private final List excludeBasePaths; + private final Set excludeExactPaths; + + public OriginRefererCheckFilter( + Collection allowedOrigins, + Collection includeBasePaths, + Collection excludeBasePaths, + Collection excludeExactPaths + ) { + this.allowedOrigins = normalizeOrigins(allowedOrigins); + this.includeBasePaths = normalizePaths(includeBasePaths); + this.excludeBasePaths = normalizePaths(excludeBasePaths); + this.excludeExactPaths = new LinkedHashSet<>(); + if (excludeExactPaths != null) { + for (String p : excludeExactPaths) { + if (StringUtils.hasText(p)) { + this.excludeExactPaths.add(normalizePath(p)); + } + } + } + } + + public static OriginRefererCheckFilter forApi() { + return new OriginRefererCheckFilter( + DEFAULT_ALLOWED_ORIGINS, + Arrays.asList("/api"), + null, + null + ); + } + + public static OriginRefererCheckFilter forWeb() { + return new OriginRefererCheckFilter( + DEFAULT_ALLOWED_ORIGINS, + Arrays.asList("/mypage", "/reservations", "/payment", "/payments"), + Arrays.asList("/oauth", "/auth"), + Arrays.asList("/loginProc") + ); + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String method = request.getMethod(); + if (!StringUtils.hasText(method)) return true; + + String upperMethod = method.toUpperCase(); + if ("OPTIONS".equals(upperMethod)) return true; + if (!STATE_CHANGING_METHODS.contains(upperMethod)) return true; + + String path = resolvePath(request); + if (!isIncludedPath(path)) return true; + return isExcludedPath(path); + } + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain + ) throws ServletException, IOException { + String origin = trimToNull(request.getHeader("Origin")); + String referer = trimToNull(request.getHeader("Referer")); + + boolean allowed; + if (StringUtils.hasText(origin)) { + allowed = isAllowedOrigin(origin); + } else { + allowed = isAllowedReferer(referer); + } + + if (!allowed) { + log.warn("[OriginRefererCheck] blocked. method={}, requestURI={}, origin={}, referer={}", + request.getMethod(), request.getRequestURI(), origin, referer); + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType("text/plain;charset=UTF-8"); + response.getWriter().write("Forbidden"); + return; + } + + filterChain.doFilter(request, response); + } + + private boolean isAllowedOrigin(String origin) { + if (!StringUtils.hasText(origin)) return false; + return allowedOrigins.contains(normalizeOrigin(origin)); + } + + private boolean isAllowedReferer(String referer) { + if (!StringUtils.hasText(referer)) return false; + for (String allowedOrigin : allowedOrigins) { + if (referer.equals(allowedOrigin) || referer.startsWith(allowedOrigin + "/")) { + return true; + } + } + return false; + } + + private boolean isIncludedPath(String path) { + for (String basePath : includeBasePaths) { + if (matchesBasePath(path, basePath)) return true; + } + return false; + } + + private boolean isExcludedPath(String path) { + if (excludeExactPaths.contains(path)) return true; + for (String basePath : excludeBasePaths) { + if (matchesBasePath(path, basePath)) return true; + } + return false; + } + + private boolean matchesBasePath(String path, String basePath) { + return path.equals(basePath) || path.startsWith(basePath + "/"); + } + + private String resolvePath(HttpServletRequest request) { + String uri = request.getRequestURI(); + String contextPath = request.getContextPath(); + if (StringUtils.hasText(contextPath) && uri.startsWith(contextPath)) { + return uri.substring(contextPath.length()); + } + return uri; + } + + private Set normalizeOrigins(Collection origins) { + Set normalized = new LinkedHashSet<>(); + if (origins == null) return normalized; + for (String origin : origins) { + if (StringUtils.hasText(origin)) { + normalized.add(normalizeOrigin(origin)); + } + } + return normalized; + } + + private List normalizePaths(Collection paths) { + List normalized = new ArrayList<>(); + if (paths == null) return normalized; + for (String p : paths) { + if (StringUtils.hasText(p)) { + normalized.add(normalizePath(p)); + } + } + return normalized; + } + + private String normalizeOrigin(String origin) { + String value = origin.trim(); + if (value.endsWith("/")) { + return value.substring(0, value.length() - 1); + } + return value; + } + + private String normalizePath(String path) { + String value = path.trim(); + if (!value.startsWith("/")) { + value = "/" + value; + } + while (value.length() > 1 && value.endsWith("/")) { + value = value.substring(0, value.length() - 1); + } + return value; + } + + private String trimToNull(String value) { + if (!StringUtils.hasText(value)) return null; + return value.trim(); + } +} diff --git a/src/test/java/com/flyway/security/filter/OriginRefererCheckFilterTest.java b/src/test/java/com/flyway/security/filter/OriginRefererCheckFilterTest.java new file mode 100644 index 00000000..f801af08 --- /dev/null +++ b/src/test/java/com/flyway/security/filter/OriginRefererCheckFilterTest.java @@ -0,0 +1,159 @@ +package com.flyway.security.filter; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +class OriginRefererCheckFilterTest { + + @Test + @DisplayName("API POST + 허용 Origin이면 통과한다") + void apiPost_withAllowedOrigin_passes() throws Exception { + OriginRefererCheckFilter filter = OriginRefererCheckFilter.forApi(); + MockFilterChain chain = new MockFilterChain(); + MockHttpServletRequest request = apiRequest("POST", "/api/auth/refresh"); + MockHttpServletResponse response = new MockHttpServletResponse(); + request.addHeader("Origin", requestOrigin(request)); + + filter.doFilter(request, response, chain); + + assertNotNull(chain.getRequest()); + } + + @Test + @DisplayName("API POST + 비허용 Origin이면 403 차단한다") + void apiPost_withDisallowedOrigin_blocks() throws Exception { + OriginRefererCheckFilter filter = OriginRefererCheckFilter.forApi(); + MockFilterChain chain = new MockFilterChain(); + MockHttpServletRequest request = apiRequest("POST", "/api/auth/refresh"); + MockHttpServletResponse response = new MockHttpServletResponse(); + request.addHeader("Origin", "https://evil.com"); + + filter.doFilter(request, response, chain); + + assertEquals(403, response.getStatus()); + assertNull(chain.getRequest()); + } + + @Test + @DisplayName("API POST + Origin 없고 허용 Referer면 통과한다") + void apiPost_withAllowedReferer_passes() throws Exception { + OriginRefererCheckFilter filter = OriginRefererCheckFilter.forApi(); + MockFilterChain chain = new MockFilterChain(); + MockHttpServletRequest request = apiRequest("POST", "/api/payments/confirm"); + MockHttpServletResponse response = new MockHttpServletResponse(); + request.addHeader("Referer", requestAppBase(request) + "/reservations/abc/booking"); + + filter.doFilter(request, response, chain); + + assertNotNull(chain.getRequest()); + } + + @Test + @DisplayName("API POST + Origin/Referer 모두 없으면 403 차단한다") + void apiPost_withoutOriginAndReferer_blocks() throws Exception { + OriginRefererCheckFilter filter = OriginRefererCheckFilter.forApi(); + MockFilterChain chain = new MockFilterChain(); + MockHttpServletRequest request = apiRequest("POST", "/api/auth/refresh"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, chain); + + assertEquals(403, response.getStatus()); + assertNull(chain.getRequest()); + } + + @Test + @DisplayName("OPTIONS 요청은 무조건 통과한다") + void optionsRequest_alwaysPasses() throws Exception { + OriginRefererCheckFilter filter = OriginRefererCheckFilter.forApi(); + MockFilterChain chain = new MockFilterChain(); + MockHttpServletRequest request = apiRequest("OPTIONS", "/api/auth/refresh"); + MockHttpServletResponse response = new MockHttpServletResponse(); + request.addHeader("Origin", "https://evil.com"); + + filter.doFilter(request, response, chain); + + assertNotNull(chain.getRequest()); + } + + @Test + @DisplayName("Web 필터는 비대상 경로에서는 동작하지 않는다") + void webFilter_nonTargetPath_isSkipped() throws Exception { + OriginRefererCheckFilter filter = OriginRefererCheckFilter.forWeb(); + MockFilterChain chain = new MockFilterChain(); + MockHttpServletRequest request = apiRequest("POST", "/search/flights"); + MockHttpServletResponse response = new MockHttpServletResponse(); + request.addHeader("Origin", "https://evil.com"); + + filter.doFilter(request, response, chain); + + assertNotNull(chain.getRequest()); + } + + @Test + @DisplayName("Web 필터는 대상 경로의 비허용 Origin을 403 차단한다") + void webFilter_targetPath_disallowedOrigin_blocks() throws Exception { + OriginRefererCheckFilter filter = OriginRefererCheckFilter.forWeb(); + MockFilterChain chain = new MockFilterChain(); + MockHttpServletRequest request = apiRequest("POST", "/reservations/abc/passengers/api"); + MockHttpServletResponse response = new MockHttpServletResponse(); + request.addHeader("Origin", "https://evil.com"); + + filter.doFilter(request, response, chain); + + assertEquals(403, response.getStatus()); + assertNull(chain.getRequest()); + } + + @Test + @DisplayName("Web 필터는 예외 경로(/auth/**)는 검사하지 않는다") + void webFilter_excludedPath_isSkipped() throws Exception { + OriginRefererCheckFilter filter = OriginRefererCheckFilter.forWeb(); + MockFilterChain chain = new MockFilterChain(); + MockHttpServletRequest request = apiRequest("POST", "/auth/refresh"); + MockHttpServletResponse response = new MockHttpServletResponse(); + request.addHeader("Origin", "https://evil.com"); + + filter.doFilter(request, response, chain); + + assertNotNull(chain.getRequest()); + } + + @Test + @DisplayName("contextPath가 있어도 경로 판별이 정상 동작한다") + void resolvesPathWithContextPath() throws Exception { + OriginRefererCheckFilter filter = OriginRefererCheckFilter.forApi(); + MockFilterChain chain = new MockFilterChain(); + MockHttpServletRequest request = apiRequest("POST", "/flyway/api/auth/refresh"); + MockHttpServletResponse response = new MockHttpServletResponse(); + request.setContextPath("/flyway"); + request.addHeader("Origin", requestOrigin(request)); + + filter.doFilter(request, response, chain); + + assertNotNull(chain.getRequest()); + } + + private MockHttpServletRequest apiRequest(String method, String requestUri) { + MockHttpServletRequest request = new MockHttpServletRequest(method, requestUri); + request.setScheme("http"); + request.setServerName("localhost"); + request.setServerPort(8080); + return request; + } + + private String requestOrigin(MockHttpServletRequest request) { + return request.getScheme() + "://" + request.getServerName() + ":" + request.getServerPort(); + } + + private String requestAppBase(MockHttpServletRequest request) { + return requestOrigin(request) + request.getContextPath(); + } +} From 0ae6e580a11e087f591037ba3bc8300368e0cced Mon Sep 17 00:00:00 2001 From: gaeunnlee Date: Wed, 11 Feb 2026 16:51:46 +0900 Subject: [PATCH 13/18] =?UTF-8?q?FLYW-140=20test:=20CSRF=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EA=B2=80=EC=A6=9D=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?(#100)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/filter/CsrfProtectionTest.java | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 src/test/java/com/flyway/security/filter/CsrfProtectionTest.java diff --git a/src/test/java/com/flyway/security/filter/CsrfProtectionTest.java b/src/test/java/com/flyway/security/filter/CsrfProtectionTest.java new file mode 100644 index 00000000..11911a6b --- /dev/null +++ b/src/test/java/com/flyway/security/filter/CsrfProtectionTest.java @@ -0,0 +1,84 @@ +package com.flyway.security.filter; + +import com.flyway.auth.controller.AuthController; +import com.flyway.auth.service.AuthTokenService; +import com.flyway.auth.service.KakaoLoginService; +import com.flyway.auth.service.SignUpService; +import com.flyway.security.service.EmailUserDetailsService; +import com.flyway.security.service.UserIdUserDetailsService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.web.csrf.CookieCsrfTokenRepository; +import org.springframework.security.web.csrf.CsrfFilter; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import javax.servlet.http.Cookie; + +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class CsrfProtectionTest { + + private MockMvc mockMvc; + private AuthTokenService authTokenService; + private CookieCsrfTokenRepository csrfTokenRepository; + + @BeforeEach + void setUp() { + SignUpService signUpService = mock(SignUpService.class); + KakaoLoginService kakaoLoginService = mock(KakaoLoginService.class); + EmailUserDetailsService emailUserDetailsService = mock(EmailUserDetailsService.class); + UserIdUserDetailsService userIdUserDetailsService = mock(UserIdUserDetailsService.class); + authTokenService = mock(AuthTokenService.class); + + AuthController controller = new AuthController( + signUpService, + kakaoLoginService, + emailUserDetailsService, + userIdUserDetailsService, + authTokenService + ); + + csrfTokenRepository = CookieCsrfTokenRepository.withHttpOnlyFalse(); + CsrfFilter csrfFilter = new CsrfFilter(csrfTokenRepository); + + mockMvc = MockMvcBuilders.standaloneSetup(controller) + .addFilters(csrfFilter) + .build(); + } + + @Test + @DisplayName("CSRF 토큰 없이 POST /auth/refresh 요청하면 403을 반환한다") + void refresh_withoutCsrfToken_returnsForbidden() throws Exception { + mockMvc.perform(post("/auth/refresh")) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("유효한 CSRF 쿠키+헤더로 POST /auth/refresh 요청하면 통과한다") + void refresh_withValidCsrfToken_passes() throws Exception { + doNothing().when(authTokenService).refresh(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); + + String token = newTokenValue(); + + mockMvc.perform(post("/auth/refresh") + .cookie(new Cookie("XSRF-TOKEN", token)) + .header("X-XSRF-TOKEN", token)) + .andExpect(status().isNoContent()); + + verify(authTokenService).refresh(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); + } + + private String newTokenValue() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/auth/csrf"); + CsrfToken token = csrfTokenRepository.generateToken(request); + return token.getToken(); + } +} From 41105e2a668a6e0e5b1bbf49c33c06c3453f018f Mon Sep 17 00:00:00 2001 From: gaeunnlee Date: Wed, 11 Feb 2026 17:47:08 +0900 Subject: [PATCH 14/18] =?UTF-8?q?FLYW-140=20fix:=20csrfFetch=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20=EB=A0=88=EC=9D=B4=EC=8A=A4=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80=20=EB=B0=8F=20=EB=B3=B4=EC=95=88=20=ED=95=84=ED=84=B0?= =?UTF-8?q?=20=EC=A0=95=EA=B7=9C=ED=99=94=20(#100)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/service/AuthTokenServiceImpl.java | 1 - .../filter/OriginRefererCheckFilter.java | 7 ++-- .../flyway/security/jwt/JwtWebAuthFilter.java | 2 +- .../webapp/resources/common/js/authFetch.js | 1 - .../webapp/resources/common/js/csrfFetch.js | 32 +++++++++++++++++++ .../handler/LoginSuccessHandlerTest.java | 5 --- 6 files changed, 37 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/flyway/auth/service/AuthTokenServiceImpl.java b/src/main/java/com/flyway/auth/service/AuthTokenServiceImpl.java index 3af13b5b..ea23b841 100644 --- a/src/main/java/com/flyway/auth/service/AuthTokenServiceImpl.java +++ b/src/main/java/com/flyway/auth/service/AuthTokenServiceImpl.java @@ -16,7 +16,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; -import org.springframework.security.core.context.SecurityContextHolder; import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletRequest; diff --git a/src/main/java/com/flyway/security/filter/OriginRefererCheckFilter.java b/src/main/java/com/flyway/security/filter/OriginRefererCheckFilter.java index e853c2f6..d516b6a2 100644 --- a/src/main/java/com/flyway/security/filter/OriginRefererCheckFilter.java +++ b/src/main/java/com/flyway/security/filter/OriginRefererCheckFilter.java @@ -15,6 +15,7 @@ import java.util.Collection; import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; import java.util.Set; @Slf4j @@ -179,10 +180,10 @@ private List normalizePaths(Collection paths) { private String normalizeOrigin(String origin) { String value = origin.trim(); - if (value.endsWith("/")) { - return value.substring(0, value.length() - 1); + while (value.length() > 1 && value.endsWith("/")) { + value = value.substring(0, value.length() - 1); } - return value; + return value.toLowerCase(Locale.ROOT); } private String normalizePath(String path) { diff --git a/src/main/java/com/flyway/security/jwt/JwtWebAuthFilter.java b/src/main/java/com/flyway/security/jwt/JwtWebAuthFilter.java index 117f0136..8dd614aa 100644 --- a/src/main/java/com/flyway/security/jwt/JwtWebAuthFilter.java +++ b/src/main/java/com/flyway/security/jwt/JwtWebAuthFilter.java @@ -50,7 +50,7 @@ protected void doFilterInternal( ) throws ServletException, IOException { String path = resolvePath(request); - if (path.startsWith("/login")) { + if (path.equals("/login") || path.startsWith("/login/")) { filterChain.doFilter(request, response); return; } diff --git a/src/main/webapp/resources/common/js/authFetch.js b/src/main/webapp/resources/common/js/authFetch.js index a6c3124a..efbb3f8a 100644 --- a/src/main/webapp/resources/common/js/authFetch.js +++ b/src/main/webapp/resources/common/js/authFetch.js @@ -38,7 +38,6 @@ function toFetchUrl(input) { export async function fetchWithRefresh(input, init = {}) { const doFetch = async (overrideInit = {}) => { const merged = { ...init, ...overrideInit }; - // csrfFetch가 GET/POST 알아서 처리함 return csrfFetch(input, merged); }; diff --git a/src/main/webapp/resources/common/js/csrfFetch.js b/src/main/webapp/resources/common/js/csrfFetch.js index 603e55bb..223de479 100644 --- a/src/main/webapp/resources/common/js/csrfFetch.js +++ b/src/main/webapp/resources/common/js/csrfFetch.js @@ -4,6 +4,31 @@ const CONFIG = { CSRF_BOOTSTRAP_URL: "/auth/csrf", }; +let csrfFetchReady = false; +const pendingCsrfFetchCalls = []; + +function enqueueCsrfFetchCall(args) { + return new Promise((resolve, reject) => { + pendingCsrfFetchCalls.push({ args, resolve, reject }); + }); +} + +function flushQueuedCsrfFetchCalls() { + while (pendingCsrfFetchCalls.length > 0) { + const call = pendingCsrfFetchCalls.shift(); + csrfFetch(...call.args).then(call.resolve).catch(call.reject); + } +} + +if (typeof window !== "undefined") { + window.csrfFetch = (...args) => { + if (csrfFetchReady) { + return csrfFetch(...args); + } + return enqueueCsrfFetchCall(args); + }; +} + function getBasePath() { return window.APP?.contextPath ?? ""; } @@ -94,3 +119,10 @@ export async function csrfFetch(input, init = {}) { return fetch(toFetchUrl(input), withCsrfHeader(merged)); } + +csrfFetchReady = true; + +if (typeof window !== "undefined") { + window.csrfFetch = (...args) => csrfFetch(...args); + flushQueuedCsrfFetchCalls(); +} diff --git a/src/test/java/com/flyway/security/handler/LoginSuccessHandlerTest.java b/src/test/java/com/flyway/security/handler/LoginSuccessHandlerTest.java index add51c38..d707c0a2 100644 --- a/src/test/java/com/flyway/security/handler/LoginSuccessHandlerTest.java +++ b/src/test/java/com/flyway/security/handler/LoginSuccessHandlerTest.java @@ -1,11 +1,8 @@ package com.flyway.security.handler; import com.flyway.auth.service.AuthTokenService; -import com.flyway.security.jwt.JwtProperties; -import com.flyway.security.jwt.JwtProvider; 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.MockHttpServletResponse; import org.springframework.security.core.Authentication; @@ -13,8 +10,6 @@ import org.springframework.security.core.userdetails.UserDetails; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; From e80f0eac77d78bf9c2711b89ff644e5fd42f0f04 Mon Sep 17 00:00:00 2001 From: gaeunnlee Date: Fri, 13 Feb 2026 12:02:42 +0900 Subject: [PATCH 15/18] =?UTF-8?q?FLYW-140=20feat:=20=ED=97=88=EC=9A=A9=20O?= =?UTF-8?q?rigin=20URI=20=ED=99=98=EA=B2=BD=EB=B3=84=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20(#100)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/service/AuthTokenServiceImpl.java | 1 + .../security/config/SecurityConfigApi.java | 10 ++++- .../security/config/SecurityConfigWeb.java | 10 ++++- .../config/SecurityOriginProperties.java | 43 +++++++++++++++++++ .../filter/OriginRefererCheckFilter.java | 13 ++---- .../flyway/security/jwt/JwtWebAuthFilter.java | 2 +- .../config/application-prod.properties | 3 ++ .../filter/OriginRefererCheckFilterTest.java | 33 ++++++++++---- 8 files changed, 94 insertions(+), 21 deletions(-) create mode 100644 src/main/java/com/flyway/security/config/SecurityOriginProperties.java diff --git a/src/main/java/com/flyway/auth/service/AuthTokenServiceImpl.java b/src/main/java/com/flyway/auth/service/AuthTokenServiceImpl.java index ea23b841..09a4d0fd 100644 --- a/src/main/java/com/flyway/auth/service/AuthTokenServiceImpl.java +++ b/src/main/java/com/flyway/auth/service/AuthTokenServiceImpl.java @@ -159,6 +159,7 @@ public void logout(HttpServletRequest request, HttpServletResponse response) { } @Override + @Transactional public void forceLogout(HttpServletRequest request, HttpServletResponse response) { try { logout(request, response); diff --git a/src/main/java/com/flyway/security/config/SecurityConfigApi.java b/src/main/java/com/flyway/security/config/SecurityConfigApi.java index 3c026a65..895c01a4 100644 --- a/src/main/java/com/flyway/security/config/SecurityConfigApi.java +++ b/src/main/java/com/flyway/security/config/SecurityConfigApi.java @@ -30,16 +30,19 @@ public class SecurityConfigApi extends WebSecurityConfigurerAdapter { private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; private final JwtAccessDeniedHandler jwtAccessDeniedHandler; private final UserDetailsService userIdUserDetailsService; + private final SecurityOriginProperties securityOriginProperties; public SecurityConfigApi( JwtProvider jwtProvider, JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint, JwtAccessDeniedHandler jwtAccessDeniedHandler, + SecurityOriginProperties securityOriginProperties, @Qualifier("userIdUserDetailsService") UserDetailsService userIdUserDetailsService ) { this.jwtProvider = jwtProvider; this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint; this.jwtAccessDeniedHandler = jwtAccessDeniedHandler; + this.securityOriginProperties = securityOriginProperties; this.userIdUserDetailsService = userIdUserDetailsService; } @@ -52,6 +55,11 @@ public JwtApiAuthFilter jwtApiAuthFilter() { ); } + @Bean + public OriginRefererCheckFilter apiOriginRefererCheckFilter() { + return OriginRefererCheckFilter.forApi(securityOriginProperties.getAllowedOrigins()); + } + @Override protected void configure(HttpSecurity http) throws Exception { http @@ -91,7 +99,7 @@ protected void configure(HttpSecurity http) throws Exception { .anyRequest().authenticated() .and() - .addFilterBefore(OriginRefererCheckFilter.forApi(), CsrfFilter.class) + .addFilterBefore(apiOriginRefererCheckFilter(), CsrfFilter.class) .addFilterBefore(jwtApiAuthFilter(), UsernamePasswordAuthenticationFilter.class) .addFilterAfter(new OnboardingAccessFilter(), JwtApiAuthFilter.class); } diff --git a/src/main/java/com/flyway/security/config/SecurityConfigWeb.java b/src/main/java/com/flyway/security/config/SecurityConfigWeb.java index 97d6b0d4..ceb0c500 100644 --- a/src/main/java/com/flyway/security/config/SecurityConfigWeb.java +++ b/src/main/java/com/flyway/security/config/SecurityConfigWeb.java @@ -56,6 +56,7 @@ public class SecurityConfigWeb extends WebSecurityConfigurerAdapter { private final AuthTokenService authTokenService; private final RefreshTokenRepository refreshTokenRepository; private final TokenHasher tokenHasher; + private final SecurityOriginProperties securityOriginProperties; public SecurityConfigWeb( JwtProvider jwtProvider, @@ -66,6 +67,7 @@ public SecurityConfigWeb( AuthTokenService authTokenService, RefreshTokenRepository refreshTokenRepository, TokenHasher tokenHasher, + SecurityOriginProperties securityOriginProperties, @Qualifier("userIdUserDetailsService") UserDetailsService userIdUserDetailsService, @Qualifier("emailUserDetailsService") UserDetailsService emailUserDetailsService ) { @@ -77,6 +79,7 @@ public SecurityConfigWeb( this.authTokenService = authTokenService; this.refreshTokenRepository = refreshTokenRepository; this.tokenHasher = tokenHasher; + this.securityOriginProperties = securityOriginProperties; this.userIdUserDetailsService = userIdUserDetailsService; this.emailUserDetailsService = emailUserDetailsService; } @@ -103,6 +106,11 @@ public RefreshTokenSessionSyncFilter refreshTokenSessionSyncFilter() { ); } + @Bean + public OriginRefererCheckFilter webOriginRefererCheckFilter() { + return OriginRefererCheckFilter.forWeb(securityOriginProperties.getAllowedOrigins()); + } + @Bean public AuthenticationFailureHandler loginFailureHandler() { return (req, res, ex) -> { @@ -155,7 +163,7 @@ protected void configure(HttpSecurity http) throws Exception { .permitAll() .and() - .addFilterBefore(OriginRefererCheckFilter.forWeb(), CsrfFilter.class) + .addFilterBefore(webOriginRefererCheckFilter(), CsrfFilter.class) .addFilterBefore(jwtWebAuthFilter(), UsernamePasswordAuthenticationFilter.class) .addFilterAfter(refreshTokenSessionSyncFilter(), JwtWebAuthFilter.class) .addFilterAfter(new OnboardingAccessFilter(), RefreshTokenSessionSyncFilter.class); diff --git a/src/main/java/com/flyway/security/config/SecurityOriginProperties.java b/src/main/java/com/flyway/security/config/SecurityOriginProperties.java new file mode 100644 index 00000000..fd05aae2 --- /dev/null +++ b/src/main/java/com/flyway/security/config/SecurityOriginProperties.java @@ -0,0 +1,43 @@ +package com.flyway.security.config; + +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +@Getter +@Component +public class SecurityOriginProperties { + + private static final String FALLBACK_ALLOWED_ORIGIN = "https://flyway.kr"; + + private final List allowedOrigins; + + public SecurityOriginProperties(@Value("${security.allowed-origins:}") String allowedOriginsRaw) { + this.allowedOrigins = parseAllowedOrigins(allowedOriginsRaw); + } + + private List parseAllowedOrigins(String raw) { + Set values = new LinkedHashSet<>(); + if (StringUtils.hasText(raw)) { + String[] tokens = raw.split(","); + for (String token : tokens) { + if (StringUtils.hasText(token)) { + values.add(token.trim()); + } + } + } + + if (values.isEmpty()) { + values.add(FALLBACK_ALLOWED_ORIGIN); + } + + return Collections.unmodifiableList(new ArrayList<>(values)); + } +} diff --git a/src/main/java/com/flyway/security/filter/OriginRefererCheckFilter.java b/src/main/java/com/flyway/security/filter/OriginRefererCheckFilter.java index d516b6a2..e02e5f20 100644 --- a/src/main/java/com/flyway/security/filter/OriginRefererCheckFilter.java +++ b/src/main/java/com/flyway/security/filter/OriginRefererCheckFilter.java @@ -24,11 +24,6 @@ public class OriginRefererCheckFilter extends OncePerRequestFilter { private static final Set STATE_CHANGING_METHODS = new LinkedHashSet<>(Arrays.asList("POST", "PUT", "PATCH", "DELETE")); - private static final List DEFAULT_ALLOWED_ORIGINS = Arrays.asList( - "https://flyway.kr", - "http://localhost:8080" - ); - private final Set allowedOrigins; private final List includeBasePaths; private final List excludeBasePaths; @@ -53,18 +48,18 @@ public OriginRefererCheckFilter( } } - public static OriginRefererCheckFilter forApi() { + public static OriginRefererCheckFilter forApi(Collection allowedOrigins) { return new OriginRefererCheckFilter( - DEFAULT_ALLOWED_ORIGINS, + allowedOrigins, Arrays.asList("/api"), null, null ); } - public static OriginRefererCheckFilter forWeb() { + public static OriginRefererCheckFilter forWeb(Collection allowedOrigins) { return new OriginRefererCheckFilter( - DEFAULT_ALLOWED_ORIGINS, + allowedOrigins, Arrays.asList("/mypage", "/reservations", "/payment", "/payments"), Arrays.asList("/oauth", "/auth"), Arrays.asList("/loginProc") diff --git a/src/main/java/com/flyway/security/jwt/JwtWebAuthFilter.java b/src/main/java/com/flyway/security/jwt/JwtWebAuthFilter.java index 8dd614aa..150378aa 100644 --- a/src/main/java/com/flyway/security/jwt/JwtWebAuthFilter.java +++ b/src/main/java/com/flyway/security/jwt/JwtWebAuthFilter.java @@ -29,7 +29,7 @@ public class JwtWebAuthFilter extends OncePerRequestFilter { private static final String ACCESS_TOKEN_COOKIE_NAME = "accessToken"; private static final String ADMIN_PREFIX = "/admin/"; - public static final String JWT_AUTHENTICATED_ATTR = "JWT_AUTHENTICATED"; + private static final String JWT_AUTHENTICATED_ATTR = "JWT_AUTHENTICATED"; private final JwtProvider jwtProvider; private final JwtAuthenticationEntryPoint entryPoint; diff --git a/src/main/resources/config/application-prod.properties b/src/main/resources/config/application-prod.properties index e407150e..875430e3 100644 --- a/src/main/resources/config/application-prod.properties +++ b/src/main/resources/config/application-prod.properties @@ -58,3 +58,6 @@ toss.payments.fail-url=https://www.flyway.kr/payments/fail sms.nurigo.api-key=${SMS_NURIGO_API_KEY} sms.nurigo.api-secret=${SMS_NURIGO_API_SECRET} sms.nurigo.sender=${SMS_NURIGO_SENDER} + +#==== Security ==== +security.allowed-origins=https://flyway.kr \ No newline at end of file diff --git a/src/test/java/com/flyway/security/filter/OriginRefererCheckFilterTest.java b/src/test/java/com/flyway/security/filter/OriginRefererCheckFilterTest.java index f801af08..cce3e82a 100644 --- a/src/test/java/com/flyway/security/filter/OriginRefererCheckFilterTest.java +++ b/src/test/java/com/flyway/security/filter/OriginRefererCheckFilterTest.java @@ -6,6 +6,9 @@ import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; +import java.util.Arrays; +import java.util.List; + import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; @@ -15,7 +18,7 @@ class OriginRefererCheckFilterTest { @Test @DisplayName("API POST + 허용 Origin이면 통과한다") void apiPost_withAllowedOrigin_passes() throws Exception { - OriginRefererCheckFilter filter = OriginRefererCheckFilter.forApi(); + OriginRefererCheckFilter filter = apiFilter(); MockFilterChain chain = new MockFilterChain(); MockHttpServletRequest request = apiRequest("POST", "/api/auth/refresh"); MockHttpServletResponse response = new MockHttpServletResponse(); @@ -29,7 +32,7 @@ void apiPost_withAllowedOrigin_passes() throws Exception { @Test @DisplayName("API POST + 비허용 Origin이면 403 차단한다") void apiPost_withDisallowedOrigin_blocks() throws Exception { - OriginRefererCheckFilter filter = OriginRefererCheckFilter.forApi(); + OriginRefererCheckFilter filter = apiFilter(); MockFilterChain chain = new MockFilterChain(); MockHttpServletRequest request = apiRequest("POST", "/api/auth/refresh"); MockHttpServletResponse response = new MockHttpServletResponse(); @@ -44,7 +47,7 @@ void apiPost_withDisallowedOrigin_blocks() throws Exception { @Test @DisplayName("API POST + Origin 없고 허용 Referer면 통과한다") void apiPost_withAllowedReferer_passes() throws Exception { - OriginRefererCheckFilter filter = OriginRefererCheckFilter.forApi(); + OriginRefererCheckFilter filter = apiFilter(); MockFilterChain chain = new MockFilterChain(); MockHttpServletRequest request = apiRequest("POST", "/api/payments/confirm"); MockHttpServletResponse response = new MockHttpServletResponse(); @@ -58,7 +61,7 @@ void apiPost_withAllowedReferer_passes() throws Exception { @Test @DisplayName("API POST + Origin/Referer 모두 없으면 403 차단한다") void apiPost_withoutOriginAndReferer_blocks() throws Exception { - OriginRefererCheckFilter filter = OriginRefererCheckFilter.forApi(); + OriginRefererCheckFilter filter = apiFilter(); MockFilterChain chain = new MockFilterChain(); MockHttpServletRequest request = apiRequest("POST", "/api/auth/refresh"); MockHttpServletResponse response = new MockHttpServletResponse(); @@ -72,7 +75,7 @@ void apiPost_withoutOriginAndReferer_blocks() throws Exception { @Test @DisplayName("OPTIONS 요청은 무조건 통과한다") void optionsRequest_alwaysPasses() throws Exception { - OriginRefererCheckFilter filter = OriginRefererCheckFilter.forApi(); + OriginRefererCheckFilter filter = apiFilter(); MockFilterChain chain = new MockFilterChain(); MockHttpServletRequest request = apiRequest("OPTIONS", "/api/auth/refresh"); MockHttpServletResponse response = new MockHttpServletResponse(); @@ -86,7 +89,7 @@ void optionsRequest_alwaysPasses() throws Exception { @Test @DisplayName("Web 필터는 비대상 경로에서는 동작하지 않는다") void webFilter_nonTargetPath_isSkipped() throws Exception { - OriginRefererCheckFilter filter = OriginRefererCheckFilter.forWeb(); + OriginRefererCheckFilter filter = webFilter(); MockFilterChain chain = new MockFilterChain(); MockHttpServletRequest request = apiRequest("POST", "/search/flights"); MockHttpServletResponse response = new MockHttpServletResponse(); @@ -100,7 +103,7 @@ void webFilter_nonTargetPath_isSkipped() throws Exception { @Test @DisplayName("Web 필터는 대상 경로의 비허용 Origin을 403 차단한다") void webFilter_targetPath_disallowedOrigin_blocks() throws Exception { - OriginRefererCheckFilter filter = OriginRefererCheckFilter.forWeb(); + OriginRefererCheckFilter filter = webFilter(); MockFilterChain chain = new MockFilterChain(); MockHttpServletRequest request = apiRequest("POST", "/reservations/abc/passengers/api"); MockHttpServletResponse response = new MockHttpServletResponse(); @@ -115,7 +118,7 @@ void webFilter_targetPath_disallowedOrigin_blocks() throws Exception { @Test @DisplayName("Web 필터는 예외 경로(/auth/**)는 검사하지 않는다") void webFilter_excludedPath_isSkipped() throws Exception { - OriginRefererCheckFilter filter = OriginRefererCheckFilter.forWeb(); + OriginRefererCheckFilter filter = webFilter(); MockFilterChain chain = new MockFilterChain(); MockHttpServletRequest request = apiRequest("POST", "/auth/refresh"); MockHttpServletResponse response = new MockHttpServletResponse(); @@ -129,7 +132,7 @@ void webFilter_excludedPath_isSkipped() throws Exception { @Test @DisplayName("contextPath가 있어도 경로 판별이 정상 동작한다") void resolvesPathWithContextPath() throws Exception { - OriginRefererCheckFilter filter = OriginRefererCheckFilter.forApi(); + OriginRefererCheckFilter filter = apiFilter(); MockFilterChain chain = new MockFilterChain(); MockHttpServletRequest request = apiRequest("POST", "/flyway/api/auth/refresh"); MockHttpServletResponse response = new MockHttpServletResponse(); @@ -156,4 +159,16 @@ private String requestOrigin(MockHttpServletRequest request) { private String requestAppBase(MockHttpServletRequest request) { return requestOrigin(request) + request.getContextPath(); } + + private OriginRefererCheckFilter apiFilter() { + return OriginRefererCheckFilter.forApi(defaultAllowedOrigins()); + } + + private OriginRefererCheckFilter webFilter() { + return OriginRefererCheckFilter.forWeb(defaultAllowedOrigins()); + } + + private List defaultAllowedOrigins() { + return Arrays.asList("https://flyway.kr", "http://localhost:8080"); + } } From 3dd952ce7997313b7123e3a8b89afa1afd508d99 Mon Sep 17 00:00:00 2001 From: gaeunnlee Date: Fri, 13 Feb 2026 12:18:37 +0900 Subject: [PATCH 16/18] =?UTF-8?q?FLYW-140=20feat:=20refresh=20token=20?= =?UTF-8?q?=EC=9E=AC=EC=82=AC=EC=9A=A9=20=EC=8B=9C=20revoke=20=ED=8A=B8?= =?UTF-8?q?=EB=9E=9C=EC=9E=AD=EC=85=98=20=EB=B6=84=EB=A6=AC=20(#100)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/service/AuthTokenServiceImpl.java | 3 +- .../RefreshTokenRevocationService.java | 21 +++++++++++++ .../config/application-prod.properties | 2 +- .../webapp/resources/common/js/csrfFetch.js | 3 +- .../service/AuthTokenServiceImplTest.java | 30 ++++++++++++++++++- 5 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/flyway/auth/service/RefreshTokenRevocationService.java diff --git a/src/main/java/com/flyway/auth/service/AuthTokenServiceImpl.java b/src/main/java/com/flyway/auth/service/AuthTokenServiceImpl.java index 09a4d0fd..1bfc9670 100644 --- a/src/main/java/com/flyway/auth/service/AuthTokenServiceImpl.java +++ b/src/main/java/com/flyway/auth/service/AuthTokenServiceImpl.java @@ -42,6 +42,7 @@ public class AuthTokenServiceImpl implements AuthTokenService { private final RefreshTokenRepository refreshTokenRepository; private final TokenHasher tokenHasher; + private final RefreshTokenRevocationService refreshTokenRevocationService; @Value("${cookie.secure:false}") private boolean cookieSecure; @@ -99,7 +100,7 @@ public void refresh(HttpServletRequest request, HttpServletResponse response) { /* 재사용 탐지 */ if (stored.getRotatedAt() != null) { - refreshTokenRepository.revokeAllByUserId(stored.getUserId(), now); + refreshTokenRevocationService.revokeAllByUserTokens(stored.getUserId(), now); forceLogout(request, response); throw new BusinessException(ErrorCode.AUTH_REFRESH_TOKEN_REUSED); } diff --git a/src/main/java/com/flyway/auth/service/RefreshTokenRevocationService.java b/src/main/java/com/flyway/auth/service/RefreshTokenRevocationService.java new file mode 100644 index 00000000..324608df --- /dev/null +++ b/src/main/java/com/flyway/auth/service/RefreshTokenRevocationService.java @@ -0,0 +1,21 @@ +package com.flyway.auth.service; + +import com.flyway.auth.repository.RefreshTokenRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +public class RefreshTokenRevocationService { + + private final RefreshTokenRepository refreshTokenRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void revokeAllByUserTokens(String userId, LocalDateTime now) { + refreshTokenRepository.revokeAllByUserId(userId, now); + } +} diff --git a/src/main/resources/config/application-prod.properties b/src/main/resources/config/application-prod.properties index 875430e3..f10a9f9d 100644 --- a/src/main/resources/config/application-prod.properties +++ b/src/main/resources/config/application-prod.properties @@ -60,4 +60,4 @@ sms.nurigo.api-secret=${SMS_NURIGO_API_SECRET} sms.nurigo.sender=${SMS_NURIGO_SENDER} #==== Security ==== -security.allowed-origins=https://flyway.kr \ No newline at end of file +security.allowed-origins=https://flyway.kr,https://www.flyway.kr \ No newline at end of file diff --git a/src/main/webapp/resources/common/js/csrfFetch.js b/src/main/webapp/resources/common/js/csrfFetch.js index 223de479..9924051e 100644 --- a/src/main/webapp/resources/common/js/csrfFetch.js +++ b/src/main/webapp/resources/common/js/csrfFetch.js @@ -16,10 +16,11 @@ function enqueueCsrfFetchCall(args) { function flushQueuedCsrfFetchCalls() { while (pendingCsrfFetchCalls.length > 0) { const call = pendingCsrfFetchCalls.shift(); - csrfFetch(...call.args).then(call.resolve).catch(call.reject); + Promise.resolve(csrfFetch(...call.args)).then(call.resolve).catch(call.reject); } } +// Early global binding: queue calls until this module finishes initialization. if (typeof window !== "undefined") { window.csrfFetch = (...args) => { if (csrfFetchReady) { diff --git a/src/test/java/com/flyway/auth/service/AuthTokenServiceImplTest.java b/src/test/java/com/flyway/auth/service/AuthTokenServiceImplTest.java index 97b40623..5191fa6f 100644 --- a/src/test/java/com/flyway/auth/service/AuthTokenServiceImplTest.java +++ b/src/test/java/com/flyway/auth/service/AuthTokenServiceImplTest.java @@ -33,6 +33,7 @@ class AuthTokenServiceImplTest { private JwtProperties jwtProperties; private RefreshTokenRepository refreshTokenRepository; private TokenHasher tokenHasher; + private RefreshTokenRevocationService refreshTokenRevocationService; private AuthTokenServiceImpl service; @BeforeEach @@ -41,12 +42,14 @@ void setUp() { jwtProperties = Mockito.mock(JwtProperties.class); refreshTokenRepository = Mockito.mock(RefreshTokenRepository.class); tokenHasher = Mockito.mock(TokenHasher.class); + refreshTokenRevocationService = Mockito.mock(RefreshTokenRevocationService.class); service = new AuthTokenServiceImpl( jwtProvider, jwtProperties, refreshTokenRepository, - tokenHasher + tokenHasher, + refreshTokenRevocationService ); } @@ -93,4 +96,29 @@ void refresh_missingCookie_throws() { .isInstanceOf(BusinessException.class) .hasMessage(ErrorCode.AUTH_REFRESH_TOKEN_MISSING.getMessage()); } + + @Test + @DisplayName("재사용 감지된 refresh token이면 REQUIRES_NEW revoke를 호출하고 예외를 던진다") + void refresh_reusedToken_callsRevocationServiceAndThrows() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setCookies(new Cookie("refreshToken", "raw-refresh")); + MockHttpServletResponse response = new MockHttpServletResponse(); + + when(tokenHasher.hash("raw-refresh")).thenReturn("hash-refresh"); + RefreshToken reused = RefreshToken.builder() + .refreshTokenId("refresh-id") + .userId("user-1") + .tokenHash("hash-refresh") + .issuedAt(LocalDateTime.now().minusMinutes(5)) + .expiresAt(LocalDateTime.now().plusMinutes(5)) + .rotatedAt(LocalDateTime.now().minusSeconds(10)) + .build(); + when(refreshTokenRepository.findByTokenHash("hash-refresh")).thenReturn(reused); + + assertThatThrownBy(() -> service.refresh(request, response)) + .isInstanceOf(BusinessException.class) + .hasMessage(ErrorCode.AUTH_REFRESH_TOKEN_REUSED.getMessage()); + + verify(refreshTokenRevocationService).revokeAllByUserTokens(eq("user-1"), any(LocalDateTime.class)); + } } From 842ebf5a7b795f272eaa350c2ae3809f1ded542d Mon Sep 17 00:00:00 2001 From: gaeunnlee Date: Fri, 13 Feb 2026 12:26:13 +0900 Subject: [PATCH 17/18] =?UTF-8?q?FLYW-140=20feat:=20Origin/Referer=20allow?= =?UTF-8?q?list=20=EB=8C=80=EC=86=8C=EB=AC=B8=EC=9E=90=20=EC=A0=95?= =?UTF-8?q?=EA=B7=9C=ED=99=94=20(#100)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/filter/OriginRefererCheckFilter.java | 7 +++++-- .../filter/OriginRefererCheckFilterTest.java | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/flyway/security/filter/OriginRefererCheckFilter.java b/src/main/java/com/flyway/security/filter/OriginRefererCheckFilter.java index e02e5f20..19b8fb8c 100644 --- a/src/main/java/com/flyway/security/filter/OriginRefererCheckFilter.java +++ b/src/main/java/com/flyway/security/filter/OriginRefererCheckFilter.java @@ -114,9 +114,12 @@ private boolean isAllowedOrigin(String origin) { } private boolean isAllowedReferer(String referer) { - if (!StringUtils.hasText(referer)) return false; + String normalizedReferer = trimToNull(referer); + if (!StringUtils.hasText(normalizedReferer)) return false; + normalizedReferer = normalizeOrigin(normalizedReferer); + for (String allowedOrigin : allowedOrigins) { - if (referer.equals(allowedOrigin) || referer.startsWith(allowedOrigin + "/")) { + if (normalizedReferer.equals(allowedOrigin) || normalizedReferer.startsWith(allowedOrigin + "/")) { return true; } } diff --git a/src/test/java/com/flyway/security/filter/OriginRefererCheckFilterTest.java b/src/test/java/com/flyway/security/filter/OriginRefererCheckFilterTest.java index cce3e82a..70975909 100644 --- a/src/test/java/com/flyway/security/filter/OriginRefererCheckFilterTest.java +++ b/src/test/java/com/flyway/security/filter/OriginRefererCheckFilterTest.java @@ -58,6 +58,20 @@ void apiPost_withAllowedReferer_passes() throws Exception { assertNotNull(chain.getRequest()); } + @Test + @DisplayName("API POST + Origin 없고 대소문자 혼합 Referer여도 통과한다") + void apiPost_withMixedCaseAllowedReferer_passes() throws Exception { + OriginRefererCheckFilter filter = apiFilter(); + MockFilterChain chain = new MockFilterChain(); + MockHttpServletRequest request = apiRequest("POST", "/api/payments/confirm"); + MockHttpServletResponse response = new MockHttpServletResponse(); + request.addHeader("Referer", "HTTPS://LOCALHOST:8080/Reservations/abc/booking"); + + filter.doFilter(request, response, chain); + + assertNotNull(chain.getRequest()); + } + @Test @DisplayName("API POST + Origin/Referer 모두 없으면 403 차단한다") void apiPost_withoutOriginAndReferer_blocks() throws Exception { From 13f4a2b4697d71f7f6458ce943bca723dd64784e Mon Sep 17 00:00:00 2001 From: gaeunnlee Date: Fri, 13 Feb 2026 12:33:48 +0900 Subject: [PATCH 18/18] =?UTF-8?q?FLYW-140=20test:=20=ED=9A=8C=EC=9B=90?= =?UTF-8?q?=EA=B0=80=EC=9E=85=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=97=90=20pho?= =?UTF-8?q?neNumber=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F?= =?UTF-8?q?=20SMS=20=EC=9D=B8=EC=A6=9D=20mock=20=EC=B2=98=EB=A6=AC=20(#100?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/flyway/auth/service/SignUpServiceImplTest.java | 6 ++++++ .../com/flyway/auth/service/UserAuthServiceImplTest.java | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/src/test/java/com/flyway/auth/service/SignUpServiceImplTest.java b/src/test/java/com/flyway/auth/service/SignUpServiceImplTest.java index 80c6eee3..190f775b 100644 --- a/src/test/java/com/flyway/auth/service/SignUpServiceImplTest.java +++ b/src/test/java/com/flyway/auth/service/SignUpServiceImplTest.java @@ -56,6 +56,8 @@ void setUp() { passwordEncoder, smsVerificationService ); + + when(smsVerificationService.isVerified(anyString())).thenReturn(true); } @Test @@ -66,6 +68,7 @@ void signUp_success_createsUserAndIdentity() { .email("test@example.com") .rawPassword("password") .attemptId("attempt-1") + .phoneNumber("01012345678") .build(); when(signUpAttemptRepository.consumeIfVerified(eq("attempt-1"), eq("test@example.com"), any())) @@ -101,6 +104,7 @@ void signUp_invalidAttempt_throws() { .email("test@example.com") .rawPassword("password") .attemptId("attempt-1") + .phoneNumber("01012345678") .build(); when(signUpAttemptRepository.consumeIfVerified(anyString(), anyString(), any())) @@ -121,6 +125,7 @@ void signUp_duplicateEmail_throws() { .email("test@example.com") .rawPassword("password") .attemptId("attempt-1") + .phoneNumber("01012345678") .build(); when(signUpAttemptRepository.consumeIfVerified(eq("attempt-1"), eq("test@example.com"), any())) @@ -142,6 +147,7 @@ void signUp_passwordEncodeFails_throws() { .email("test@example.com") .rawPassword("password") .attemptId("attempt-1") + .phoneNumber("01012345678") .build(); when(signUpAttemptRepository.consumeIfVerified(eq("attempt-1"), eq("test@example.com"), any())) diff --git a/src/test/java/com/flyway/auth/service/UserAuthServiceImplTest.java b/src/test/java/com/flyway/auth/service/UserAuthServiceImplTest.java index f377eb8b..cc237e3f 100644 --- a/src/test/java/com/flyway/auth/service/UserAuthServiceImplTest.java +++ b/src/test/java/com/flyway/auth/service/UserAuthServiceImplTest.java @@ -55,6 +55,8 @@ void setUp() { passwordEncoder, smsVerificationService ); + + when(smsVerificationService.isVerified(anyString())).thenReturn(true); } @Test @@ -66,6 +68,7 @@ void signUp_duplicateEmail_throwBusinessException() { req.setEmail("dup@example.com"); req.setRawPassword("password1234"); req.setAttemptId("AttemptId"); + req.setPhoneNumber("01012345678"); when(userIdentityRepository.existsEmailIdentity("dup@example.com")) .thenReturn(true); @@ -100,6 +103,7 @@ void signUp_success_saveAll() { req.setEmail("test@example.com"); req.setRawPassword("password1234"); req.setAttemptId("AttemptId"); + req.setPhoneNumber("01012345678"); when(userIdentityRepository.existsEmailIdentity("test@example.com")) .thenReturn(false); @@ -165,6 +169,7 @@ void signUp_passwordEncodingMissing_throwBusinessException() { req.setEmail("test@example.com"); req.setRawPassword("password1234"); req.setAttemptId("AttemptId"); + req.setPhoneNumber("01012345678"); when(signUpAttemptRepository.consumeIfVerified( eq("AttemptId"), @@ -264,6 +269,7 @@ void signUp_always_checks_duplicate_first() { req.setEmail("test@example.com"); req.setRawPassword("password1234"); req.setAttemptId("AttemptId"); + req.setPhoneNumber("01012345678"); when(userIdentityRepository.existsEmailIdentity(anyString())) .thenReturn(false);