Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
a8dc54a
FLYW-140 feat: refreshToken 불일치 시 강제 로그아웃 및 WEB 세션 동기화 필터 추가 (#100)
gaeunnlee Feb 8, 2026
366cc1b
FLYW-140 fix: JwtApiAuthFilter에 JWT 인증 마커 추가 및 중복 인증 방지 (#100)
gaeunnlee Feb 8, 2026
4a11841
FLYW-140 feat: 로그인 페이지 returnUrl 파라미터 연동 (#100)
gaeunnlee Feb 9, 2026
ff042c6
FLYW-140 feat: 로그인 성공 시 returnUrl 기반 리다이렉트 추가 (#100)
gaeunnlee Feb 9, 2026
a085a02
FLYW-140 feat: 웹 인증 실패 시 login redirect entrypoint 적용 (#100)
gaeunnlee Feb 9, 2026
b5c109e
FLYW-140 fix: JWT/refresh 인증 실패 시 returnUrl 기반 redirect 처리 (#100)
gaeunnlee Feb 9, 2026
8acb044
FLYW-140 feat: SecurityConfigWeb CSRF 활성화 및 form 태그 csrfInput 추가 (#100)
gaeunnlee Feb 9, 2026
e7ec217
FLYW-140 feat: SecurityConfigApi CSRF 활성화 및 csrfFetch 유틸 추가 (#100)
gaeunnlee Feb 10, 2026
12310cb
FLYW-140 feat: 항공권 목록 조회 POST 변경 및 CSRF 유틸 적용 (#100)
gaeunnlee Feb 10, 2026
98b9998
FLYW-140 feat: SecurityConfigWeb csrf 활성화 (#100)
gaeunnlee Feb 11, 2026
7fabc30
FLYW-140 feat: fetch 요청 csrfFetch 일괄 변경 (#100)
gaeunnlee Feb 11, 2026
8a0d02f
FLYW-140 feat: OriginRefererCheckFilter 구현 및 적용 (#100)
gaeunnlee Feb 11, 2026
0ae6e58
FLYW-140 test: CSRF 토큰 검증 테스트 (#100)
gaeunnlee Feb 11, 2026
41105e2
FLYW-140 fix: csrfFetch 초기화 레이스 방지 및 보안 필터 정규화 (#100)
gaeunnlee Feb 11, 2026
e80f0ea
FLYW-140 feat: 허용 Origin URI 환경별 설정 (#100)
gaeunnlee Feb 13, 2026
3dd952c
FLYW-140 feat: refresh token 재사용 시 revoke 트랜잭션 분리 (#100)
gaeunnlee Feb 13, 2026
842ebf5
FLYW-140 feat: Origin/Referer allowlist 대소문자 정규화 (#100)
gaeunnlee Feb 13, 2026
13f4a2b
FLYW-140 test: 회원가입 테스트에 phoneNumber 필드 추가 및 SMS 인증 mock 처리 (#100)
gaeunnlee Feb 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/main/java/com/flyway/auth/controller/AuthController.java
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,11 @@ public ResponseEntity<Void> refresh(
}
}

@GetMapping("/auth/csrf")
public ResponseEntity<Void> csrf() {
return ResponseEntity.noContent().build();
}

private void autoLoginByEmail(String email, HttpServletRequest req, HttpServletResponse res) {
UserDetails userDetails = emailUserDetailsService.loadUserByUsername(email);
authenticateAndSave(userDetails, req, res);
Expand Down
20 changes: 19 additions & 1 deletion src/main/java/com/flyway/auth/controller/AuthViewController.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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";
}

Expand Down Expand Up @@ -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;
}

}
5 changes: 5 additions & 0 deletions src/main/java/com/flyway/auth/service/AuthTokenService.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ public interface AuthTokenService {
*/
void logout(HttpServletRequest request, HttpServletResponse response);

/**
* 강제 로그아웃: 세션/보안 컨텍스트 정리 + 쿠키 삭제
*/
void forceLogout(HttpServletRequest request, HttpServletResponse response);

/**
* 토큰 폐기
*/
Expand Down
43 changes: 40 additions & 3 deletions src/main/java/com/flyway/auth/service/AuthTokenServiceImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@
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 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;
Expand All @@ -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;
Expand Down Expand Up @@ -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);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Expand Down Expand Up @@ -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);
}

Expand All @@ -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();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

@Transactional
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ public List<Airline> 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);
}

Expand Down
3 changes: 3 additions & 0 deletions src/main/java/com/flyway/search/dto/FlightSearchRequest.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.flyway.search.dto;

import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;

import java.time.LocalDate;

Expand All @@ -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;
Expand Down
23 changes: 22 additions & 1 deletion src/main/java/com/flyway/security/config/SecurityConfigApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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;
}

Expand All @@ -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
Expand All @@ -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()

Expand All @@ -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);
}
Expand Down
Loading