Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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
79 changes: 50 additions & 29 deletions src/main/java/com/ject/vs/config/JwtAuthFilter.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
package com.ject.vs.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.ject.vs.domain.TokenStatus;
import com.ject.vs.dto.ErrorResponse;
import com.ject.vs.exception.ErrorCode;
import com.ject.vs.exception.TokenErrorCode;
import com.ject.vs.util.CookieUtil;
import com.ject.vs.util.JwtProvider;
import jakarta.servlet.FilterChain;
Expand All @@ -11,6 +16,7 @@
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
Expand All @@ -23,26 +29,15 @@
@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {
private static final List<String> JWT_EXCLUDED_PATHS = List.of(
"/v3/api-docs/**",
"/swagger-ui/**",
"/swagger-ui.html",
"/api/**",
"/actuator/health",
"/actuator/health/**",
"/",
"/error",
"/auth/reissue"
);

private final JwtProvider jwtProvider;
private final CookieUtil cookieUtil;
private final AntPathMatcher pathMatcher = new AntPathMatcher();
private final ObjectMapper objectMapper;

@Override
protected boolean shouldNotFilter(@NonNull HttpServletRequest request) {
String path = getRequestPath(request);
return JWT_EXCLUDED_PATHS.stream()
return SecurityPaths.JWT_EXCLUDED_PATHS.stream()
.anyMatch(pattern -> pathMatcher.match(pattern, path));
}

Expand All @@ -65,29 +60,55 @@ protected void doFilterInternal(@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain) throws ServletException, IOException {

// 토큰 확인
String accessToken = cookieUtil.getCookieValue(request, CookieUtil.CookieType.ACCESS_TOKEN);

if (!StringUtils.hasText(accessToken) || !jwtProvider.validationToken(accessToken)) {
filterChain.doFilter(request, response);
return;
}
TokenStatus status = jwtProvider.validationToken(accessToken);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 변경으로 JwtAuthFilter 생성자와 validationToken() 반환 타입이 바뀌었는데 기존 테스트가 반영되지 않아 로컬에서 ./gradlew test --no-daemon 실행 시 테스트 컴파일이 실패합니다.

보안 로직 변경인 만큼 TokenStatus.VALID/EXPIRED/INVALID/EMPTY, access/refresh token type 구분 케이스를 회귀 테스트로 같이 보강하면 좋겠습니다.


var tokenInfo = jwtProvider.parseToken(accessToken);
switch (status) {
case VALID -> {
boolean authenticated = setAuthentication(request, accessToken);

if (tokenInfo.isAccessToken()
&& SecurityContextHolder.getContext().getAuthentication() == null) {
if(!authenticated) {
sendErrorResponse(response, TokenErrorCode.INVALID_TOKEN);
return;
}
filterChain.doFilter(request, response);
}
case EMPTY -> filterChain.doFilter(request, response);
case EXPIRED -> sendErrorResponse(response, TokenErrorCode.EXPIRED_TOKEN);
case INVALID -> sendErrorResponse(response, TokenErrorCode.INVALID_TOKEN);
}
}

UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
tokenInfo.userId(),
null,
AuthorityUtils.NO_AUTHORITIES
);
private boolean setAuthentication(HttpServletRequest request, String token) {
var tokenInfo = jwtProvider.parseToken(token);

authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
if(!tokenInfo.isAccessToken()) {
return false;
}

filterChain.doFilter(request, response);
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
tokenInfo.userId(),
null,
AuthorityUtils.NO_AUTHORITIES
);

authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);

return true;
}

private void sendErrorResponse(HttpServletResponse response, ErrorCode errorCode) throws IOException {
response.setStatus(errorCode.getStatus().value());
response.setContentType("application/json;charset=UTF-8");

ErrorResponse errorResponse = new ErrorResponse(
errorCode.getCode(),
errorCode.getMessage()
);

response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
}
}
64 changes: 44 additions & 20 deletions src/main/java/com/ject/vs/config/OAuth2LoginSuccessHandler.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package com.ject.vs.config;

