이전의 개인 프로젝트나 데브코스에서 진행했던 팀 프로젝트나 최근 진행한 프로젝트, 그리고 앞으로도 진행할 프로젝트는 JWT를 활용한 Stateless 한 인증 방식이 주가 될 것 같다. 그래서 처음에 구현할 때는 인터넷에서 관련 예제를 받아서 따라해보면서 적용하는 방식으로 진행했다.
그런데 나중에 데브코스에서 스프링 시큐리티 관련 강의를 들어보니 완전 이상하게 이해하고 구현하고 있었다. 정확히 말하면 스프링 시큐리티 구조를 전혀 신경쓰지 않고 주먹구구 식으로 돌아가기만 하도록 구현하고 있었다는 것을 알게 되었다.
그래서 이번에는 해당 강의 내용을 참고하되 개인적으로 진행했던 프로젝트에서 어떤 방식으로 적용했는지 기록해보고자 한다.
제일 처음 JWT를 적용한 SimpleTodoList 에서는 다음과 같은 구성 요소로 JWT 기반 인증을 수행했다.
UsernamePasswordAuthenticationToken
을 시큐리티 컨텍스트에 저장하는 필터.사용자는 로그인 성공 시 토큰을 발급받고 요청 전송 시 Authorization
헤더에 담아서 보내는 방식이다. 그러면 서버는 JwtTokenFilter
쪽에서 요청을 받아서 JWT 를 분석, UsernamePasswordAuthenticationToken
을 생성한다.
그리고 실제로 요청을 처리하는 컨트롤러에서는 @RequestHeader
어노테이션으로 JWT 를 읽어서 다시 위의 JwtTokenUtil
을 이용하여 사용자 정보를 가져와서 인가를 수행한다.
척 보기에도 여러가지 문제가 있지만 크게 몇 가지 고른다면 다음과 같다.
UsernamePasswordAuthenticationToken
사용.Authorization
헤더에 접근, JWT 에서 직접 클레임을 추출.이 문제들은 대부분 스프링 시큐리티에 대한 이해가 부족했기 때문에 발생했다. 그래서 데브코스에서 스프링 시큐리티 강의를 들으면서 조금이나마 인증이 어떻게 돌아가는지 공부하면서 더 좋은, 올바른 방법을 고민했었다.
그래서 최근에 수행했던 팀 프로젝트에서는 이와 좀 다르게 JWT 관련 구성 요소를 정의했는데 이는 다음 문단에서 다루겠다.
데브코스에서 최종 팀 프로젝트를 수행할 때 나는 내가 맡았던 기능을 구현하기 위해 비슷한 환경의 프로젝트를 따로 생성해서 진행했었다. 그러면서 나름대로 강의에서 얻은 내용과 스프링 시큐리티에서 제공하는 인증 메커니즘의 소스 코드를 비교하며 인증 과정을 새롭게 작성해봤는데 이는 아래와 같다.
JwtTokenFilter
처럼 JWT 가 포함된 HTTP 요청을 받으면 이를 인증해서 시큐리티 컨텍스트에 인증 객체를 등록하는 필터.JwtAuthenticationToken
을 인증하는 커스텀 AuthenticationProvider
.위와 다른점은 UsernamePasswordAuthenticationToken
을 사용하지 않고 아래와 같은 JwtAuthenticationToken
을 별도로 정의해서 사용했다는 것이다.
@Getter
public class JwtAuthenticationToken extends AbstractAuthenticationToken {
private final Object principal;
private String credentials;
...
그 이유는 AbstractAuthenticationToken 과 UsernamePasswordAuthenticationToken 의 관계에 대해서 좀 더 알게 됐기 때문이다. 기존에 사용했던 UsernamePasswordAuthenticationToken
은 클래스 이름부터 알 수 있듯이 아이디, 비밀번호 기반의 전통적인 인증 방식에서 사용하기 위한 토큰이다.
그래서 JWT 를 발급받기 전에 로그인 화면에서 아이디, 비밀번호를 입력하여 인증하는 때에는 이걸 사용하는게 맞다. 하지만 이후 JWT 를 HTTP 요청에 포함하여 사용자를 인증할 때는 아이디, 비밀번호를 입력하지 않기 때문에 이 토큰을 재활용하는 것은 그 목적에 맞지 않고 불필요하다고 생각했다.
특히 아이디, 비밀번호만 갖고 있는 UsernamePasswordAuthenticationToken
에서는 JWT 관련 정보들을 포함하기 까다롭기 때문에 추후 확장성을 생각한다면 별도의 인증 토큰을 정의하는게 맞다고 생각해서 위와 같은 클래스를 구현하게 된 것이다.
그리고 이런 커스텀 인증 토큰을 인증하기 위해 AuthenticationProvider
인터페이스의 구현체인 JwtAuthenticationProvider
를 작성, 이 클래스에서 JwtTokenUtil
을 활용하여 올바른 토큰인지 검증하고 추출한 인증 정보를 기반으로 인증된 토큰을 생성할 수 있도록 구성하였다. 사실 JWT 자체가 인증을 대신하기 때문에 없어도 되는 컴포넌트인데 예를 들어 io.jsonwebtoken
의 jjwt
라이브러리는 JWT 파싱 과정에서 불완전한 형태나 만료된 토큰에 대해 예외를 발생시키기 때문에 토큰 자체를 인증하는 과정이 별도로 필요하지 않다.
마지막으로 애플리케이션에서 인증 정보를 활용할 때는 이전처럼 @RequestHeader
같은 어노테이션으로 JWT 를 읽어와서 클레임을 추출하는게 아니라 JwtAuthenticationFilter
에서 시큐리티 컨텍스트에 등록한 JwtAuthenticationToken
을 불러와서 사용하도록 했다. 이 과정도 매번 시큐리티 컨텍스트에 접근할 필요가 없는 것이 스프링 시큐리티에서는 AuthenticationPrincipalArgumentResolver
를 제공하기 때문에 핸들러 메서드에서 @AuthenticationPrincipal
어노테이션으로 인증 토큰 자체를 주입받을 수 있다. 여기에 맨 처음 정의한 JwtAuthentication
을 principal 로 가져와서 활용할 수 있을 것이다.
이런 일련의 과정을 구성하는 데에는 데브코스에서 들은 강의와 스프링 시큐리티에서 제공하는 UsernamePasswordAuthenticationFilter 의 소스 코드였다. UsernamePasswordAuthenticationToken
은 보통 UserDetailsService
로 등록된 DaoAuthenticationProvider
로 인증되기 때문에 별도의 AuthenticationProvider
를 정의할 필요가 없지만 이번에 적용한 JWT 는 스프링 시큐리티에서 기본적으로 지원하지 않는 기능이기 때문에 별도의 provider 를 정의해준 것이다.
'돌아가기만 하면 어떻게 구현하던 자유 아니냐?' 싶지만 스프링 프레임워크를 활용하여 애플리케이션을 작성한다면 스프링스럽게 작성하는 것도 큰 가치가 될 수 있다고 생각한다.
인증 토큰(authentication token)을 인증 매니저(authentication manager)에 전달하여 해당 토큰을 인증하는 인증 제공자(authentication provider)에게 위임하는 스프링 시큐리티의 핵심 인증 과정에 대한 이해 없이 JWT 관련 기능을 구현하는 데만 신경쓰다 보니 정말 '냄새나는 코드'를 작성한 것 같아 많이 부끄럽다.