스프링 시큐리티를 활용하여 인증, 인가를 구현하였다. 진행한 과정, 고민한 것들을 기록해 보려한다.
이 프로젝트는 현재 기술 스택은 다음과 같다.
springboot 3.2.1
spring security 6.2.1
kotlin
시큐리티 6버전에서 설정방법, 인터페이스들의 변경이 있어 이전 버전과 차이가 있음에 유의해야한다.
진행 사항을 크게 보면 다음과 같다.
설명은 바텀업으로 진행된다. 실제 인증, 인가를 처리하는 곳을 먼저 보고 추상화된 클래스들을 올라가면서 살펴볼 것이다.
//security
implementation("org.springframework.boot:spring-boot-starter-security")
//jwt
implementation("io.jsonwebtoken:jjwt-api:0.11.5")
implementation("io.jsonwebtoken:jjwt-impl:0.11.5")
implementation("io.jsonwebtoken:jjwt-jackson:0.11.5")
현재 나의 프로젝트에서 민감정보는 커밋되지 않도록 분리해둔 상태다. 민감정보는
secure-jwt.properteis
에서 파일에서 따로 관리 중이다. secure-jwt.properteis에서
access-expiration-time=3600000
refresh-expiration-time=604800000
토큰의 주기도 민감정보로 분리해 놓았다. 얼마만큼 살아있는지도 모르게 해야할 것 같아서. access token
의 경우 1시간
, refresh token
의 경우 일주일
동안 살아있을 수 있게 설정하였다.
인증의 경우 나에게 2가지의 선택지가 있었다.
filter
로 진행하였다.AuthenticationProvider
를 구현한 Jwt용 Provider
UserDetailsService
UsernamePasswordAuthenticationFilter
를 상속 받은 JwtAuthenticationFilter
@Component
@PropertySource("classpath:/secure-jwt.properties")
class JwtTokenProvider (
private val userDetailsService: UserDetailsService,
private val encryptor: Encryptor
): AuthenticationProvider {
override fun authenticate(authentication: Authentication): Authentication {
val username = authentication.name
val password = authentication.credentials.toString()
val userDetails = userDetailsService.loadUserByUsername(username)
if(!encryptor.matches(password, userDetails.password)){
throw BadCredentialsException("Invalid Password")
}
return UsernamePasswordAuthenticationToken(userDetails, password, userDetails.authorities)
}
override fun supports(authentication: Class<*>): Boolean {
return authentication == UsernamePasswordAuthenticationToken::class.java
}
}
AuthenticationManager
로부터 위임받은 인증을 authenticate메서드를 오버라이드하여 인증을 진행한다. 우선 토큰이 올바른지 체크 후 사용자의 비밀번호를 체크하게 된다.
이때 사용자 정보를 가져와서 비밀번호를 맞춰보고 인증을 진행해야하는데 스프링시큐리티는 사용자 정보를 가져오는 부분도 추상화를 해놓았다. UserDetailsService인터페이스
를 구현하여 provider에 의존성 주입을 해주면 원하는 방식의 커스텀 인증이 가능하다.
@Component
class UserDetailServiceImpl (
private val userRepository: UserRepository
): UserDetailsService {
override fun loadUserByUsername(email: String): UserDetails {
val user = (userRepository.findByEmail(email)
?: throw UsernameNotFoundException(AuthExceptionType.INVALID_EMAIL.message))
val grantedAuthorities: Set<GrantedAuthority> = HashSet()
return org.springframework.security.core.userdetails
.User(user.email, user.password, grantedAuthorities)
}
}
나의 경우는 UserRepository를 통해 유저 정보를 가져오고 있다.
class JwtAuthenticationFilter(
private val authenticationManager: AuthenticationManager,
private val redisTemplate: RedisTemplate<String, String>,
private val accessExpirationTime: Long,
private val refreshExpirationTime: Long
) : UsernamePasswordAuthenticationFilter() {
init {
setFilterProcessesUrl("/v1/login")
}
override fun attemptAuthentication(request: HttpServletRequest, response: HttpServletResponse): Authentication {
try {
val gson = Gson()
val loginRequest: LogInRequest = gson.fromJson(InputStreamReader(request.inputStream, Charsets.UTF_8), LogInRequest::class.java)
val authenticationToken = UsernamePasswordAuthenticationToken(loginRequest.email, loginRequest.password)
return authenticationManager.authenticate(authenticationToken)
} catch (e: Exception) {
throw e
}
}
override fun successfulAuthentication(
request: HttpServletRequest,
response: HttpServletResponse,
chain: FilterChain,
authResult: Authentication
) {
val userDetails = authResult.principal as UserDetails
val accessToken = JwtTokenHelper.createAccessToken(userDetails.username, accessExpirationTime)
val refreshToken = JwtTokenHelper.createRefreshToken(userDetails.username, refreshExpirationTime)
saveRefreshToken(userDetails.username, refreshToken)
response.contentType = MediaType.APPLICATION_JSON_VALUE
response.writer.write(Gson().toJson(LogInResponse(LogInStatus.SUCCESS, accessToken, refreshToken)))
response.setHeader(HttpHeaders.AUTHORIZATION, accessToken)
}
override fun unsuccessfulAuthentication(
request: HttpServletRequest,
response: HttpServletResponse,
failed: AuthenticationException
) {
throw failed
}
}
필터에서는 UsernamePasswordAuthenticationFilter
를 상속 받았고 인증을 시도하는 메소드
, 인증 시도 후 콜백 함수
, 예외 발생했을 때 처리하는 함수
를 오버라이드 했다.
인증의 경우에는 로그인을 진행할 때만 해당 필터를 실행해야한다. setFilterProcessesUrl
를 통해 내가 적은 url만 필터를 진행할 수 있다.
인증을 다 마치고 나면 access token
, refresh token
을 발급한다. 이때 refresh token은 레디스에 저장되고 내가 설정한 시간(1주일)이 지나면 만료된다.
unsuccessfulAuthentication의 경우 단순히 예외를 던지기만 하는데 이는 예외 공통처리 부분에서 다시 설명하겠다.
여기서 주목해야할 점은 스프링은 filter를 사용해서 인가를 처리한다는 것이다. 그래서 내가 커스텀 인가 로직을 추가하고 싶다면 커스텀 필터를 만들고 이 필터체인에 등록해주어야한다.
class JwtAuthorizationFilter: OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
val accessToken = request.getHeader(HttpHeaders.AUTHORIZATION)
accessToken?.let {
if (JwtTokenHelper.validateToken(accessToken, ACCESS_SECRET_KEY)) {
val parseClaimsJws = JwtTokenHelper.parseJwtToken(accessToken, ACCESS_SECRET_KEY)
val userId = parseClaimsJws!!.body.subject
SecurityContextHolder.getContext().authentication = UserAuthentication(userId)
}
}
filterChain.doFilter(request, response)
}
}
OncePerRequestFilter
를 상속받아 doFilterInternal
를 오버라이드했다. access token을 검증하여 올바른 토큰의 경우 쓰레드 로컬에서 관리가 되는 securityContext를 추가했다. 우리는 이 컨텍스트를 가지고 스프링 어디에서나 컨텍스트에 저장한 데이터에 접근할 수 있다.
@Configuration
@EnableWebSecurity
class WebSecurityConfig (
private val jwtTokenProvider: JwtTokenProvider,
private val redisTemplate: RedisTemplate<String, String>,
@Value("\${spring.jwt.token.access-expiration-time}")
private val accessExpirationTime: Long,
@Value("\${spring.jwt.token.refresh-expiration-time}")
private val refreshExpirationTime: Long
) {
@Bean
fun authenticationManager(http: HttpSecurity): AuthenticationManager {
val authenticationManagerBuilder = http.getSharedObject(
AuthenticationManagerBuilder::class.java
)
authenticationManagerBuilder.authenticationProvider(jwtTokenProvider)
return authenticationManagerBuilder.build()
}
@Bean
fun securityFilterChain(http: HttpSecurity, authenticationManager: AuthenticationManager): SecurityFilterChain {
return http
.authorizeHttpRequests { requests ->
requests
.requestMatchers("/signup", "/v1/login", "/accesstoken").permitAll()
.anyRequest().authenticated()
}
.csrf{csrfConfig: CsrfConfigurer<HttpSecurity> -> csrfConfig.disable()}
.sessionManagement{sessionConfig -> sessionConfig.sessionCreationPolicy(SessionCreationPolicy.STATELESS)}
.addFilterBefore(JwtExceptionFilter(), UsernamePasswordAuthenticationFilter::class.java)
.addFilterBefore(JwtAuthenticationFilter(authenticationManager, redisTemplate, accessExpirationTime, refreshExpirationTime), UsernamePasswordAuthenticationFilter::class.java)
.addFilterBefore(JwtAuthorizationFilter(), UsernamePasswordAuthenticationFilter::class.java)
.exceptionHandling {access ->
access.accessDeniedHandler{ _, response, _ ->
response.status = HttpServletResponse.SC_UNAUTHORIZED
}
access.authenticationEntryPoint{_, response, _ ->
response.status = HttpServletResponse.SC_FORBIDDEN
}
}.build()
}
@Bean
fun ignoringCustomizer(): WebSecurityCustomizer {
return WebSecurityCustomizer { web: WebSecurity ->
web.ignoring().requestMatchers(
"/resources/**",
"/static/**",
"/favicon.ico",
"/error"
)
}
}
}
JwtTokenProvider
를 authenticationManager
에 등록을 해주어야한다.
securityFilterChain 메소드를 통해 필터체인을 설정할 수 있다. 여기서 내가 오해했던 것이 permitAll()을 하면 아예 시큐리티 필터체인을 실행하지 않는다고 생각했으나 사실은 필터체인을 모두 실행하고 오류가 나와도 그 오류를 무시하고 내가 접근하려는 url을 실행하는 것이였다.
세션과 csrf는 사용하지 않어서 사용하지 않음으로 설정했다.
내가 정의한 custom filter들을 등록해주었다.
exceptionHandling
에서는 필터 수행 중 JwtExceptionFilter
에서 처리하지 않은 예외들을 처리한다. 전역 예외처리 같이 사용한다. AccessDeniedHandler
, AuthenticationEntryPoint
클래스를 만들어 등록하는 것도 가능하지만 함수가 1개인 인터페이스이기 때문에 람다로도 처리가 가능하다. 나는 간단하게 람다를 이용해 등록하였다.
WebSecurity 통해 전역 설정을 해주었다. 항상 접근할 수 있어야하는 자원의 경우 전역 설정으로 등록해주었다.
HttpSecurity vs. WebSecurity
내가 생각한 정책은
access token
과 refresh token
을 클라이언트에게 발급한다.access token
을 가지고 인가를 시도한다.access token
이 만료되었다면 클라이언트는 refresh token
을 가지고 access token
을 발급받는다.유저에게 access token
과 refresh token
을 발급할 때 같은 시크릿 키를 쓴다고 해보자. 내 로직에서는 어떤 나쁜 사람이 refresh token를 탈취하여 인증 토큰을 대신해서 인가를 했을 때 인가가 되어진다. 따라서 나는 access token과 refresh token의 secret key를 다르게 설정하였다.
val ACCESS_SECRET_KEY: SecretKey = Keys.secretKeyFor(SignatureAlgorithm.HS256)
val REFRESH_SECRET_KEY: SecretKey = Keys.secretKeyFor(SignatureAlgorithm.HS256)
필터체인은 servlet 기반이기 때문에 @ControllerAdvice
를 사용할 수 없다. servlet 안에서 예외가 났을 때 일정한 형태의 respone를 내려주고 싶었고 찾은 방법이 예외 처리 필터를 만드는 것이였다.
다음과 같이 필터 기반으로 필터가 다음 필터를 호출하는 형태로 되어있다. 예외를 처리해주는 필터를 인증, 인가 필터의 상단에 위치시켜 예외 처리를 해주는 형식으로 진행했다.
class JwtExceptionFilter: OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
try {
filterChain.doFilter(request, response)
} catch (e: BadCredentialsException) {
setErrorResponse(response, AuthExceptionType.INVALID_PASSWORD)
} catch (e: UsernameNotFoundException) {
setErrorResponse(response, AuthExceptionType.INVALID_EMAIL)
} catch (e: ExpiredJwtException) {
setErrorResponse(response, AuthExceptionType.EXPIRED_TOKEN)
} catch (e: JwtException) {
setErrorResponse(response, AuthExceptionType.INVALID_TOKEN)
} catch (e: Exception) {
throw e
}
}
private fun setErrorResponse(response: HttpServletResponse, authExceptionType: AuthExceptionType) {
response.status = authExceptionType.httpStatusCode.value()
response.contentType = MediaType.APPLICATION_JSON_VALUE
response.writer.write(Gson().toJson(StatusDataResult(authExceptionType.name, authExceptionType.message)))
}
}
각각의 필터들에서는 예외를 던지기만하고 예외 처리 필터에서 잡아 일관된 형태의 응답을 내려주게 하였다. 다음과 같이 status, data로 예외가 내려온다.
{
"status": "INVALID_ACCESS_TOKEN",
"data": "invalid access token"
}