import com.ject.vs.domain.UserStatus;
import com.ject.vs.dto.LoginTokenResponse;
import com.ject.vs.dto.OAuthAttributes;
import com.ject.vs.exception.CustomException;
import com.ject.vs.service.AuthService;
import com.ject.vs.util.CookieUtil;
import jakarta.servlet.ServletException;
Expand All @@ -12,7 +14,6 @@
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
Expand All @@ -26,49 +27,72 @@ public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHan
private final AuthService authService;

@Value("${app.oauth2.redirect-success-url}")
private String redirectSuccessUrl;
private String homeUrl;

@Value("${app.oauth2.extra-info-url}")
private String extraInfoUrl;

@Value("${app.jwt.access-token-expiration-seconds}")
private long accessTokenExpiration;

@Value("${app.jwt.refresh-token-expiration-seconds}")
private long refreshTokenExpiration;

@Value("${app.cookie.secure:false}") // 운영 상황에서는 true로 변경 https 사용할 경우
private boolean secureCookie;

@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
Authentication authentication) throws IOException {

OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
String email = oAuth2User.getAttribute("email");

OAuth2AuthenticationToken oauthToken = (OAuth2AuthenticationToken) authentication;
OAuth2User oauth2User = (OAuth2User) authentication.getPrincipal();
try {
LoginTokenResponse loginResponse = authService.socialLogin(email);

String registrationId = oauthToken.getAuthorizedClientRegistrationId();
String userNameAttributeName = "sub";
addTokenCookies(response, loginResponse);

OAuthAttributes attributes = OAuthAttributes.of(
registrationId,
userNameAttributeName,
oauth2User.getAttributes()
);
// 상태(REGISTER, UNREGISTER)에 따른 리다이렉트 경로 결정
String targetUrl = determineTargetUrl(loginResponse.getUserStatus());

LoginTokenResponse tokenResponse = authService.socialLogin(attributes.getSub());
// 리다이렉트 실행
getRedirectStrategy().sendRedirect(request, response, targetUrl);

ResponseCookie accessTokenCookie = ResponseCookie.from(CookieUtil.CookieType.ACCESS_TOKEN, tokenResponse.getAccessToken())
} catch (CustomException e) {
// 401 또는 500 에러 처리 (로그인 실패 시)
response.sendError(e.getErrorCode().getHttpStatus().value(), e.getMessage());
}
}

private void addTokenCookies(HttpServletResponse response, LoginTokenResponse loginResponse) {
// 30분
ResponseCookie accessTokenCookie = ResponseCookie.from(CookieUtil.CookieType.ACCESS_TOKEN, loginResponse.getAccessToken())
.httpOnly(true)
.secure(secureCookie)
.secure(true)
.path("/")
.maxAge(accessTokenExpiration)
.sameSite("Lax")
.maxAge(60 * 30)
.build();

ResponseCookie refreshTokenCookie = ResponseCookie.from(CookieUtil.CookieType.REFRESH_TOKEN, tokenResponse.getRefreshToken())
// 30일
ResponseCookie refreshTokenCookie = ResponseCookie.from(CookieUtil.CookieType.REFRESH_TOKEN, loginResponse.getRefreshToken())
.httpOnly(true)
.secure(secureCookie)
.secure(true)
.path("/")
.maxAge(refreshTokenExpiration)
.sameSite("Lax")
.maxAge(60 * 60 * 24 * 14)
.build();

response.addHeader(HttpHeaders.SET_COOKIE, accessTokenCookie.toString());
response.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString());
}

getRedirectStrategy().sendRedirect(request, response, redirectSuccessUrl);
private String determineTargetUrl(UserStatus status) {
if(UserStatus.REGISTER.equals(status)) {
return homeUrl;
}
return extraInfoUrl;
}
}
45 changes: 25 additions & 20 deletions src/main/java/com/ject/vs/config/SecurityConfig.java
Original file line number Diff line number Diff line change
@@ -1,38 +1,43 @@
package com.ject.vs.config;

import com.ject.vs.service.CustomOAuth2UserService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.web.cors.CorsConfigurationSource;

