Web

[Web] SpringBoot 환경에서의 세션 인증 방식과 토큰 인증 방식

Joonfluence 2023. 12. 13.

서론

오늘은 인증과 인가 기능을 구현할 때, 꼭 짚고 넘어가야 하는 세션 인증 방식과 토큰 인증 방식의 장/단점을 비교해보겠습니다. 그리고 스프링에선 어떻게 구현할 수 있는지 간략하게 소개드리겠습니다.

대상 독자

인증/인가 기능을 처음 구현해보시는 분들, 이전에 인증/인가 기능을 구현해봤지만 잘 기억이 나질 않는 분들, 이와 관련된 이론적 지식들을 알고 싶은 분들에게 이 글을 추천합니다.

본론

인증과 인가

  • 인증이란 유저를 식별할 수 있는 정보(ex - ID/PW, 소셜로그인 정보)를 통해, 유저가 적합한 유저인지 판단하는 방법을 말합니다.
  • 인가란 인증된 유저에게 권한을 부여하여, 해당 프로그램 기능에 접근할 수 있도록 허용하는 것을 말합니다. 반대로 권한이 없는 유저는 접근하지 못하도록 막는 것을 말합니다.
  • 인증과 인가를 구현하는 대표적인 방법에는 세션 기반 인증과 토큰 기반 인증, 이렇게 2가지가 있습니다. 각각의 방식에는 아래와 같은 장점과 단점이 존재합니다.

세션 기반 인증

  • 장점
    • 토큰 방식보다 상대적으로 구현이 쉽습니다. 인증된 유저에 세션을 생성해주고, 세션 조회를 통해 인가된 요청은 통과시키고 그렇지 않은 요청은 차단해줍니다. 세션 만료도 옵션 값만 설정해주면 처리됩니다.
    • 보안 측면에서 더 유리합니다. 서버 메모리에 저장하므로, 유저의 로그인 상태를 서버에서 통제할 수 있습니다. 가령, 해킹 가능성이 있는 유저를 판별하여, 해당 sessionId를 삭제함으로써 강제로 로그아웃 시킬 수도 있죠.
  • 단점
    • 확장하기 위해, 별도의 작업이 필요합니다. 서버가 stateless 하지 않으므로 서버가 여러 대로 확장 됐을 때, 세션 불일치 문제가 발생하게 되는데, 이는 로그인 시 세션을 생성한 서버와 로그인 이후 요청을 받는 서버가 서로 다를 수 있기 떄문입니다. 이를 해결하기 위해서 Sticky Session, Session Clustering, 세션 스토리지 외부 분리 등의 작업을 따로 해줘야 합니다.

Spring Session을 사용하면 세션 기반 인증 시스템을 쉽게 구현할 수 있습니다. 유저 로그인 시, 서버 메모리에 sessionId를 저장하고 로그인 시 쿠키 안에 sessionId를 클라이언트에 반환해줍니다. 또한 세션 데이터를 서버의 메모리에 저장하는 대신 외부 스토리지에 저장할 수 있도록 다양한 스토리지를 지원합니다. 분산 환경에서는 보통 서버와 세션 서버를 구분하는데, Redis를 대표적으로 많이 사용합니다.

토큰 기반 인증

  • 장점
    • 토큰 인증 방식을 사용하면, 서버 확장이 용이합니다. 세션 방식처럼 별도의 작업이 필요하지 않습니다. 클라이언트의 LocalStorage 혹은 쿠키에 유저 정보를 포함한 토큰을 저장하기 때문에 별도의 세션이 필요하지 않아, stateless한 서버를 구성할 수 있습니다. stateless한 서버는 세션처럼 서버가 유저로부터 독립적으로 구성될 수 있습니다.
  • 단점
    • 토큰을 탈취 당했을 때, 해커의 위협으로부터 취약합니다. 사용자 토큰이 탈취되면, 세션과 다르게 이를 무효화할 수 있는 방법이 없습니다. 이에 대한 대안으로 Refresh Token을 추가로 사용할 수 있지만, Refresh Token 또한 탈취됐을 경우 요청을 막을 수 없다는 한계가 존재합니다.

