diff --git a/src/main/java/com/example/couphoneserver/config/JwtExceptionFilter.java b/src/main/java/com/example/couphoneserver/config/JwtExceptionFilter.java new file mode 100644 index 0000000..ffc3a9b --- /dev/null +++ b/src/main/java/com/example/couphoneserver/config/JwtExceptionFilter.java @@ -0,0 +1,40 @@ +package com.example.couphoneserver.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +@Component +public class JwtExceptionFilter extends OncePerRequestFilter { + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + try { + filterChain.doFilter(request, response); // go to JwtAuthenticationFilter + } catch (Exception e) { + setErrorResponse(request, response, e); + } + } + + public void setErrorResponse(HttpServletRequest request, HttpServletResponse response, Exception e) throws IOException { + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + final Map body = new HashMap<>(); + + body.put("status", HttpServletResponse.SC_UNAUTHORIZED); + body.put("error", "Unauthorized"); + body.put("message", e.getMessage()); + body.put("path", request.getServletPath()); + + final ObjectMapper mapper = new ObjectMapper(); + mapper.writeValue(response.getOutputStream(), body); + response.setStatus(HttpServletResponse.SC_OK); + } +} diff --git a/src/main/java/com/example/couphoneserver/config/SecurityConfig.java b/src/main/java/com/example/couphoneserver/config/SecurityConfig.java index 6983cda..63f7e99 100644 --- a/src/main/java/com/example/couphoneserver/config/SecurityConfig.java +++ b/src/main/java/com/example/couphoneserver/config/SecurityConfig.java @@ -1,6 +1,7 @@ package com.example.couphoneserver.config; import com.example.couphoneserver.service.MemberDetailService; +import com.example.couphoneserver.service.RefreshTokenService; import com.example.couphoneserver.utils.jwt.JwtTokenProvider; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; @@ -21,6 +22,7 @@ public class SecurityConfig { private final MemberDetailService memberDetailService; private final JwtTokenProvider jwtProvider; + private final RefreshTokenService refreshTokenService; @Bean public WebSecurityCustomizer configure() { @@ -33,12 +35,15 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) .formLogin(FormLoginConfigurer::disable) + + .authorizeHttpRequests((auth) -> auth.requestMatchers("/auth/login").permitAll()) .authorizeHttpRequests((auth) -> auth.requestMatchers("/admin/**").hasRole("ADMIN")) .authorizeHttpRequests((auth) -> auth.anyRequest().permitAll()) .sessionManagement(httpSecuritySessionManagementConfigurer -> httpSecuritySessionManagementConfigurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) - .addFilterBefore(new TokenAuthenticationFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class); + .addFilterBefore(new TokenAuthenticationFilter(jwtProvider, refreshTokenService), UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(new JwtExceptionFilter(), TokenAuthenticationFilter.class); return http.build(); } diff --git a/src/main/java/com/example/couphoneserver/config/TokenAuthenticationFilter.java b/src/main/java/com/example/couphoneserver/config/TokenAuthenticationFilter.java index 750db04..54f70e8 100644 --- a/src/main/java/com/example/couphoneserver/config/TokenAuthenticationFilter.java +++ b/src/main/java/com/example/couphoneserver/config/TokenAuthenticationFilter.java @@ -1,5 +1,8 @@ package com.example.couphoneserver.config; +import com.example.couphoneserver.common.exception.jwt.bad_request.JwtNoTokenException; +import com.example.couphoneserver.common.exception.jwt.unauthorized.JwtExpiredTokenException; +import com.example.couphoneserver.service.RefreshTokenService; import com.example.couphoneserver.utils.jwt.JwtCode; import com.example.couphoneserver.utils.jwt.JwtTokenProvider; import jakarta.servlet.FilterChain; @@ -16,45 +19,61 @@ import java.io.IOException; +import static com.example.couphoneserver.common.response.status.BaseExceptionResponseStatus.EXPIRED_TOKEN; +import static com.example.couphoneserver.common.response.status.BaseExceptionResponseStatus.TOKEN_NOT_FOUND; + @Slf4j @RequiredArgsConstructor public class TokenAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider jwtProvider; + private final RefreshTokenService tokenService; private final static String HEADER_AUTHORIZATION = "Authorization"; private final static String TOKEN_PREFIX = "Bearer "; - /** - * 1. request header Authorization 내 Token 유효성 검사 - * 2. 유효하면 인증 정보를 security context 에 저장 - * 3. Token 기간 만료 시 userId 를 header 에서 가져옴 - * 4. userId 로 refresh token 을 조회한 뒤 유효성 검사이후 유효기간 전이라면 access 재발급 - */ @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - String accessToken = resolveToken(request, HEADER_AUTHORIZATION); + if (permitAllUrl.of(request.getRequestURI())) { + filterChain.doFilter(request, response); + return; + } + String accessToken = resolveToken(request, HEADER_AUTHORIZATION); // 1. 사용자가 보낸 토큰을 확인 + if (StringUtils.hasText(accessToken) && jwtProvider.validateToke(accessToken) == JwtCode.ACCESS) { + // Access token 이 유효하면 Authentication authentication = jwtProvider.getAuthentication(accessToken); SecurityContextHolder.getContext().setAuthentication(authentication); // security context 에 인증 정보 저장 log.info("Access Token 은 아직 유효합니다."); } else if (StringUtils.hasText(accessToken) && jwtProvider.validateToke(accessToken) == JwtCode.EXPIRED) { - // 재발급해야함 + // 2-1. Access token 이 만료되었다면 log.info("Authorization 필드에 담겨진 Access Token 이 Expired 되었습니다!"); String refreshToken = null; - if (StringUtils.hasText(request.getHeader("Auth"))) { // Auth 에는 userId가 담겨서 와야함! - Long userId = Long.parseLong(request.getHeader("Auth")); - refreshToken = jwtProvider.getRefreshToken(userId); // userId로 refreshToken 조회 - } - // refresh token 검증 + // 2-2. jwt token 으로부터 userId 를 찾고, 해당 userId 에 대한 refreshToken 을 탐색 + Long userId = jwtProvider.getUserIdFromExpiredToken(accessToken); + log.warn(userId.toString()); + refreshToken = jwtProvider.getRefreshToken(userId); + + // refresh token 이 존재하고 유효하다면 if (StringUtils.hasText(refreshToken) && jwtProvider.validateToke(refreshToken) == JwtCode.ACCESS) { // access token 재발급 + log.info("해당 회원 ID 에 대한 Refresh token 이 존재하고 유효합니다."); + log.info("Access token 을 재발급하여 반환합니다."); + Authentication authentication = jwtProvider.getAuthentication(refreshToken); String newAccessToken = jwtProvider.generateAccessToken(authentication); SecurityContextHolder.getContext().setAuthentication(authentication); response.setHeader(HttpHeaders.AUTHORIZATION, newAccessToken); - log.info("Access token 을 재발급합니다."); + } else if (StringUtils.hasText(refreshToken) && jwtProvider.validateToke(refreshToken) == JwtCode.EXPIRED) { + // refresh token 이 존재하지만 만료되었다면 + log.warn("해당 회원 ID 에 대한 Refresh token 이 존재하지만, 만료되었습니다."); + throw new JwtExpiredTokenException(EXPIRED_TOKEN); + } + if (refreshToken == null) { + // refresh token 이 존재하지 않으면 + log.warn("해당 회원 ID 에 대한 Refresh token 이 존재하지 않습니다."); + throw new JwtNoTokenException(TOKEN_NOT_FOUND); } } filterChain.doFilter(request, response); diff --git a/src/main/java/com/example/couphoneserver/config/WebConfig.java b/src/main/java/com/example/couphoneserver/config/WebConfig.java index 80b765e..c5c65af 100644 --- a/src/main/java/com/example/couphoneserver/config/WebConfig.java +++ b/src/main/java/com/example/couphoneserver/config/WebConfig.java @@ -21,7 +21,8 @@ public class WebConfig implements WebMvcConfigurer { public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtAuthenticationInterceptor) .order(1) - .addPathPatterns("/auth", "/brands"); + .addPathPatterns("/auth", "/brands", "/users") + .excludePathPatterns("/auth/login"); } @Override diff --git a/src/main/java/com/example/couphoneserver/config/permitAllUrl.java b/src/main/java/com/example/couphoneserver/config/permitAllUrl.java new file mode 100644 index 0000000..b1929f8 --- /dev/null +++ b/src/main/java/com/example/couphoneserver/config/permitAllUrl.java @@ -0,0 +1,17 @@ +package com.example.couphoneserver.config; + +import java.util.HashSet; +import java.util.Set; + +public class permitAllUrl { + private static final Set urlSet; + + static { + urlSet = new HashSet<>(); + urlSet.add("/auth/login"); + } + + public static boolean of(String url) { + return urlSet.contains(url); + } +} diff --git a/src/main/java/com/example/couphoneserver/utils/jwt/JwtTokenProvider.java b/src/main/java/com/example/couphoneserver/utils/jwt/JwtTokenProvider.java index 256bb6e..b77a386 100644 --- a/src/main/java/com/example/couphoneserver/utils/jwt/JwtTokenProvider.java +++ b/src/main/java/com/example/couphoneserver/utils/jwt/JwtTokenProvider.java @@ -1,8 +1,10 @@ package com.example.couphoneserver.utils.jwt; import com.example.couphoneserver.common.exception.MemberException; +import com.example.couphoneserver.common.exception.jwt.bad_request.JwtBadRequestException; import com.example.couphoneserver.common.exception.jwt.bad_request.JwtNoTokenException; import com.example.couphoneserver.common.exception.jwt.bad_request.JwtUnsupportedTokenException; +import com.example.couphoneserver.common.exception.jwt.unauthorized.JwtExpiredTokenException; import com.example.couphoneserver.common.exception.jwt.unauthorized.JwtInvalidTokenException; import com.example.couphoneserver.common.exception.jwt.unauthorized.JwtMalformedTokenException; import com.example.couphoneserver.domain.entity.RefreshToken; @@ -160,10 +162,39 @@ public Long getUserId(String token) { } private Claims getClaims(String token) { - return Jwts.parserBuilder() - .setSigningKey(key) - .build() - .parseClaimsJws(token) - .getBody(); + try { + return Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody(); + } catch (SecurityException e) { + throw new JwtInvalidTokenException(INVALID_TOKEN); + } catch (MalformedJwtException e) { + throw new JwtMalformedTokenException(INVALID_TOKEN); + } catch (ExpiredJwtException e) { + throw new JwtExpiredTokenException(EXPIRED_TOKEN); + } catch (UnsupportedJwtException e) { + throw new JwtUnsupportedTokenException(UNSUPPORTED_TOKEN_TYPE); + } catch (IllegalArgumentException e) { + throw new JwtBadRequestException(JWT_ERROR); + } } + public Long getUserIdFromExpiredToken(String token) { + try { + Claims claims = Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody(); + return Long.parseLong(claims.get("userId", String.class)); + } catch (ExpiredJwtException e) { + // 만료된 토큰에서 사용자 ID를 추출 + // access token 이 만료되었지만 refresh token 이 존재하는 경우 + // JwtExceptionFilter 에 의해 ExpiredException 이 처리되기 전에 userId 를 반환해야 할 때에만 사용 + Claims expiredClaims = e.getClaims(); + return Long.parseLong(expiredClaims.get("userId", String.class)); + } + } + }