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/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/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..1bfc9670 100644 --- a/src/main/java/com/flyway/auth/service/AuthTokenServiceImpl.java +++ b/src/main/java/com/flyway/auth/service/AuthTokenServiceImpl.java @@ -12,6 +12,7 @@ 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; @@ -19,6 +20,7 @@ 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,13 +34,15 @@ 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; private final RefreshTokenRepository refreshTokenRepository; private final TokenHasher tokenHasher; + private final RefreshTokenRevocationService refreshTokenRevocationService; @Value("${cookie.secure:false}") private boolean cookieSecure; @@ -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); + refreshTokenRevocationService.revokeAllByUserTokens(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,30 @@ public void logout(HttpServletRequest request, HttpServletResponse response) { } deleteCookie(response, ACCESS_COOKIE, ACCESS_COOKIE_PATH); - deleteCookie(response, REFRESH_COOKIE, REFRESH_COOKIE_PATH); + deleteRefreshCookies(response); + } + + @Override + @Transactional + 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 +227,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/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/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/java/com/flyway/security/config/SecurityConfigApi.java b/src/main/java/com/flyway/security/config/SecurityConfigApi.java index 2ed11b06..895c01a4 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,9 @@ 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; @Configuration @EnableWebSecurity @@ -26,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; } @@ -48,6 +55,11 @@ public JwtApiAuthFilter jwtApiAuthFilter() { ); } + @Bean + public OriginRefererCheckFilter apiOriginRefererCheckFilter() { + return OriginRefererCheckFilter.forApi(securityOriginProperties.getAllowedOrigins()); + } + @Override protected void configure(HttpSecurity http) throws Exception { http @@ -56,7 +68,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() @@ -79,6 +99,7 @@ protected void configure(HttpSecurity http) throws Exception { .anyRequest().authenticated() .and() + .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 99655eca..ceb0c500 100644 --- a/src/main/java/com/flyway/security/config/SecurityConfigWeb.java +++ b/src/main/java/com/flyway/security/config/SecurityConfigWeb.java @@ -1,9 +1,14 @@ 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.OriginRefererCheckFilter; +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; @@ -20,6 +25,12 @@ 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; +import java.util.Arrays; +import java.util.List; @Slf4j @Configuration @@ -32,23 +43,31 @@ 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; 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; + private final RefreshTokenRepository refreshTokenRepository; + private final TokenHasher tokenHasher; + private final SecurityOriginProperties securityOriginProperties; public SecurityConfigWeb( JwtProvider jwtProvider, JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint, PasswordEncoder passwordEncoder, LoginSuccessHandler loginSuccessHandler, + WebLoginRedirectEntryPoint webLoginRedirectEntryPoint, AuthTokenService authTokenService, + RefreshTokenRepository refreshTokenRepository, + TokenHasher tokenHasher, + SecurityOriginProperties securityOriginProperties, @Qualifier("userIdUserDetailsService") UserDetailsService userIdUserDetailsService, @Qualifier("emailUserDetailsService") UserDetailsService emailUserDetailsService ) { @@ -56,7 +75,11 @@ public SecurityConfigWeb( this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint; this.passwordEncoder = passwordEncoder; this.loginSuccessHandler = loginSuccessHandler; + this.webLoginRedirectEntryPoint = webLoginRedirectEntryPoint; this.authTokenService = authTokenService; + this.refreshTokenRepository = refreshTokenRepository; + this.tokenHasher = tokenHasher; + this.securityOriginProperties = securityOriginProperties; this.userIdUserDetailsService = userIdUserDetailsService; this.emailUserDetailsService = emailUserDetailsService; } @@ -70,6 +93,24 @@ 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 OriginRefererCheckFilter webOriginRefererCheckFilter() { + return OriginRefererCheckFilter.forWeb(securityOriginProperties.getAllowedOrigins()); + } + @Bean public AuthenticationFailureHandler loginFailureHandler() { return (req, res, ex) -> { @@ -92,13 +133,19 @@ protected void configure(AuthenticationManagerBuilder auth) throws Exception { @Override protected void configure(HttpSecurity http) throws Exception { http - .csrf().disable() + .csrf() + .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) + .and() .authorizeRequests() .antMatchers(STATIC_RESOURCES).permitAll() .antMatchers(PUBLIC_ENDPOINTS).permitAll().anyRequest().authenticated() .and() + .exceptionHandling() + .authenticationEntryPoint(webLoginRedirectEntryPoint) + .and() + .formLogin() .loginPage("/login") .loginProcessingUrl("/loginProc") @@ -116,8 +163,10 @@ protected void configure(HttpSecurity http) throws Exception { .permitAll() .and() + .addFilterBefore(webOriginRefererCheckFilter(), CsrfFilter.class) .addFilterBefore(jwtWebAuthFilter(), UsernamePasswordAuthenticationFilter.class) - .addFilterAfter(new OnboardingAccessFilter(), JwtWebAuthFilter.class); + .addFilterAfter(refreshTokenSessionSyncFilter(), JwtWebAuthFilter.class) + .addFilterAfter(new OnboardingAccessFilter(), RefreshTokenSessionSyncFilter.class); } @Bean @@ -125,4 +174,3 @@ public LogoutHandler jwtCookieLogoutHandler() { return (request, response, authentication) -> authTokenService.logout(request, response); } } - 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 new file mode 100644 index 00000000..19b8fb8c --- /dev/null +++ b/src/main/java/com/flyway/security/filter/OriginRefererCheckFilter.java @@ -0,0 +1,202 @@ +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.Locale; +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 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(Collection allowedOrigins) { + return new OriginRefererCheckFilter( + allowedOrigins, + Arrays.asList("/api"), + null, + null + ); + } + + public static OriginRefererCheckFilter forWeb(Collection allowedOrigins) { + return new OriginRefererCheckFilter( + allowedOrigins, + 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) { + String normalizedReferer = trimToNull(referer); + if (!StringUtils.hasText(normalizedReferer)) return false; + normalizedReferer = normalizeOrigin(normalizedReferer); + + for (String allowedOrigin : allowedOrigins) { + if (normalizedReferer.equals(allowedOrigin) || normalizedReferer.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(); + while (value.length() > 1 && value.endsWith("/")) { + value = value.substring(0, value.length() - 1); + } + return value.toLowerCase(Locale.ROOT); + } + + 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/main/java/com/flyway/security/filter/RefreshTokenSessionSyncFilter.java b/src/main/java/com/flyway/security/filter/RefreshTokenSessionSyncFilter.java new file mode 100644 index 00000000..5bb11f44 --- /dev/null +++ b/src/main/java/com/flyway/security/filter/RefreshTokenSessionSyncFilter.java @@ -0,0 +1,153 @@ +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.net.URLEncoder; +import java.nio.charset.StandardCharsets; +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); + String returnUrl = buildReturnUrl(request); + String encoded = URLEncoder.encode(returnUrl, StandardCharsets.UTF_8); + response.sendRedirect(request.getContextPath() + "/login?returnUrl=" + encoded); + } + + 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; + } + + 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/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; + } } 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; + } +} 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) { diff --git a/src/main/java/com/flyway/security/jwt/JwtWebAuthFilter.java b/src/main/java/com/flyway/security/jwt/JwtWebAuthFilter.java index 810ed6de..150378aa 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/"; + private 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.equals("/login") || 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; + } } diff --git a/src/main/resources/config/application-prod.properties b/src/main/resources/config/application-prod.properties index e407150e..f10a9f9d 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,https://www.flyway.kr \ No newline at end of file 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/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 @@ 마이페이지