JWT를 활용한 인증 과정과 구현
회원가입에 이어 이제 로그인을 구현할 차례이다.
로그인이 뭘까?
로그인 사용자가 웹 애플리케이션에 접근하기 위해 본인이 맞는지 확인하는 과정이다.
사용자는 아이디와 비밀번호를 입력하여 인증을 수행하고 인증이 성공하면 웹 애플리케이션에 접근할 수 있다.
그러면 인증이 성공적으로 완료된 후 사용자는 애플리케이션의 모든 리소스에 접근할 수 있을까? 아니다. 인증만으로는 애플리케이션 내의 모든 자원에 자유롭게 접근할 수 없다. 사용자가 자원에 접근할 수 있는 권한이 있어야 접근이 가능하다.
인증을 통해 신원을 확인했다면, 그 다음에는 인가가 필요하다.
인증과 인가
인증 Authentication
인증은 사용자의 신원을 확인하는 과정으로 사용자의 입력 정보를 바탕으로 셀제 등록한 사용자와 일치하는지를 확인하는 절차이다.
인가 Authorization
인증을 마친 후, 어떤 자원에 접근할 수 있는지 어떤 권한을 갖는지를 결정하는 과정이다.
예를 듦면, 관리자는 모든 게시글에 대해 작성, 수정, 삭제가 가능하지만 일반 사용자는 게시글을 조회하는 것만 가능하다.
즉, 인증은 사용자가 누구인지를 확인하는 과정이고, 인가는 그 사용자가 어떤 자원에 접근할 수 있는지, 어떤 권한을 갖는지 결정하는 과정이다.
이제 로그인의 흐름을 생각해보자.
요구사항 및 분석
1. 가장 먼저 유저가 아이디와 패스워드를 입력한 후 로그인 버튼을 누른다.
- 입력 정보 : 아이디, 비밀번호
- 비밀번호의 경우 보안을 위해 입력시 마스킹 처리해야 한다.
- 개인정보는 다른 사용자에게 노출되어서는 안된다.
- 아이디, 패스워드 중 하나만 입력하는 경우
- 둘 다 입력하지 않는 경우
2. 서버는 유저의 입력정보를 가지고 서버에 저장된 정보와 확인 후 응답값을 반환한다.
- 서버는 아이디와 비밀번호를 받아서 DB내의 저장된 값과 비교 한다.
- 둘 다 일치하면 성공을 반환한다.
- 둘 중 하나라도 실패하면 예외를 반환한다.
3. 로그인은 일정 시간 유지되어야 한다.
- 어떤 방식으로 로그인 정보를 유지할지
- 사용자가 로그인한 사용자라는 것을 인증할 데이터
- 어느 정도의 시간이 유지되어야 하는지
- 기한 만료시 어떻게 처리할지 (로그아웃?)
4. 사용자가 로그아웃을 하면 다음 접속시 재로그인을 해야 한다.
- 유저가 로그아웃 요청을 한다.
- 로그인되어 있는 유저의 정보를 제거한다.
- 다시 화면에 접근시 재 로그인을 요청한다.
Http는 상태를 유지하지 않는 비 연결형 프로토콜이다. 그렇기 때문에 서버는 이전의 요청 정보를 알 수가 없다. 따라서 사용자가 로그인을 했는지 알기 위해서는 추가적인 작업이 필요하다.
로그인 상태를 어떻게 유지할까? 로그인을 했다는 것을 알고 있는 것은 누구일까? 서버이다. 사용자가 요청을 하면
서버가 확인 후 응답을 주기 때문에 서버는 사용자가 로그인한 상태인지 알 수 있다.
인증이 성공하면 서버는 유저에게 로그인이 정상적으로 되었다는 정보를 주거나 본인이 잘 가지고 있어야 한다. 이 부분을
구현 방법을 찾아보자!
어떤 기술 사용할까?
쿠키
쿠키는 서버가 사용자의 웹 브라우저에 전송하는 작은 데이터 조각이다.
서버가 유저를 식별하는 데이터를 클라이언트에 보내면 클라이언트는 해당 정보를 사용자의 브라우저에 저장하는 방식이다.
이후 클라이언트가 요청할 때마다 저장된 쿠키를 요청 헤더의 cookie에 담아서 보내고, 서버를 이를 통해 클라이언트를 식별할 수 있다.
하지만 이 방식은 요청시 쿠키의 값을 그대로 보내기 때문에 유출 또는 조작될 위험이 있고, 제한이 있어 많은 정보를 담을 수 없다.
세션
쿠키 방식은 상태를 유지할 수는 있지만 보안에 있어서 약하다. 이를 해결하기 위해 세션은 서버측에서 중요한 데이터를 관리한다.
서버는 유저 정보 인증 후 세션 ID와 사용자의 상태 정보를 서버 측에 저장한다. 그리고 세션 아이디를 사용자의 브라우저 쿠키에 저장한다. 이후 클라이언트가 서버에 요청시 세션 ID를 함께 보내면, 서버는 세션ID를 확인하여 해당 사용자의 로그인 상태 및 권한을 확인할 수 있다.
하지만 세션은 정보를 서버에 저장하기 때문에 사용자가 많이 늘어나면 부하가 심해진다. 그리고 탈취자가 세션 ID 자체를 탈취해 클라이언트로 위장할 위험이 있다.
토큰
클라이언트가 인증에 성공하면 서버에서 해당 클라이언트에게 인증되었다는 의미로 ‘토큰’을 부여한다. 이 토큰은 유일하며, 토큰을 받은 클라이언트는 서버에 요청을 보낼 때 요청 헤더에 토큰을 넣어서 보낸다. 그러면 서버는 클라이언트로부터 받은 토큰을 서버에서 제공한 토큰과 일치하는지 확인 후에 인증 과정을 처리한다.
기존의 세션 인증은 서버가 세션에 관한 정보를 서버가 가지고 있기 때문에 많은 오버헤드가 발생하였다. 하지만 하지만, 토큰은 서버가 아닌 클라이언트에 저장되기 때문에 메모리나 스토리지 등을 통해 세션을 관리했던 서버의 부담을 덜 수 있다. 토큰 자체에 데이터가 있기 때문에 클라이언트에서 받아 위조되었는지 확인하면 되기 때문이다.
토큰에는 필요한 정보가 포함되어 있어, 서버가 매번 데이터베이스를 조회하지 않아도 되며, 분산 환경에 적합하다.
하지만 토큰의 길이가 길어져 요청이 많아질수록 네트워크 부하가 심해질 수 있으며, Payload는 조회가 가능하기 때문에 중요한 정보를 담으면 안된다. 그리고 탈취시 대응 방법이 없다…응?
토큰의 약점
토큰 기반 인증에서는 토큰 자체에 인증 정보를 담아 클라이언트가 보관하며, 클라이언트는 요청할 때마다 이 토큰을 서버에 전달한다. 서버는 받은 토큰의 유효성만 검증 할 뿐, 토큰의 상태를 서버에 저장하거나 로그인 상태를 관리하지 않는다.
토큰은 만료 시간을 갖는데, 만료되기 전까지는 토큰을 갖는 누구나 서버에 접근할 수 있다. 즉, 유효한 토큰이 탈취되면, 탈취자는 해당 토큰으로 서비스에 접근할 수 있다. 그러나 토큰 기반 인증은 stateless 방식이므로, 서버는 토큰을 소지한 사용자의 로그인 상태를 저장하지 않기 때문에, 요청을 보낸 사용자가 누구인지 확인할 수 없다.
이 때문에 토큰이 탈취 여부조차 알 수 없고, 세션 기반 인증처럼 서버에서 강제로 로그아웃시키는 등의 조치를 취하기 어렵다.
두 손 놓고 있을 순 없잖어…
탈취 당하면 대응 방법이 없기 때문에 유효 기간 동안 지속적으로 악용될 위험이 있다. 그래서 최대한 탈취 위험을 낮추고, 탈취되더라도 피해를 최소화하도록 해야 한다.
토큰에는 유효기간이 있다. 일반적으로 유효기간을 짧게 설정해서 토큰이 자주 갱신되도록 하여, 토큰이 탈취되더라도 오래 유지하지 못하도록 할 수 있다.
그런데 토큰의 유효기간이 짧으면..? 자주 인증해줘야 하는 거 아니야? 맞다. 토큰의 유효 기간이 짧으면 사용자는 계속해서 토큰을 갱신하기 위해 로그인 해주어야 한다. 하짐만 서비스를 이용하는데 계속해서 로그인을 하라고 한다면, 정말 스트레스 받을 것 같다.
유효 기간은 짧게 가지고 가되, 사용자가 지속적으로 로그인을 하지 않도록 할 수는 없을까?
그래서 나온 것이 Access Token과 Refresh Token을 같이 사용하는 방법이다.
서버는 사용자 인증 후 클라이언트에게 Access Token 토큰을 준다. 그리고 클라이언트가 요청을 보낼때 Access Token을 같이 전송ㅎ나다.
Refresh Token은 서버에 저장이 되며, Access Token이 만료되었을 때 새롭게 발급하기 위한 용돋로 사용된다.
Access Token은 유효기간을 짧게 설정해서 토큰이 자주 갱신되도록 하고, Refresh Token은 유효 기간을 상대적으로 길게 설정하여 Access Token이 만료될 때마다 유효한 Refresh Token을 이용해 새로운 Access Token을 발급 하도록 한다.
이렇게 하면 사용자는 지속석인 로그인 요구로 인한 불편을 겪지 않고, 보안성을 유지할 수 있을 것이다.
Access Token으로 보안 위험을 줄이기는 했지만, Refresh Token이 탈취되면 어떡해..?
일반적으로 Refresh Token은 Access Token에 비해 탈취될 위험이 적다. 만료 기한이 더 길기 때문에 네트워크에 노출되는 빈도가 낮기 때문에 탈취 가능성이 낮아진다. 그리고 Refresh Token은 서버에 저장된다. 클라이언트가 Refresh Token을 보내면 서버에 저장되어 있는 해당 클라이언트의 Refresh Token 데이터와 비교하여 검증 과정을 거친다.
반면 Access token은 클라이언트가 서버에 요청할 때마다 전송되기 때문에 노출 위험이 상대적으로 높다.
왜 Refresh token을 서버에 저장하죠?
A. 좋은 질문입니다! 서버에 특정 클라이언트의 refresh token을 저장하면, 해당 클라이언트의 refresh token을 서버측에서 만료 기한 전에 수동적으로 만료시킬 수 있습니다. JWT 토큰은 만료기한이 지나기 전까지 위험에 지속적으로 노출될 수 있는 단점이 있었습니다. 이를 해결하기 위해 Refresh token을 서버에 저장 시켜서 서버가 refresh token의 생명 주기를 관리하는 것이죠! 자연스레 또 다른 질문이 있을 수 있죠!
그런데 세션의 단점이 서버에게 부담준다고 했는데, 서버 단에 토큰을 저장하는 건 괜찮아?
Refresh token은 클라이언트의 접속 정보를 저장하고 있어야 하기 때문에 서버에 저장을 해야 한다. 하지만 세션과 달리 DB 접속 빈도에 차이가 존재한다. 세션 인증 방식은 클라이언트의 매 요청 마다 인 메모리 디비에 접근하지만 토큰 인증 방식에서는 access token이 만료될 때만 인메모리 디비에 접근한다. 100만 명의 클라이언트가 동시 요청을 하면, 세션 인증 방식의 경우에는 100만명 모두 DB에 접근하지만, refresh token 방식은 100만 명 중 access token이 만료된 일부만이 DB에 접근하기 때문에 DB 접근 빈도가 훨씬 줄어들게 된다.
Refresh Token이 탈취되면 만료 기한도 길게 설정되어 있는데 위험이 크지 않을까?
Refresh token은 로그인 상태를 길게 가져갈 수 있는 장점이 있지만 탈취된다면 큰 피해가 생길 수 있다. 만료 기한이 길게 설정되기 때문에 유효한 시간동안 악의적인 사용자가 사용자 데이터에 접근할 수 있게된다. 그러면 이 상황을 어떻게 해결할 수 있을까?
Refresh token rotation
그래서 나온 방법이 RTR(Refresh token rotation)이다. Refresh token은 서버 측에 저장되는데, 클라이언트는 만료된 access token을 갱신하기 위해 이 refresh token을 서버에 보낸다. 그럼면 서버는 기존 refresh token을 무효화 후 새로운 refresh token과 access token을 발급한다. 이렇게 되면 악의적인 사용자가 가지고 있던 refresh token은 무효화 되어 사용할 수 없게 된다. 하지만 반대로 악의적인 사용자가 refresh token을 보내서 나의 refresh token이 만료가 되면 어떡하지? 이런 경우에는 사용자가 재로그인을 하게 되면 악의적인 사용자의 refresh token이 무효화 되기 때문에 큰 문제가 없다.
따라서, RTR 구조를 통해 보안성을 높일 수 있다.
이 과정을 통해 Refresh token의 탈취 가능성을 보완하는 Refresh token rotation 방식으로 구현하기로 하였다.
구현
Spring security를 사용하였다.
Spring security 내에 ‘/login’으로 요청을 보내면, UsernamePasswordAuthenticationFilter를 상속 받은 객체가 JwtAutheticationFilter이 동작한다. attemptAuthentication 메서드를 실행하여 인증 과정을 시작한다.
Spring Security를 활용하여 로그인 시 JWT 인증을 처리하는 방법은 매우 효율적입니다. 특히 JWT 토큰을 사용하면 서버에 상태를 저장하지 않고 인증을 처리할 수 있기 때문에 성능 상 이점이 있습니다. 이번 글에서는 Filter를 사용하여 JWT 인증을 처리하는 방법을 설명하겠습니다.
attemptAuthentication 메서드에서 유저 입력 정보를 검증하고, 성공할 경우 JWT 토큰을 발급하여 클라이언트에게 반환한다.
그리고 JwtTokenRenewalFilter는 JWT 토큰 갱신을 담당하는 필터로, 만약 클라이언트가 전송한 accessToken이 만료되었고, refreshToken이 유효하다면, 새로운 accessToken과 refreshToken을 발급하여 응답으로 반환한다.
Filter들은 HttpSecurity 설정에 추가되어, UsernamePasswordAuthenticationFilter 전에 실행된다.
동작 흐름은 아래와 같다.
- TokenAuthenticationFilter: 액세스 토큰을 확인하고, 유효한 경우 SecurityContext에 설정한다.
- JwtTokenRenewalFilter: 만료된 액세스 토큰이 있을 때 유효한 리프레시 토큰으로 새로운 토큰을 갱신한다.
- JwtAuthenticationFilter: 인증되지 않은 사용자가 로그인 요청을 보낼 때 사용자 인증 후 새로운 토큰을 생성한다.
TokenAuthenticationFilter
TokenAuthenticationFilter 필터는 모든 요청에 대해 Authorization 헤더에서 JWT 액세스 토큰을 추출하고 유효성을 검사하여, 유효하면 Authentication 객체를 생성하고 SecurityContextHolder에 저장한다. 유효하지 않은 토큰의 경우, 401 Unauthorized 응답을 반환하고 필터 체인을 종료한다.
JwtTokenRenewalFilter
엑세스 토큰의 만료 여부를 검사하고, 갱신을 위해 유효한 리프레시 토큰이 있는지 확인한다.
리프레시 토큰이 유효하면 새로운 액세스 토큰과 리프레시 토큰을 생성하여 Redis에 저장하고, 응답 헤더에 갱신된 액세스 토큰을 반환한다. 리프레시 토큰이 유효하지 않은 경우 요청을 인증 실패로 처리한다.
JwtAuthenticationFilter
attemptAuthentication
attemptAuthentication 메서드는 로그인 요청 시 호출되며, 사용자 인증이 성공하면 액세스 토큰과 리프레시 토큰을 생성한다.
내부적으로 UserDetailsService와 PasswordEncoder를 사용해 username과 암호화된 비밀번호와 일치 여부를 확인 후 인증이 성공하면 Authentication 객체가 반환되고, successfulAuthentication 가 호출되며, 인증 실패 시 unsuccessfulAuthentication 메서드가 호출된다.
successfulAuthentication
JWT 액세스 토큰과 리프레시 토큰을 각각 생성한다.
Authorization 헤더에는 “Bearer “로 시작하는 액세스 토큰을, X-Refresh-Token 헤더에는 리프레시 토큰을 추가하고,
응답 본문에 accessToken과 refreshToken을 JSON 형태로 클라이언트에 전달한다.
unsuccessfulAuthentication
인증 실패 시 클라이언트에게 실패 이유를 반환한다.
테스트
로그인 성공 테스트
로그인 실패 테스트
비밀번호를 잘못 입력한 경우 401 을 반환하며 로그인에 실패한다.