JWT(JSON Web Token) 을 확인하고 검증하는 Spring Security Filter 를 구현하기 전, 흐름을 살펴보려 합니다.
1. JWT 를 클라이언트 요청에 담기
클라이언트가 매 요청 시 JWT 를 보낼 때 해당 JWT 를 검증해야 하는데 클라이언트는 어떤 식으로 JWT 를 요청에 담을 수 있을까요? 일반적으로 JWT 는 아래와 같이 헤더의 Authorization Header 를 통해 전달합니다.
{
"Authorization": Bearer {JWT}
}
- Bearer 인증 type 의 JWT 을 활용, Request 의 Header 에서 Bearer 를 제외하고 JWT 를 추출하고, 해당 JWT를 검증하여 검증에 성공할 시 Authentication 객체에 인증이 되었다는 것을 표기하고, SecurityContext 에 저장해주면 됩니다.
여기서 잠깐 Spring Security 가 인증을 어떤 방식으로 처리하는지 짚고 넘어가려 합니다.
Authentication 객체는 인증 정보를 담고 있고 이를 통해 인증이 되어있는 여부를 확인하는 것이 Spring Security 인증 방식의 핵심이라고 할 수 있습니다. Authentication 객체는 Principal, Credentials, Authorities를 담고 있습니다.
- Principal : User 의 식별자로, UserId, Email 과 같은 정보를 담고 있는 객체
- Credentials : Password 와 같은 중요한 정보로, 유출 방지를 위해 인증된 이후 삭제
- Authorities : 권한 정보로, 보통 Role 혹은 Scope(어떤 걸 수행할 수 있는지)로 설정
SecurityContext 는 Authentication 객체를 담는 Container 역할을 하고 SecurityContextHolder 는 다시 SecurityContext 를 관리하는 역할을 한다고 이해해 볼 수 있습니다.
정리해보면 인증 필터를 통해 요청을 받아 인증 여부를 판단하고, 인증이 됐을 시 SecurityContextHolder에 Authentication 객체를 할당합니다.
2. Filter 만들기
- 자 이제 돌아와서, 그럼 Filter 를 만들어 줍니다. Filter는 Security의 Filter지만 결국에는 Servlet 에 등록되는 ServletFilter 로써 동작하기에 기본적으로는 Servlet에서 제공하는 Filter 인터페이스를 따라야합니다.
public interface Filter {
default void init(FilterConfig filterConfig) throws ServletException {
}
void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException;
}
default void destroy() {
}
}
결국 doFilter 를 구현하는 것이 핵심인데요, Spring Web 및 Spring Security 에서는 기본 Filter 인터페이스를 상속하여 만든 다양한 필터들을 제공합니다. 여기서 구현하려는 로직에 맞는 Filter 를 골라 다시 상속하여 구현하면 되겠습니다.
- 요구사항은 매요청마다 JWT 를 확인하여 검증하는 것이므로, Spring Web에서 제공하는 OncePerRequestFilter 를 사용합니다.
이제 jwt 패키지에 JwtAuthenticationFilter 를 만들어줍니다.
@Component
class JwtAuthenticationFilter(
private val jwtPlugin: JwtPlugin
) : OncePerRequestFilter() {
companion object {
private val BEARER_PATTERN = Regex("^Bearer (.+?)$")
}
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
val jwt = request.getBearerToken()
if (jwt != null) {
jwtPlugin.validateToken(jwt)
.onSuccess {
val userId = it.payload.subject.toLong()
val role = it.payload.get("role", String::class.java)
val email = it.payload.get("email", String::class.java)
val principal = UserPrincipal(
id = userId,
email = email,
roles = setOf(role)
)
// Authentication 구현체 생성
val authentication = JwtAuthenticationToken(
principal = principal,
// request로 부터 요청 상세정보 생성
details = WebAuthenticationDetailsSource().buildDetails(request)
)
// SecurityContext에 authentication 객체 저장
SecurityContextHolder.getContext().authentication = authentication
}
}
filterChain.doFilter(request, response)
}
private fun HttpServletRequest.getBearerToken(): String? {
val headerValue = this.getHeader(HttpHeaders.AUTHORIZATION) ?: return null
return BEARER_PATTERN.find(headerValue)?.groupValues?.get(1)
}
}
3. 코드 뜯어보기
class JwtAuthenticationFilter(
private val jwtPlugin: JwtPlugin
) : OncePerRequestFilter()
- "JwtAuthenticationFilter" 는 "OncePerRequestFilter" 를 상속하여 매요청마다 한 번씩만 실행됩니다.
- 이전에 만들어놓은 JwtPlugin Class 는 JWT 를 검증하고, 생성하는 메소드를 가지고 있습니다. "jwtPlugin" 은 JWT 토큰을 검증하기 위한 의존성이며, 해당 Filter 는 "jwtPlugin" 을 통해 JWT 의 유효성을 확인합니다.
companion object {
private val BEARER_PATTERN = Regex("^Bearer (.+?)$")
}
- "BEARER_PATTERN" *정규 표현식으로 "Bearer" 로 시작하는 문자열에서 JWT 토큰을 추출합니다.
* 정규 표현식 : 정규 표현식(Regex, Regular Expression)은 문자열에서 특정한 패턴을 검색, 추출, 대체하는 데 사용되는 문자열입니다. 정규 표현식은 복잡한 문자열 매칭과 조작을 간결하게 표현할 수 있어 매우 유용합니다.
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
)
- "doFilterInternal" Method 는 Filter 의 핵심으로, 각 Http 요청을 처리하는데요, 위에서 설명한 doFilter 의 코드를 참고하면 이해하는데 더욱 도움이 될 수 있으니 참고 부탁드립니다.
여기서 잠시 doFilter 가 왜 핵심인지 알아보려 합니다.
"doFilterInternal" Method 는 필터가 실제로 요청을 처리하는 부분이기 때문에 핵심이라 볼 수 있습니다. 이 메서드에서 JWT 추출하고 검증하며, 검증된 토큰의 정보를 바탕으로 인증 객체를 생성하고, 이를 Spring Security 의 컨텍스트에 설정합니다. 이렇게 설정된 인증 객체는 이후의 보안 작업에서 사용됩니다!
또한 필터 체인에서 다음 필터로 요청을 전달하여 전체 필터 체인이 정상적으로 작동하도록 합니다. 만약 이 메서드가 제대로 작동하지 않으면, 요청에 대한 인증이 올바르게 수행되지 않거나, 필터 체인이 중단될 수 있습니다.
val jwt = request.getBearerToken()
- Request Header 에서 JWT 토큰을 추출합니다.
if (jwt != null) {
jwtPlugin.validateToken(jwt)
.onSuccess {
val userId = it.payload.subject.toLong()
val role = it.payload.get("role", String::class.java)
val email = it.payload.get("email", String::class.java)
- 추출된 JWT 가 null 이 아닌 경우, 이전에 만들어 놓은 jwtPlugin 을 사용 토큰을 검증합니다.
- 검증된 토큰의 Payload 에서 UserId, role, email 정보를 추출합니다.
val principal = UserPrincipal(
id = userId,
email = email,
roles = setOf(role)
)
val authentication = JwtAuthenticationToken(
principal = principal,
details = WebAuthenticationDetailsSource().buildDetails(request)
)
SecurityContextHolder.getContext().authentication = authentication
- UserPrincipal 객체를 생성하여 Payload 에서 추출한 사용자의 정보를 저장합니다.
// UserPrincipal Data class 참고
data class UserPrincipal(
val id : Long,
val email : String,
val authorities : Collection<GrantedAuthority>
) {
constructor(id: Long, email: String, roles: Set<String>): this(
id,
email,
roles.map { SimpleGrantedAuthority("ROLE_$it") }
)
}
- Authentication 구현체를 "JwtAuthenticationToken" 객체를 생성하여 인증 정보를 포함합니다.
- "WebAuthenticationDetailsSource().buildDetails(request)" 요청으로부터 인증 세부 정보를 생성합니다.
- "SecurityContextHolder.getContext().authentication = authentication" 를 통해 SecurityContextHolder 안 SecurityContext에 authentication 객체를 저장합니다.
filterChain.doFilter(request, response)
- 필터 체인을 계속 진행하여 다음 필터로 요청과 응답을 전달합니다.
private fun HttpServletRequest.getBearerToken(): String? {
val headerValue = this.getHeader(HttpHeaders.AUTHORIZATION) ?: return null
return BEARER_PATTERN.find(headerValue)?.groupValues?.get(1)
}
- 요청 헤더에서 Authorization Header 값을 가져와 정규 표현식을 사용하여 "Bearer" 로 시작하는 토큰을 추출합니다.
- 토큰이 없거나 형식이 맞지 않으면 null을 반환합니다.
다소 복잡할 수 있는 Filter 부분을 정리해보았는데요 .. ChatGpt 의 도움을 받아, 더 더 확실하게 이해할 수 있었습니다 ..
혹시나 누락된 부분이나, 더 중요한 정보가 있다면 댓글 부탁드립니다.
너무 어려운 인증 인가.. 다들 화이팅