Skip to content

Commit

Permalink
refactor: Spring Security 인증/인가 로직 수정
Browse files Browse the repository at this point in the history
  • Loading branch information
Wo-ogie committed Sep 23, 2024
1 parent 5153e26 commit b7935e8
Show file tree
Hide file tree
Showing 9 changed files with 123 additions and 163 deletions.
11 changes: 11 additions & 0 deletions src/main/kotlin/com/routebox/routebox/domain/user/UserService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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로 유저를 조회한다.
*
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ class JwtAccessDeniedHandler : AccessDeniedHandler {
ObjectMapper().writeValueAsString(
ErrorResponse(
CustomExceptionType.ACCESS_DENIED.code,
CustomExceptionType.ACCESS_DENIED.message,
"${CustomExceptionType.ACCESS_DENIED.message} ${accessDeniedException.message}",
),
),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ class JwtAuthenticationEntryPoint : AuthenticationEntryPoint {
ObjectMapper().writeValueAsString(
ErrorResponse(
CustomExceptionType.ACCESS_DENIED.code,
CustomExceptionType.ACCESS_DENIED.message,
"${CustomExceptionType.ACCESS_DENIED.message} ${authenticationException.message}",
),
),
)
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}

/**
Expand All @@ -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,
)
}
}

This file was deleted.

35 changes: 18 additions & 17 deletions src/main/kotlin/com/routebox/routebox/security/JwtManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<Claims> =
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<Claims> {
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) {
Expand All @@ -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)
}
}
49 changes: 21 additions & 28 deletions src/main/kotlin/com/routebox/routebox/security/SecurityConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down
Loading

0 comments on commit b7935e8

Please sign in to comment.