From b7935e8f475bf1ab08f48dbcbfd501ac431982a9 Mon Sep 17 00:00:00 2001 From: JaeUk Date: Mon, 23 Sep 2024 18:06:13 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20Spring=20Security=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D/=EC=9D=B8=EA=B0=80=20=EB=A1=9C=EC=A7=81=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../routebox/domain/user/UserService.kt | 11 ++++ .../security/CustomUserDetailsService.kt | 27 -------- .../security/JwtAccessDeniedHandler.kt | 2 +- .../security/JwtAuthenticationEntryPoint.kt | 2 +- .../security/JwtAuthenticationFilter.kt | 63 ++++++++++++++++--- .../routebox/security/JwtExceptionFilter.kt | 48 -------------- .../routebox/routebox/security/JwtManager.kt | 35 ++++++----- .../routebox/security/SecurityConfig.kt | 49 +++++++-------- .../routebox/config/TestSecurityConfig.kt | 49 ++++++--------- 9 files changed, 123 insertions(+), 163 deletions(-) delete mode 100644 src/main/kotlin/com/routebox/routebox/security/CustomUserDetailsService.kt delete mode 100644 src/main/kotlin/com/routebox/routebox/security/JwtExceptionFilter.kt diff --git a/src/main/kotlin/com/routebox/routebox/domain/user/UserService.kt b/src/main/kotlin/com/routebox/routebox/domain/user/UserService.kt index 88c8243..6d53b29 100644 --- a/src/main/kotlin/com/routebox/routebox/domain/user/UserService.kt +++ b/src/main/kotlin/com/routebox/routebox/domain/user/UserService.kt @@ -13,6 +13,7 @@ import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import org.springframework.web.multipart.MultipartFile import java.time.LocalDate +import kotlin.jvm.optionals.getOrNull @Service class UserService( @@ -35,6 +36,16 @@ class UserService( fun getUserById(id: Long): User = userRepository.findById(id).orElseThrow { UserNotFoundException() } + /** + * Id(PK)로 유저를 조회한다. + * + * @param id 조회할 유저의 id + * @return 조회된 user entity(nullable) + */ + @Transactional(readOnly = true) + fun findUserById(id: Long): User? = + userRepository.findById(id).getOrNull() + /** * Social login uid로 유저를 조회한다. * diff --git a/src/main/kotlin/com/routebox/routebox/security/CustomUserDetailsService.kt b/src/main/kotlin/com/routebox/routebox/security/CustomUserDetailsService.kt deleted file mode 100644 index 65320df..0000000 --- a/src/main/kotlin/com/routebox/routebox/security/CustomUserDetailsService.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.routebox.routebox.security - -import com.routebox.routebox.domain.user.UserService -import com.routebox.routebox.exception.user.UserWithdrawnException -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration -import org.springframework.security.core.userdetails.UserDetailsService - -@Configuration -class CustomUserDetailsService { - - @Bean - fun userDetailsService(userService: UserService): UserDetailsService = - UserDetailsService { username -> - val user = userService.getUserById(username.toLong()) - - if (user.deletedAt != null) { - throw UserWithdrawnException() - } - - UserPrincipal( - userId = user.id, - socialLoginUid = user.socialLoginUid, - userRoles = user.roles, - ) - } -} diff --git a/src/main/kotlin/com/routebox/routebox/security/JwtAccessDeniedHandler.kt b/src/main/kotlin/com/routebox/routebox/security/JwtAccessDeniedHandler.kt index a8142f4..07be754 100644 --- a/src/main/kotlin/com/routebox/routebox/security/JwtAccessDeniedHandler.kt +++ b/src/main/kotlin/com/routebox/routebox/security/JwtAccessDeniedHandler.kt @@ -35,7 +35,7 @@ class JwtAccessDeniedHandler : AccessDeniedHandler { ObjectMapper().writeValueAsString( ErrorResponse( CustomExceptionType.ACCESS_DENIED.code, - CustomExceptionType.ACCESS_DENIED.message, + "${CustomExceptionType.ACCESS_DENIED.message} ${accessDeniedException.message}", ), ), ) diff --git a/src/main/kotlin/com/routebox/routebox/security/JwtAuthenticationEntryPoint.kt b/src/main/kotlin/com/routebox/routebox/security/JwtAuthenticationEntryPoint.kt index 1ea6acc..23a8ce6 100644 --- a/src/main/kotlin/com/routebox/routebox/security/JwtAuthenticationEntryPoint.kt +++ b/src/main/kotlin/com/routebox/routebox/security/JwtAuthenticationEntryPoint.kt @@ -35,7 +35,7 @@ class JwtAuthenticationEntryPoint : AuthenticationEntryPoint { ObjectMapper().writeValueAsString( ErrorResponse( CustomExceptionType.ACCESS_DENIED.code, - CustomExceptionType.ACCESS_DENIED.message, + "${CustomExceptionType.ACCESS_DENIED.message} ${authenticationException.message}", ), ), ) diff --git a/src/main/kotlin/com/routebox/routebox/security/JwtAuthenticationFilter.kt b/src/main/kotlin/com/routebox/routebox/security/JwtAuthenticationFilter.kt index 6b8011c..d44a57e 100644 --- a/src/main/kotlin/com/routebox/routebox/security/JwtAuthenticationFilter.kt +++ b/src/main/kotlin/com/routebox/routebox/security/JwtAuthenticationFilter.kt @@ -1,23 +1,31 @@ package com.routebox.routebox.security +import com.routebox.routebox.domain.user.UserService +import com.routebox.routebox.exception.security.InvalidTokenException +import com.routebox.routebox.exception.user.UserNotFoundException +import com.routebox.routebox.security.SecurityConfig.Companion.AUTH_WHITE_LIST +import com.routebox.routebox.security.SecurityConfig.Companion.AUTH_WHITE_PATHS import jakarta.servlet.FilterChain import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse import org.springframework.http.HttpHeaders +import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException +import org.springframework.security.authentication.BadCredentialsException import org.springframework.security.authentication.UsernamePasswordAuthenticationToken import org.springframework.security.core.context.SecurityContextHolder -import org.springframework.security.core.userdetails.UserDetailsService import org.springframework.stereotype.Component +import org.springframework.util.AntPathMatcher import org.springframework.web.filter.OncePerRequestFilter @Component class JwtAuthenticationFilter( private val jwtManager: JwtManager, - private val userDetailsService: UserDetailsService, + private val userService: UserService, ) : OncePerRequestFilter() { companion object { private const val TOKEN_TYPE_BEARER_PREFIX = "Bearer " + private val pathMatcher = AntPathMatcher() } /** @@ -33,32 +41,67 @@ class JwtAuthenticationFilter( response: HttpServletResponse, filterChain: FilterChain, ) { - val accessToken = getAccessToken(request) - if (!accessToken.isNullOrBlank()) { - try { + if (isRequiredAuth(request.requestURI, request.method)) { + val accessToken = getAccessTokenFromHeader(request) + if (accessToken.isNullOrBlank()) { + throw AuthenticationCredentialsNotFoundException("Access token does not exist.") + } + + runCatching { jwtManager.validate(accessToken) val userId = jwtManager.getUserIdFromToken(accessToken) - val userDetails = userDetailsService.loadUserByUsername(userId.toString()) - val authentication = UsernamePasswordAuthenticationToken(userDetails, "", userDetails.authorities) + val userPrincipal = loadUserPrincipal(userId) + val authentication = UsernamePasswordAuthenticationToken(userPrincipal, "", userPrincipal.authorities) SecurityContextHolder.getContext().authentication = authentication - } catch (ignored: Exception) { - // 인증 권한 설정 중 에러가 발생하면 권한을 부여하지 않고 다음 단계로 진행 + }.getOrElse { e -> + if (e is InvalidTokenException) { + throw BadCredentialsException("Invalid access token.") + } else { + throw e + } } } + filterChain.doFilter(request, response) } + /** + * 인증/인가 권한이 필요한 요청인지 확인한다. + */ + private fun isRequiredAuth(uri: String, method: String): Boolean { + if (AUTH_WHITE_PATHS.any { authWhitePath -> pathMatcher.match(authWhitePath, uri) }) { + return false + } + if (AUTH_WHITE_LIST.any { (path, httpMethod) -> pathMatcher.match(path, uri) && httpMethod.name() == method }) { + return false + } + return true + } + /** * Request의 header에서 token을 읽어온다. * * @param request Request 객체 * @return Header에서 추출한 token */ - fun getAccessToken(request: HttpServletRequest): String? { + private fun getAccessTokenFromHeader(request: HttpServletRequest): String? { val authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION) if (authorizationHeader == null || !authorizationHeader.startsWith(TOKEN_TYPE_BEARER_PREFIX)) { return null } return authorizationHeader.substring(TOKEN_TYPE_BEARER_PREFIX.length) } + + private fun loadUserPrincipal(userId: Long): UserPrincipal { + val user = userService.findUserById(userId) + if (user == null || user.deletedAt != null) { + throw UserNotFoundException() + } + + return UserPrincipal( + userId = user.id, + socialLoginUid = user.socialLoginUid, + userRoles = user.roles, + ) + } } diff --git a/src/main/kotlin/com/routebox/routebox/security/JwtExceptionFilter.kt b/src/main/kotlin/com/routebox/routebox/security/JwtExceptionFilter.kt deleted file mode 100644 index 8d0d9e2..0000000 --- a/src/main/kotlin/com/routebox/routebox/security/JwtExceptionFilter.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.routebox.routebox.security - -import com.fasterxml.jackson.databind.ObjectMapper -import com.routebox.routebox.exception.CustomExceptionType -import com.routebox.routebox.exception.ErrorResponse -import com.routebox.routebox.exception.security.InvalidTokenException -import jakarta.servlet.FilterChain -import jakarta.servlet.http.HttpServletRequest -import jakarta.servlet.http.HttpServletResponse -import org.springframework.stereotype.Component -import org.springframework.web.filter.OncePerRequestFilter - -/** - * `JwtAuthenticationFilter`에서 발생하는 에러를 처리하기 위한 filter - * - * @see JwtAuthenticationFilter - */ -@Component -class JwtExceptionFilter : OncePerRequestFilter() { - override fun doFilterInternal( - request: HttpServletRequest, - response: HttpServletResponse, - filterChain: FilterChain, - ) { - try { - filterChain.doFilter(request, response) - } catch (ex: InvalidTokenException) { - setErrorResponse(CustomExceptionType.INVALID_TOKEN, response) - } - } - - /** - * Exception 정보를 입력받아 응답할 error response를 설정한다. - * - * @param exceptionType exception type - * @param response HttpServletResponse 객체 - */ - private fun setErrorResponse( - exceptionType: CustomExceptionType, - response: HttpServletResponse, - ) { - response.status = HttpServletResponse.SC_UNAUTHORIZED - response.characterEncoding = "utf-8" - response.contentType = "application/json; charset=UTF-8" - val errorResponse = ErrorResponse(exceptionType.code, exceptionType.message) - ObjectMapper().writeValue(response.outputStream, errorResponse) - } -} diff --git a/src/main/kotlin/com/routebox/routebox/security/JwtManager.kt b/src/main/kotlin/com/routebox/routebox/security/JwtManager.kt index 66c7c89..ce6fc97 100644 --- a/src/main/kotlin/com/routebox/routebox/security/JwtManager.kt +++ b/src/main/kotlin/com/routebox/routebox/security/JwtManager.kt @@ -99,24 +99,12 @@ class JwtManager(@Value("\${routebox.jwt.secret-key}") private val salt: String) private fun getClaimsFromToken(token: String): Claims = getJwsFromToken(token).body - private fun getJwsFromToken(token: String): Jws = - Jwts.parserBuilder() - .setSigningKey(secretKey) - .build() - .parseClaimsJws(token) - - /** - * 토큰의 유효성, 만료일자 검증 - * - * @param token 검증하고자 하는 JWT token - * @throws InvalidTokenException Token 값이 잘못되거나 만료되어 유효하지 않은 경우 - */ - fun validate(token: String) { - if (token.isBlank()) { - throw InvalidTokenException("The token is empty") - } + private fun getJwsFromToken(token: String): Jws { try { - getJwsFromToken(token) + return Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token) } catch (ex: UnsupportedJwtException) { throw InvalidTokenException("The claimsJws argument does not represent an Claims JWS", ex) } catch (ex: MalformedJwtException) { @@ -129,4 +117,17 @@ class JwtManager(@Value("\${routebox.jwt.secret-key}") private val salt: String) throw InvalidTokenException("The claimsJws string is null or empty or only whitespace", ex) } } + + /** + * 토큰의 유효성, 만료일자 검증 + * + * @param token 검증하고자 하는 JWT token + * @throws InvalidTokenException Token 값이 잘못되거나 만료되어 유효하지 않은 경우 + */ + fun validate(token: String) { + if (token.isBlank()) { + throw InvalidTokenException("The token is empty") + } + getJwsFromToken(token) + } } diff --git a/src/main/kotlin/com/routebox/routebox/security/SecurityConfig.kt b/src/main/kotlin/com/routebox/routebox/security/SecurityConfig.kt index f5abdb5..8609b35 100644 --- a/src/main/kotlin/com/routebox/routebox/security/SecurityConfig.kt +++ b/src/main/kotlin/com/routebox/routebox/security/SecurityConfig.kt @@ -16,14 +16,14 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource class SecurityConfig { companion object { // Authentication white paths (HTTP method 상관 X) - private val AUTH_WHITE_PATHS = listOf( + val AUTH_WHITE_PATHS = listOf( "/swagger-ui/**", "/v3/api-docs/**", "/actuator/health", ) // Authentication white list (특정 endpoint, HTTP method에 대해서만) - private val AUTH_WHITE_LIST = mapOf( + val AUTH_WHITE_LIST = mapOf( "/api/v1/auth/login/kakao" to HttpMethod.POST, "/api/v1/auth/login/apple" to HttpMethod.POST, "/api/v1/auth/tokens/refresh" to HttpMethod.POST, @@ -38,33 +38,26 @@ class SecurityConfig { jwtAccessDeniedHandler: JwtAccessDeniedHandler, jwtAuthenticationEntryPoint: JwtAuthenticationEntryPoint, jwtAuthenticationFilter: JwtAuthenticationFilter, - jwtExceptionFilter: JwtExceptionFilter, - ): SecurityFilterChain { - return httpSecurity - .csrf { it.disable() } - .httpBasic { it.disable() } - .formLogin { it.disable() } - .sessionManagement { session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } - .cors { - it.configurationSource(corsConfigurationSource()) + ): SecurityFilterChain = httpSecurity + .csrf { it.disable() } + .httpBasic { it.disable() } + .formLogin { it.disable() } + .sessionManagement { session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } + .cors { it.configurationSource(corsConfigurationSource()) } + .authorizeHttpRequests { auth -> + auth.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() + AUTH_WHITE_PATHS.forEach { authWhitePath -> auth.requestMatchers(authWhitePath).permitAll() } + AUTH_WHITE_LIST.forEach { (path: String, httpMethod: HttpMethod) -> + auth.requestMatchers(httpMethod, path).permitAll() } - .authorizeHttpRequests { auth -> - auth.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() - AUTH_WHITE_PATHS.forEach { authWhitePath -> auth.requestMatchers(authWhitePath).permitAll() } - AUTH_WHITE_LIST.forEach { (path: String, httpMethod: HttpMethod) -> - auth.requestMatchers(httpMethod, path).permitAll() - } - auth.anyRequest().authenticated() - } - .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java) - .addFilterBefore(jwtExceptionFilter, jwtAuthenticationFilter.javaClass) - .exceptionHandling { exceptionHandlingConfigurer -> - exceptionHandlingConfigurer - .accessDeniedHandler(jwtAccessDeniedHandler) - .authenticationEntryPoint(jwtAuthenticationEntryPoint) - } - .build() - } + auth.anyRequest().authenticated() + } + .addFilterAt(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java) + .exceptionHandling { exceptionHandlingConfigurer -> + exceptionHandlingConfigurer + .accessDeniedHandler(jwtAccessDeniedHandler) + .authenticationEntryPoint(jwtAuthenticationEntryPoint) + }.build() @Bean fun corsConfigurationSource(): CorsConfigurationSource { diff --git a/src/test/kotlin/com/routebox/routebox/config/TestSecurityConfig.kt b/src/test/kotlin/com/routebox/routebox/config/TestSecurityConfig.kt index e1d8446..8a60ca1 100644 --- a/src/test/kotlin/com/routebox/routebox/config/TestSecurityConfig.kt +++ b/src/test/kotlin/com/routebox/routebox/config/TestSecurityConfig.kt @@ -1,52 +1,39 @@ package com.routebox.routebox.config -import com.routebox.routebox.domain.user.User -import com.routebox.routebox.domain.user.UserService -import com.routebox.routebox.domain.user.constant.Gender -import com.routebox.routebox.domain.user.constant.LoginType -import com.routebox.routebox.security.CustomUserDetailsService import com.routebox.routebox.security.JwtAccessDeniedHandler import com.routebox.routebox.security.JwtAuthenticationEntryPoint import com.routebox.routebox.security.JwtAuthenticationFilter -import com.routebox.routebox.security.JwtExceptionFilter -import com.routebox.routebox.security.JwtManager import com.routebox.routebox.security.SecurityConfig -import org.mockito.ArgumentMatchers.anyLong -import org.mockito.kotlin.given +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.mockito.kotlin.mock import org.springframework.boot.test.context.TestConfiguration -import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Import -import org.springframework.test.context.event.annotation.BeforeTestMethod -import java.time.LocalDate -import kotlin.random.Random @Import( value = [ SecurityConfig::class, JwtAccessDeniedHandler::class, - JwtAuthenticationFilter::class, JwtAuthenticationEntryPoint::class, - JwtExceptionFilter::class, - CustomUserDetailsService::class, - JwtManager::class, ], ) @TestConfiguration class TestSecurityConfig { - @MockBean - lateinit var userService: UserService - @BeforeTestMethod - fun securitySetUp() { - given(userService.getUserById(anyLong())).willReturn( - User( - id = Random.nextLong(1, 10000), - loginType = LoginType.KAKAO, - socialLoginUid = Random.toString(), - nickname = Random.toString(), - gender = Gender.PRIVATE, - birthDay = LocalDate.of(2024, 1, 1), - ), - ) + @Bean + fun jwtAuthenticationFilter(): JwtAuthenticationFilter { + return TestJwtAuthenticationFilter() // 테스트용 필터를 반환 + } + + class TestJwtAuthenticationFilter : JwtAuthenticationFilter(mock(), mock()) { + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain, + ) { + filterChain.doFilter(request, response) + } } }