@Configuration
public class SecurityConfig {

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http,
OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler,
CorsConfigurationSource corsConfigurationSource) throws Exception {
return http
public SecurityFilterChain securityFilterChain(
HttpSecurity http,
OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler,
CustomOAuth2UserService customOAuth2UserService,
CorsConfigurationSource corsConfigurationSource
) throws Exception {
CsrfTokenRequestAttributeHandler requestHandler = new CsrfTokenRequestAttributeHandler();
requestHandler.setCsrfRequestAttributeName(null);

http
.cors(cors -> cors.configurationSource(corsConfigurationSource))
.csrf(AbstractHttpConfigurer::disable)
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(requestHandler)
)
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/v3/api-docs/**",
"/swagger-ui/**",
"/swagger-ui.html",
"/api/**",
"/actuator/health",
"/actuator/health/**",
"/",
"/error",
"/auth/reissue"
).permitAll()
.requestMatchers(SecurityPaths.PUBLIC_ENDPOINTS.toArray(String[]::new)).permitAll()
.requestMatchers(HttpMethod.POST, "/auth/reissue").permitAll()
.anyRequest().authenticated()
)
.oauth2Login(oauth2 -> oauth2
.successHandler(oAuth2LoginSuccessHandler))
.build();
.successHandler(oAuth2LoginSuccessHandler)
.userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService)));

return http.build();
}
}
}
29 changes: 29 additions & 0 deletions src/main/java/com/ject/vs/config/SecurityPaths.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.ject.vs.config;

import java.util.ArrayList;
import java.util.List;

public class SecurityPaths {

public static final List<String> PUBLIC_ENDPOINTS = List.of(
"/v3/api-docs/**",
"/swagger-ui/**",
"/swagger-ui.html",
"/actuator/health",
"/actuator/health/**",
"/",
"/error",
"/oauth2/authorization/**",
"/login/oauth2/code/**"
);

public static final List<String> JWT_EXCLUDED_PATHS = createJwtExcludedPaths();

private static List<String> createJwtExcludedPaths() {
List<String> paths = new ArrayList<>(PUBLIC_ENDPOINTS);

paths.add("/auth/reissue");

return List.copyOf(paths);
}
}
34 changes: 25 additions & 9 deletions src/main/java/com/ject/vs/controller/AuthController.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.ject.vs.controller;

import com.ject.vs.dto.TokenInfo;
import com.ject.vs.dto.TokenReissueResponse;
import com.ject.vs.service.AuthService;
import com.ject.vs.util.CookieUtil;
import jakarta.servlet.http.HttpServletRequest;
Expand All @@ -19,30 +20,45 @@ public class AuthController {
private final AuthService authService;
private final CookieUtil cookieUtil;

@Value("${app.jwt.access-token-expiration-seconds}")
private long accessTokenExpiration;

@Value("${app.jwt.refresh-token-expiration-seconds}")
private long refreshTokenExpiration;

@Value("${app.cookie.secure:true}")
private boolean secureCookie;

@PostMapping("/auth/reissue")
public ResponseEntity<Void> reissue(HttpServletRequest request, HttpServletResponse response) {
String refreshToken = cookieUtil.getCookieValue(
request,
CookieUtil.CookieType.REFRESH_TOKEN
);
String refreshToken = cookieUtil.getCookieValue(request, CookieUtil.CookieType.REFRESH_TOKEN);

TokenInfo newAccessTokenInfo = authService.reissueAccessToken(refreshToken);
TokenReissueResponse tokenResponse = authService.reissueAccessToken(refreshToken);

ResponseCookie accessTokenCookie = ResponseCookie.from(
CookieUtil.CookieType.ACCESS_TOKEN,
newAccessTokenInfo.tokenValue()
)
CookieUtil.CookieType.ACCESS_TOKEN,
tokenResponse.accessToken()
)
.httpOnly(true)
.secure(secureCookie)
.path("/")
.sameSite("None")
.maxAge(accessTokenExpiration)
.build();

ResponseCookie refreshTokenCookie = ResponseCookie.from(
CookieUtil.CookieType.REFRESH_TOKEN,
tokenResponse.refreshToken()
)
.httpOnly(true)
.secure(secureCookie)
.path("/")
.sameSite("None")
.maxAge(60 * 30)
.maxAge(refreshTokenExpiration)
.build();

response.addHeader(HttpHeaders.SET_COOKIE, accessTokenCookie.toString());
response.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString());

return ResponseEntity.ok().build();
}
Expand Down
Loading
Loading