여기서 정상적인 요청이란 서버에서 발급한 (아직 만료되지 않은) 토큰을 HTTP Header에 담아서 API 요청을 보내는 것을 말합니다. 이 경우에는 서버에서 정상 응답을 해줍니다. 비정상적인 요청이란 만료되거나 서버에서 발급한 토큰이 아니거나, 토큰 없이 API 요청을 보내는 경우를 말합니다. 이 경우에는 인가해줘선 안되며 401(Unauthorized) 상태를 응답으로 보냅니다.

JWT란

이 때 활용되는 토큰은 JWT(Json web token)을 많이 사용합니다. JWT를 사용하는 까닭은 토큰 안에 유저 정보가 저장되어 있기도 하고, 쿠키나 LocalStorage 값은 임의로 변경될 수 있기 떄문입니다. JWT은 payload, header, body 등으로 구성되며, 다양한 암호화 알고리즘을 활용해 암호화할 수 있습니다. JWT 관련해선, 아래와 같이 따로 라이브러리를 설치해주셔야 합니다. Spring Boot 3.1.5 버젼 기준, 0.11.2 버젼을 사용하시면 됩니다.

implementation 'io.jsonwebtoken:jjwt-api:0.11.2'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.2'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.2'

보안 취약점에 대한 대응 방안

토큰 기반 인증 방식은 AccessToken 토큰 탈취 시 보안이 취약하다는 치명적인 단점이 존재합니다. 보안 위협을 원천 봉쇄할 순 없으므로 만료 기한을 30분-1시간과 같이 짧게 설정하여, 그 위험을 줄일 수 있습니다. 그렇지만 자주 로그인해야 하는 불편함이 생깁니다. 이 문제를 개선하기 위해, Sliding Sessions 전략과 함께 사용하는 방법, AccessToken과 RefreshToken을 사용하는 방법 등이 존재합니다. 각각 하나씩 살펴보겠습니다.

  1. Sliding Sessions 전략과 함께 AccessToken 사용하는 방법

Sliding Sessions 전략이란 세션을 지속적으로 이용하는 유저에게 자동으로 만료 기한을 늘려주는 방법입니다. 즉, AccessToken의 만료 기한을 늘려주는 거죠. 정상적인 요청을 보냈을 때, 기한을 늘려주기도 하고 특정 API를 호출했을 때 (글 작성을 시작할 때 발급해준다거나, 쇼핑몰에서 장바구니에 아이템을 담는 경우에 발급해주는 등) 늘려주기도 합니다. 이 방식의 장점은 AccessToken의 만료기간을 짧게 가져가면서도, 자주 로그인하지 않아도 되기 때문에 편리하다는 점입니다. 단점은 접속이 단발적으로 이뤄지는 경우에는 효과가 없습니다.

  1. AccessToken과 RefreshToken을 사용하는 방법

이 방식에선 AccessToken의 만료 기한을 30분 정도로 짧게 두는 대신, 2주나 한달 정도의 긴 만료 시간을 갖는 RefreshToken을 AccessToken을 재발급하는 목적으로 사용합니다. 클라이언트는 AccessToken이 만료되었다는 응답을 받으면 따로 저장해두었던 RefreshToken을 이용하여 AccessToken의 재발급을 요청합니다. 서버는 유효한 RefreshToken으로 요청이 들어오면 새로운 AccessToken을 발급하고, 만료된 RefreshToken으로 요청이 들어오면 오류를 반환해, 사용자에게 로그인을 요구합니다.

또 RTR(Refresh Token Rotation), Refresh Token을 한번만 사용할 수 있게(One Time Use Only) 만드는 방법을 적용해볼 수 있습니다. Refresh Token을 사용하여 새로운 Access Token을 발급받을 때 Refresh Token도 새롭게 발급해줍니다. 이 방식의 장점은 Refresh Token을 훔쳐 사용하더라도, 이미 사용된 경우라면 문제가 발생되지 않습니다. 그렇지만 최초 발급되어 사용되지 않은 Refresh Token을 훔쳐 사용하는 경우는 여전히 막을 수 없다는 한계가 존재합니다.

  1. Sliding Sessions 전략과 함께 AccessToken과 RefreshToken을 사용하는 방법

AccessToken의 Sliding Sessions 전략이 AccessToken 자체의 만료 기간을 늘려주었다면, RefreshToken에 적용하여 만료 기간을 늘려줄 수도 있습니다. 이 방식의 장점은 사용자가 접속을 뜸하게 하는 경우에도 사용자의 로그인 유지를 쉽게 할 수 있습니다. 그렇지만 RefreshToken의 만료 기간이 계속 늘어날 수 있으므로 보안 강화를 위해 서버에 저장하는 편이 좋습니다.

