diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthController.java index e88c90981..ecd34319c 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthController.java @@ -2,6 +2,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import kr.co.pennyway.api.apis.auth.dto.PhoneVerificationDto; import kr.co.pennyway.api.apis.auth.dto.SignInReq; import kr.co.pennyway.api.apis.auth.dto.SignUpReq; @@ -17,10 +18,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import java.time.Duration; import java.util.Map; @@ -69,6 +67,13 @@ public ResponseEntity signIn(@RequestBody @Validated SignInReq.General reques return createAuthenticatedResponse(authUseCase.signIn(request)); } + @Operation(summary = "토큰 갱신", description = "리프레시 토큰을 이용해 액세스 토큰과 리프레시 토큰을 갱신합니다.") + @GetMapping("/refresh") + @PreAuthorize("isAnonymous()") + public ResponseEntity refresh(@CookieValue("refreshToken") @Valid String refreshToken) { + return createAuthenticatedResponse(authUseCase.refresh(refreshToken)); + } + private ResponseEntity createAuthenticatedResponse(Pair userInfo) { ResponseCookie cookie = cookieUtil.createCookie("refreshToken", userInfo.getValue().refreshToken(), Duration.ofDays(7).toSeconds()); return ResponseEntity.ok() diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelper.java index 2cbd4fbdf..34254b52f 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelper.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelper.java @@ -5,15 +5,20 @@ import kr.co.pennyway.api.common.security.jwt.Jwts; import kr.co.pennyway.api.common.security.jwt.access.AccessTokenClaim; import kr.co.pennyway.api.common.security.jwt.refresh.RefreshTokenClaim; +import kr.co.pennyway.api.common.security.jwt.refresh.RefreshTokenClaimKeys; import kr.co.pennyway.common.annotation.Helper; import kr.co.pennyway.domain.common.redis.refresh.RefreshToken; import kr.co.pennyway.domain.common.redis.refresh.RefreshTokenService; import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.infra.common.exception.JwtErrorCode; +import kr.co.pennyway.infra.common.exception.JwtErrorException; import kr.co.pennyway.infra.common.jwt.JwtProvider; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.tuple.Pair; import java.time.Duration; import java.time.LocalDateTime; +import java.util.Map; @Slf4j @Helper @@ -47,6 +52,25 @@ public Jwts createToken(User user) { return Jwts.of(accessToken, refreshToken); } + public Pair refresh(String refreshToken) { + Map claims = refreshTokenProvider.getJwtClaimsFromToken(refreshToken).getClaims(); + + Long userId = Long.parseLong((String) claims.get(RefreshTokenClaimKeys.USER_ID.getValue())); + String role = (String) claims.get(RefreshTokenClaimKeys.ROLE.getValue()); + + String newAccessToken = accessTokenProvider.generateToken(AccessTokenClaim.of(userId, role)); + RefreshToken newRefreshToken; + try { + newRefreshToken = refreshTokenService.refresh(userId, refreshToken, refreshTokenProvider.generateToken(RefreshTokenClaim.of(userId, role))); + } catch (IllegalArgumentException e) { + throw new JwtErrorException(JwtErrorCode.EXPIRED_TOKEN); + } catch (IllegalStateException e) { + throw new JwtErrorException(JwtErrorCode.TAKEN_AWAY_TOKEN); + } + + return Pair.of(userId, Jwts.of(newAccessToken, newRefreshToken.getToken())); + } + private long toSeconds(LocalDateTime expiryTime) { return Duration.between(LocalDateTime.now(), expiryTime).getSeconds(); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthUseCase.java index 2086c3bed..56eb0573c 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthUseCase.java @@ -61,6 +61,10 @@ public Pair signIn(SignInReq.General request) { return Pair.of(user.getId(), jwtAuthHelper.createToken(user)); } + public Pair refresh(String refreshToken) { + return jwtAuthHelper.refresh(refreshToken); + } + private Pair checkOauthUserNotGeneralSignUp(String phone) { Pair isGeneralSignUpAllowed = userSyncMapper.isGeneralSignUpAllowed(phone); diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/annotation/RefreshTokenStrategy.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/annotation/RefreshTokenStrategy.java index 911bd2039..b29ebda68 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/annotation/RefreshTokenStrategy.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/annotation/RefreshTokenStrategy.java @@ -8,6 +8,6 @@ ElementType.TYPE, ElementType.ANNOTATION_TYPE}) @Retention(RetentionPolicy.RUNTIME) @Documented -@Qualifier("accessTokenStrategy") +@Qualifier("refreshTokenStrategy") public @interface RefreshTokenStrategy { } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/handler/GlobalExceptionHandler.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/handler/GlobalExceptionHandler.java index dbb101da3..25999c0b1 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/handler/GlobalExceptionHandler.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/handler/GlobalExceptionHandler.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.databind.exc.MismatchedInputException; import kr.co.pennyway.api.common.response.ErrorResponse; +import kr.co.pennyway.common.exception.CausedBy; import kr.co.pennyway.common.exception.GlobalErrorException; import kr.co.pennyway.common.exception.ReasonCode; import kr.co.pennyway.common.exception.StatusCode; @@ -11,6 +12,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.http.converter.HttpMessageNotWritableException; +import org.springframework.security.access.AccessDeniedException; import org.springframework.validation.BindingResult; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingRequestHeaderException; @@ -20,8 +22,8 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.servlet.NoHandlerFoundException; +import org.springframework.web.servlet.resource.NoResourceFoundException; -import java.nio.file.AccessDeniedException; import java.util.HashMap; import java.util.Map; @@ -54,7 +56,9 @@ protected ResponseEntity handleGlobalErrorException(GlobalErrorEx @ExceptionHandler(AccessDeniedException.class) protected ErrorResponse handleAccessDeniedException(AccessDeniedException e) { log.warn("handleAccessDeniedException : {}", e.getMessage()); - return ErrorResponse.of(String.valueOf(StatusCode.FORBIDDEN.getCode()), e.getMessage()); + CausedBy causedBy = CausedBy.of(StatusCode.FORBIDDEN, ReasonCode.ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN); + + return ErrorResponse.of(causedBy.getCode(), causedBy.getReason()); } /** @@ -156,6 +160,20 @@ protected ErrorResponse handleNoHandlerFoundException(NoHandlerFoundException e) return ErrorResponse.of(code, e.getMessage()); } + /** + * 존재하지 않는 URL 호출 시 + * + * @see NoHandlerFoundException + */ + @ResponseStatus(HttpStatus.NOT_FOUND) + @ExceptionHandler(NoResourceFoundException.class) + protected ErrorResponse handleNoResourceFoundException(NoResourceFoundException e) { + log.warn("handleNoResourceFoundException : {}", e.getMessage()); + + String code = String.valueOf(StatusCode.NOT_FOUND.getCode() * 10 + ReasonCode.INVALID_URL_OR_ENDPOINT.getCode()); + return ErrorResponse.of(code, e.getMessage()); + } + /** * API 호출 시 데이터를 반환할 수 없는 경우 * diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authentication/SecurityUserDetails.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authentication/SecurityUserDetails.java index 808d3c39f..250d9cfc7 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authentication/SecurityUserDetails.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authentication/SecurityUserDetails.java @@ -81,4 +81,14 @@ public boolean isCredentialsNonExpired() { public boolean isEnabled() { throw new UnsupportedOperationException(); } + + @Override + public String toString() { + return "SecurityUserDetails{" + + "userId=" + userId + + ", username='" + username + '\'' + + ", authorities=" + authorities + + ", accountNonLocked=" + accountNonLocked + + '}'; + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/filter/JwtAuthenticationFilter.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/filter/JwtAuthenticationFilter.java new file mode 100644 index 000000000..0363fcd03 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/filter/JwtAuthenticationFilter.java @@ -0,0 +1,130 @@ +package kr.co.pennyway.api.common.security.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import kr.co.pennyway.api.common.security.jwt.access.AccessTokenClaimKeys; +import kr.co.pennyway.domain.common.redis.forbidden.ForbiddenTokenService; +import kr.co.pennyway.infra.common.exception.JwtErrorCode; +import kr.co.pennyway.infra.common.exception.JwtErrorException; +import kr.co.pennyway.infra.common.jwt.JwtClaims; +import kr.co.pennyway.infra.common.jwt.JwtProvider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.lang.NonNull; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** + * JWT 인증 필터
+ * 만약, 유효한 액세스 토큰과 리프레시 토큰이 모두 없다면 익명 사용자로 간주한다.
+ * 인증된 유저는 SecurityContextHolder에 SecurityUser를 등록하며, Controller에서 @AuthenticationPrincipal 어노테이션을 통해 접근할 수 있다. + * + *
+ * {@code
+ *  @GetMapping("/user")
+ *  public ResponseEntity getUser(@AuthenticationPrincipal SecurityUser user) {
+ *      Long userId = user.getId();
+ *      ...
+ *  }
+ * }
+ * 
+ * + * @see org.springframework.security.core.annotation.AuthenticationPrincipal + */ +@RequiredArgsConstructor +@Slf4j +public class JwtAuthenticationFilter extends OncePerRequestFilter { + private final UserDetailsService userDetailService; + private final ForbiddenTokenService forbiddenTokenService; + + private final JwtProvider accessTokenProvider; + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws ServletException, IOException { + if (isAnonymousRequest(request)) { + filterChain.doFilter(request, response); + return; + } + + String accessToken = resolveAccessToken(request, response); + + UserDetails userDetails = getUserDetails(accessToken); + authenticateUser(userDetails, request); + filterChain.doFilter(request, response); + } + + /** + * AccessToken과 RefreshToken이 모두 없는 경우, 익명 사용자로 간주한다. + */ + private boolean isAnonymousRequest(HttpServletRequest request) { + String accessToken = request.getHeader(HttpHeaders.AUTHORIZATION); + String refreshToken = request.getHeader(HttpHeaders.SET_COOKIE); + + return accessToken == null && refreshToken == null; + } + + /** + * @throws ServletException : Authorization 헤더가 없거나, 금지된 토큰이거나, 토큰이 만료된 경우 예외 발생 + */ + private String resolveAccessToken(HttpServletRequest request, HttpServletResponse response) throws ServletException { + String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + + String token = accessTokenProvider.resolveToken(authHeader); + + if (!StringUtils.hasText(token)) { + handleAuthException(JwtErrorCode.EMPTY_ACCESS_TOKEN); + } + + if (forbiddenTokenService.isForbidden(token)) { + handleAuthException(JwtErrorCode.FORBIDDEN_ACCESS_TOKEN); + } + + if (accessTokenProvider.isTokenExpired(token)) { + handleAuthException(JwtErrorCode.EXPIRED_TOKEN); + } + + return token; + } + + /** + * UserDetailsService를 통해 SecurityUser를 가져오는 메서드 + */ + private UserDetails getUserDetails(String accessToken) { + JwtClaims claims = accessTokenProvider.getJwtClaimsFromToken(accessToken); + String userId = (String) claims.getClaims().get(AccessTokenClaimKeys.USER_ID.getValue()); + + return userDetailService.loadUserByUsername(userId); + } + + /** + * SecurityContextHolder에 SecurityUser를 등록하는 메서드 + */ + private void authenticateUser(UserDetails userDetails, HttpServletRequest request) { + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities() + ); + + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + log.info("Authenticated user: {}", userDetails.getUsername()); + } + + /** + * 인증 예외가 발생했을 때, 로그를 남기고 예외를 던지는 메서드 + */ + private void handleAuthException(JwtErrorCode errorCode) throws ServletException { + log.warn("AuthErrorException(code={}, message={})", errorCode.name(), errorCode.getExplainError()); + JwtErrorException exception = new JwtErrorException(errorCode); + throw new ServletException(exception); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/filter/JwtExceptionFilter.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/filter/JwtExceptionFilter.java new file mode 100644 index 000000000..574c71f7b --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/filter/JwtExceptionFilter.java @@ -0,0 +1,42 @@ +package kr.co.pennyway.api.common.security.filter; + +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 kr.co.pennyway.api.common.response.ErrorResponse; +import kr.co.pennyway.infra.common.exception.JwtErrorException; +import kr.co.pennyway.infra.common.util.JwtErrorCodeUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.lang.NonNull; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Slf4j +@RequiredArgsConstructor +public class JwtExceptionFilter extends OncePerRequestFilter { + private final ObjectMapper objectMapper; + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws ServletException, IOException { + try { + filterChain.doFilter(request, response); + } catch (Exception e) { + log.warn("Exception caught in JwtExceptionFilter: {}", e.getMessage()); + JwtErrorException exception = JwtErrorCodeUtil.determineAuthErrorException(e); + + sendAuthError(response, exception); + } + } + + private void sendAuthError(HttpServletResponse response, JwtErrorException e) throws IOException { + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(e.getErrorCode().getStatusCode().getCode()); + + ErrorResponse errorResponse = ErrorResponse.of(e.causedBy().getCode(), e.causedBy().getReason()); + objectMapper.writeValue(response.getWriter(), errorResponse); + } +} \ No newline at end of file diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/handler/JwtAccessDeniedHandler.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/handler/JwtAccessDeniedHandler.java new file mode 100644 index 000000000..46198b10d --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/handler/JwtAccessDeniedHandler.java @@ -0,0 +1,33 @@ +package kr.co.pennyway.api.common.security.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import kr.co.pennyway.api.common.response.ErrorResponse; +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.ReasonCode; +import kr.co.pennyway.common.exception.StatusCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; + +import java.io.IOException; + +@Slf4j +@RequiredArgsConstructor +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + private final ObjectMapper objectMapper; + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { + log.warn("handle error: {}", accessDeniedException.getMessage()); + CausedBy causedBy = CausedBy.of(StatusCode.FORBIDDEN, ReasonCode.ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN); + + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(causedBy.statusCode().getCode()); + ErrorResponse errorResponse = ErrorResponse.of(causedBy.getCode(), causedBy.getReason()); + objectMapper.writeValue(response.getWriter(), errorResponse); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/handler/JwtAuthenticationEntryPoint.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/handler/JwtAuthenticationEntryPoint.java new file mode 100644 index 000000000..d2003546b --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/handler/JwtAuthenticationEntryPoint.java @@ -0,0 +1,33 @@ +package kr.co.pennyway.api.common.security.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import kr.co.pennyway.api.common.response.ErrorResponse; +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.ReasonCode; +import kr.co.pennyway.common.exception.StatusCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; + +import java.io.IOException; + +@Slf4j +@RequiredArgsConstructor +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + private final ObjectMapper objectMapper; + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { + log.warn("commence error: {}", authException.getMessage()); + CausedBy causedBy = CausedBy.of(StatusCode.UNAUTHORIZED, ReasonCode.MISSING_OR_INVALID_AUTHENTICATION_CREDENTIALS); + + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(causedBy.statusCode().getCode()); + ErrorResponse errorResponse = ErrorResponse.of(causedBy.getCode(), causedBy.getReason()); + objectMapper.writeValue(response.getWriter(), errorResponse); + } +} \ No newline at end of file diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/JwtSecurityConfig.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/JwtSecurityConfig.java new file mode 100644 index 000000000..70133bf1f --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/JwtSecurityConfig.java @@ -0,0 +1,23 @@ +package kr.co.pennyway.api.config.security; + +import kr.co.pennyway.api.common.security.filter.JwtAuthenticationFilter; +import kr.co.pennyway.api.common.security.filter.JwtExceptionFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.SecurityConfigurerAdapter; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.DefaultSecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@RequiredArgsConstructor +public class JwtSecurityConfig extends SecurityConfigurerAdapter { + private final JwtExceptionFilter jwtExceptionFilter; + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + @Override + public void configure(HttpSecurity http) throws Exception { + http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + http.addFilterBefore(jwtExceptionFilter, JwtAuthenticationFilter.class); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityConfig.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityConfig.java index 7db0010bc..c1ae423d3 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityConfig.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityConfig.java @@ -1,16 +1,25 @@ package kr.co.pennyway.api.config.security; +import com.fasterxml.jackson.databind.ObjectMapper; +import kr.co.pennyway.api.common.security.handler.JwtAccessDeniedHandler; +import kr.co.pennyway.api.common.security.handler.JwtAuthenticationEntryPoint; import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.security.ConditionalOnDefaultWebSecurity; +import org.springframework.boot.autoconfigure.security.SecurityProperties; import org.springframework.boot.autoconfigure.security.servlet.PathRequest; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; import org.springframework.http.HttpMethod; +import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; @@ -19,6 +28,7 @@ @Configuration @EnableWebSecurity +@ConditionalOnDefaultWebSecurity @RequiredArgsConstructor public class SecurityConfig { private static final String[] publicReadOnlyPublicEndpoints = { @@ -26,6 +36,8 @@ public class SecurityConfig { // Swagger "/api-docs/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger", }; + private final ObjectMapper objectMapper; + private final JwtSecurityConfig jwtSecurityConfig; @Bean public BCryptPasswordEncoder bCryptPasswordEncoder() { @@ -33,6 +45,17 @@ public BCryptPasswordEncoder bCryptPasswordEncoder() { } @Bean + public AccessDeniedHandler accessDeniedHandler() { + return new JwtAccessDeniedHandler(objectMapper); + } + + @Bean + public AuthenticationEntryPoint authenticationEntryPoint() { + return new JwtAuthenticationEntryPoint(objectMapper); + } + + @Bean + @Order(SecurityProperties.BASIC_AUTH_ORDER) public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) @@ -41,12 +64,19 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .logout(AbstractHttpConfigurer::disable) .sessionManagement((sessionManagement) -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .with(jwtSecurityConfig, Customizer.withDefaults()) .authorizeHttpRequests( auth -> auth.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() .requestMatchers(HttpMethod.OPTIONS, "*").permitAll() .requestMatchers(HttpMethod.GET, publicReadOnlyPublicEndpoints).permitAll() .anyRequest().permitAll() + ) + .exceptionHandling( + exception -> exception + .accessDeniedHandler(accessDeniedHandler()) + .authenticationEntryPoint(authenticationEntryPoint()) ); + return http.build(); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityFilterConfig.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityFilterConfig.java new file mode 100644 index 000000000..fb32425a6 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityFilterConfig.java @@ -0,0 +1,33 @@ +package kr.co.pennyway.api.config.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import kr.co.pennyway.api.common.security.filter.JwtAuthenticationFilter; +import kr.co.pennyway.api.common.security.filter.JwtExceptionFilter; +import kr.co.pennyway.domain.common.redis.forbidden.ForbiddenTokenService; +import kr.co.pennyway.infra.common.jwt.JwtProvider; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.core.userdetails.UserDetailsService; + +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +@Configuration +public class SecurityFilterConfig { + private final UserDetailsService userDetailServiceImpl; + private final ForbiddenTokenService forbiddenTokenService; + + private final JwtProvider accessTokenProvider; + + private final ObjectMapper objectMapper; + + @Bean + public JwtExceptionFilter jwtExceptionFilter() { + return new JwtExceptionFilter(objectMapper); + } + + @Bean + public JwtAuthenticationFilter jwtAuthorizationFilter() { + return new JwtAuthenticationFilter(userDetailServiceImpl, forbiddenTokenService, accessTokenProvider); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/forbidden/ForbiddenToken.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/forbidden/ForbiddenToken.java new file mode 100644 index 000000000..84dc4cd93 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/forbidden/ForbiddenToken.java @@ -0,0 +1,42 @@ +package kr.co.pennyway.domain.common.redis.forbidden; + +import lombok.Builder; +import lombok.Getter; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; + +@Getter +@RedisHash("forbiddenToken") +public class ForbiddenToken { + @Id + private final String accessToken; + private final Long userId; + @TimeToLive + private final long ttl; + + @Builder + private ForbiddenToken(String accessToken, Long userId, long ttl) { + this.accessToken = accessToken; + this.userId = userId; + this.ttl = ttl; + } + + public static ForbiddenToken of(String accessToken, Long userId, long ttl) { + return new ForbiddenToken(accessToken, userId, ttl); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof ForbiddenToken that)) return false; + return accessToken.equals(that.accessToken) && userId.equals(that.userId); + } + + @Override + public int hashCode() { + int result = accessToken.hashCode(); + result = ((1 << 5) - 1) * result + userId.hashCode(); + return result; + } +} \ No newline at end of file diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/forbidden/ForbiddenTokenRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/forbidden/ForbiddenTokenRepository.java new file mode 100644 index 000000000..83db477af --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/forbidden/ForbiddenTokenRepository.java @@ -0,0 +1,6 @@ +package kr.co.pennyway.domain.common.redis.forbidden; + +import org.springframework.data.repository.CrudRepository; + +public interface ForbiddenTokenRepository extends CrudRepository { +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/forbidden/ForbiddenTokenService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/forbidden/ForbiddenTokenService.java new file mode 100644 index 000000000..12ea5d41b --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/forbidden/ForbiddenTokenService.java @@ -0,0 +1,43 @@ +package kr.co.pennyway.domain.common.redis.forbidden; + +import kr.co.pennyway.common.annotation.DomainService; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.time.Duration; +import java.time.LocalDateTime; + +@Slf4j +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +@DomainService +public class ForbiddenTokenService { + private final ForbiddenTokenRepository forbiddenTokenRepository; + + /** + * 토큰을 블랙 리스트에 등록합니다. + * + * @param accessToken String : 블랙 리스트에 등록할 액세스 토큰 + * @param userId Long : 블랙 리스트에 등록할 유저 아이디 + * @param expiresAt LocalDateTime : 블랙 리스트에 등록할 토큰의 만료 시간 (등록할 access token의 만료시간을 추출한 값) + */ + public void createForbiddenToken(String accessToken, Long userId, LocalDateTime expiresAt) { + final LocalDateTime now = LocalDateTime.now(); + final long timeToLive = Duration.between(now, expiresAt).toSeconds(); + + log.info("forbidden token ttl : {}", timeToLive); + + ForbiddenToken forbiddenToken = ForbiddenToken.of(accessToken, userId, timeToLive); + forbiddenTokenRepository.save(forbiddenToken); + log.info("forbidden token registered. about User : {}", forbiddenToken.getUserId()); + } + + /** + * 토큰이 블랙 리스트에 등록되어 있는지 확인합니다. + * + * @return : 블랙 리스트에 등록되어 있으면 true, 아니면 false + */ + public boolean isForbidden(String accessToken) { + return forbiddenTokenRepository.existsById(accessToken); + } +} \ No newline at end of file diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/exception/JwtErrorCode.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/exception/JwtErrorCode.java index 4568bd417..862f03bfc 100644 --- a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/exception/JwtErrorCode.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/exception/JwtErrorCode.java @@ -33,6 +33,7 @@ public enum JwtErrorCode implements BaseErrorCode { * 403 FORBIDDEN: 인증된 클라이언트가 권한이 없는 자원에 접근 */ FORBIDDEN_ACCESS_TOKEN(FORBIDDEN, ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN, "해당 토큰에는 엑세스 권한이 없습니다"), + TAKEN_AWAY_TOKEN(FORBIDDEN, ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN, "탈취당한 토큰입니다. 다시 로그인 해주세요."), SUSPENDED_OR_BANNED_TOKEN(FORBIDDEN, USER_ACCOUNT_SUSPENDED_OR_BANNED, "사용자 계정이 정지되었습니다"), /**