제 생각엔 토큰 방식을 사용하다면 Refresh 토큰을 같이 사용해주는 편이 좋고, RTR(Refresh Token Rotation) 방식이 가장 보안 위협으로부터 안전한 방식이라 생각합니다.

Refresh Token와 관한 궁금증들

  • Refresh Token은 저장해야 할까? 저장한다면 어디에 저장해야 할까?
    • RefreshToken을 저장해야 할까요? 꼭 저장하지 않아도 됩니다. 단, 서버에선 올바르지 않은 요청을 무효화할 수 있는 방법이 존재하지 않습니다.
    • 만약 세션 방식의 장점(보안 강화)을 위해서라면, 저장해도 됩니다. 단, 토큰 방식의 장점을 포기해야 하죠.
    • 저장한다면 어디에 저장해야 할까요? 이는 DB에 저장할 수도 있고, Redis와 같은 메모리 DB 안에 저장할 수도 있습니다.
  • 꼭 JWT 토큰 형태여야 할까?
    • 이럴 때 가장 정확한 건 RFC 문서를 참조하는 것이다. RFC 6749 문서를 보면, "Refresh tokens ... They may or may not be JWT. Refresh tokens can be a simple encoded string or a UUID. Refresh tokens are also bearer tokens, hence malicious users can theoretically steal the refresh token and use it indefinitely to access protected resources from the server."라고 언급된 것처럼, UUid여도 상관이 없다고 한다.

토큰 기반 인증에서의 클라이언트 플로우

  • Refresh 토큰을 사용하지 않는 경우
  1. 주어진 정보(이메일, 패스워드)로 회원가입을 시도합니다.
  2. 가입된 회원정보로 로그인을 시도합니다.
  3. 올바른 정보로 입력되면 사용자 인증 처리가 되고, 토큰(액세스 토큰, 경우에 따라선 리프레시 토큰까지)을 발급해줍니다.
  4. 클라이언트는 발급된 토큰을 Header에 담아서 요청을 보냅니다.
  5. 서버에 보관하고 있는 개인 키로, 클라이언트가 보낸 토큰이 정상적인 토큰인지 판별합니다.

Spring Security와 JWT를 활용했을 때의 다이어그램

  • Refresh 토큰을 사용하는 경우 (Refresh Token을 Redis에 저장하는 경우를 가정합니다)
  1. 클라이언트에서 로그인합니다.
  2. 서버는 클라이언트에 JWT 형태의 AccessToken과 RefreshToken을 발급합니다. 그리고 Refresh 토큰을 세션 스토리지에 저장합니다.
  3. 클라이언트는 쿠키 혹은 로컬 스토리지에 두 Token 정보를 저장합니다.
  4. 클라이언트는 매 요청마다 Access Token을 헤더에 담아서 요청합니다.
  5. 이 때, Access Token이 만료가 되면 서버는 만료되었다는 Exception을 발생 시킵니다.
  6. 클라이언트는 해당 응답을 받으면 Refresh Token을 서버에 보냅니다.
  7. 서버는 다시 세션 스토리지에 Refresh Token 유효성 체크를 하게 되고, 새로운 Access Token을 발급합니다.
  8. 클라이언트는 새롭게 받은 Access Token을 기존의 Access Token에 덮어쓰게 됩니다.

위 클라이언트 플로우를 Spring 환경에 그대로 적용해보면 아래와 같습니다.

실습코드

실제 위 내용을 적용한 코드들은 아래 프로젝트 코드에서 확인해보실 수 있습니다. 다음 번에는 해당 기능을 구현한 실제 코드들을 가지고 조금 더 자세히 이야기해보도록 하겠습니다. 감사합니다.

https://github.com/joonfluence/starbucks-coffee-order-app
https://github.com/joonfluence/starbucks-coffee-order-app/issues/9

레퍼런스

https://www.rfc-editor.org/rfc/rfc6749.html
https://hudi.blog/refresh-token/
https://hudi.blog/session-based-auth-vs-token-based-auth/
https://blog.ull.im/engineering/2019/02/07/jwt-strategy.html
https://docs.spring.io/spring-security/reference/features/exploits/csrf.html
https://redis.io/docs/management/persistence/

반응형

댓글