TIL/개발 칼럼

사용자의 UX를 고려한 로그인(JWT, 세션, 쿠키) 보안 전략 수립 및 구현(2)

김민석(갈레, 페퍼) 2022. 7. 2. 20:59
반응형

안녕하세요🙌! 개발자 갈레입니다!

 

이번 글에서는 사용자의 UX를 고려한 JWT 로그인 보안 전략 수립 및 구현에 대해 다뤄볼겁니다! 

앞 글 사용자의 UX를 고려한 로그인(JWT, 세션, 쿠키) 보안 전략 수립 및 구현(1)에서 로그인에서 주로 사용되는 전략 두가지를 분석했고, 그 한계점까지 알아봤습니다.

 

앞 글을 못 보신 분들은 꼭 보고 오시길 바랍니다! 그래야 이 글을 제대로 이해할 수 있습니다.

아래는 앞 글의 목차입니다:)

목차

1. 문제 상황 분석

  • 로그인

2. 선택 가능한 기술 분석

  • 쿠키
  • 세션
  • 세션 로그인 보완 방식과 한계점

 


들어가며

컨텐츠만 보면 막 궁금증이 있는 분들이 있으실거에요!

보통 기술 구현에만 집중하다 보면 잊을 수 있는 중요한 질문들입니다:)

 

Q. JWT 로그인에 사용자 UX를 고려할게 있나?

Q. JWT 로그인 보안 전략이랄게 있나? Access, refresh token으로 구현하는거 아닌가?

 

 실제로 JWT 토큰에 대해 구현하다보면 사용자 UX를 고려한 여러 보안 전략🔒을 세울일이 많답니다!

사용자 UX를 고려하지 않으면, 사용자가 자주 로그인😡해야할 수도 있습니다. (사용자는 친절하지 않습니다:)😬)

그렇다고 사용자 UX만을 고려하자니 사용자의 의 정보 탈취 가능성이 높아집니다!

편의와 보안은 Trade off인 경우가 많습니다. jwt 토큰 또한 그렇죠.

 

 글을 읽으면서 여러분들이 스스로의 프로젝트에 맞는 적절한 사용자 편의와 로그인 정보 보안을 선택할 수 있는 시야 혹은 사고 과정 틀을 얻을 수 있으면 합니다. 이를 얻으면 여러분의 프로젝트가 제것과 다를지 몰라도, 기술 선택 시 사고 과정은 큰 틀을 벗어나지 않기 때문에 분명 도움이 될거라 생각합니다. 그럼 시작해볼까요!

 

궁금증(호기심)을 가지면 좋은 컨텐츠

  • JWT 토큰은 뭘까? 왜 사용할까? 장단점은?
  • JWT의 단점을 보완하는 전략은?
  • 우리 프로젝트에 맞는 JWT 보안 전략은?

 

 글을 읽으시면서 핵심 부분(노란) 꼬리질문(초록) 부분에 집중하며 읽어주세요! 개인적으로 키워드에서 꼬리질문을 하는게 개념을 깊이있게 이해하는 과정이라고 생각합니다:)

 


목차

1. 선택 가능한 기술 분석

  • JWT 토큰
  • Access token
  • Sliding session
  • Refresh token
  • Refresh token rotation
  • Refresh token automatic reuse detection

2. 기술 선택 및 구현

  • 기술 선택
  • 로그인 기능 구현

3. 문서화

  • WIKI

1. 선택 가능한 기술 분석

 

JWT

JWT는 json web token입니다! JWT를 한 문장로 정의하자면 '사용자 로그인 정보를 발급 주체자만이 인증할 수 있는 데이터로 변환하여 만든 토큰'이 됩니다.

  1. 클라이언트->서버 로그인 요청
  2. 토큰 발급 주체자(보통 Authentication server이지만, 여기선 service 서버라고 하겠습니다.) 즉 서버는 client의 아이디 패스워드를 DB와 비교.
  3. 서버는 사용자 접속 정보를 자신만이 인증할 수 있는 데이터로 변환하여 클라이언트에게 반환.
  4. 클라이언트는 로그인 시, 발급 받은 JWT 토큰으로 로그인 상태 인증

JWT가 어떻게 구성돼있는지 빠르게 살펴보죠!(<그림1> 참조) jwt는 세가지 파트로 구성돼있습니다.(출처: jwt.io)

 

JWT 구성

  1. Header: 헤더는 보통 암호화 알고리즘의 종류와 토큰 종류로 이뤄져있습니다.
    • 토큰의 메타 데이터가 들어있다고 생각해도 좋을 것 같습니다.
  2. Payload: Claims that Statement about entity and additional data
    • 사용자를 식별할 수 있는 보안에 중요하지 않은 데이터와 추가 데이터입니다. 사용자 아이디 즉 username이 들어갈 수 있겠군요! 궁금증이 들 수 있습니다! 왜 보안에 중요하지 않은 데이터가 들어가야할까요? JWT의 Payload 원문은 마지막에 Base64로 인코딩만 되기 때문에 쉽게 복호화가 될 수 있습니다. 악의적인 사용자가 JWT를 탈취하게 됐을 때, 보안에 중요한 데이터가 있으면 문제가 되겠죠!
  3. Signature: Header와 Payload와 HMAC SHA256 혹은 비대칭 키를 이용해서 만든 해싱 값입니다.
    • 여기서 Signature는 말 그대로 작성자의 신원을 보장합니다. 즉 인증 역할을 수행하는 파트죠. 인증의 목적이 있기 때문에 비대칭 키를 사용합니다. 개인키는 작성자만 가지고 있죠. SHA256는 해싱 알고리즘이지만, HMAC SHA256은 암호화 키를 기반으로한 해싱 알고리즘입니다. 해싱할 데이터와 작성자만이 알 수 있는 secret key를 가지고 해싱을 하는 알고리즘이죠. Secret key는 작성자만 알고있기 때문에 해당 알고리즘 또한 인증에 사용될 수 있습니다.

<그림1> JWT 구조

 

 

장점

  • 서버에 부담이 없다.(서버는 발행과 인증만 하면 되기 때문)
  • 데이터의 조작이 감지될 수 있다.

단점

  • 탈취되면 지속적으로 악용될 수 있다.

 

 JWT는 클라이언트의 저장소에 의지한다는 점에서 쿠키와 비슷하지만 데이터 조작 검증 가능성에서 차이가 있습니다. JWT는 HMAC SHA256 알고리즘 혹은 비대칭키를 사용하기 때문에 해당 토큰이 자신이 발급한 객체인지 확인할 수가 있죠. 하지만 쿠키와 비슷한 단점이 여전히 존재합니다. 탈취되면 지속적으로 악용될 수 있다는 점이죠. 이를 막으려면 어떻게 해야할까요? '지속성'을 조정해주거나, '악용'을 막는 방법이 있습니다. 악용을 막기는 어렵습니다. 악의적인 사용자는 선량한 사용자의 네트워크를 엿들을 것인데, 이를 서버가 막기는 어렵죠. 그렇다면 '지속성'을 조정해주는 방법이 있습니다. 이를 이용하여 JWT의 단점을 보완하는 방법에 대해 알아보죠.

 


Access token

 Access token은 위에서 우리가 언급한 JWT의 '지속성' 단점을 보완해주는 방법입니다. 바로 만료기한을 둬서 악용으로 인한 피해를 최소한으로 줄여주자는 취지죠. 보통 Access token의 만료시간은 30분 정도로 짧게 잡습니다. 서버는 토큰에 만료기한을 함께 적어서 발급합니다. 이후 사용자 요청이 들어올 때 함께 온 Access token의 만료기한을 살펴보고 토큰 유효성을 판단하게 되죠. 궁금증이 들 수 있습니다! 만료기한이 조작되면 어떡하죠? 좋은 질문입니다! 만료기한은 Access token의 payload안에 들어가게 되서 signature를 만드는데 사용됩니다. 따라서 데이터가 조작된다면 토큰 인증이 제대로 이뤄지지 않겠죠. JWT에서는 Secret key 혹은 개인키가 탈취되지 않는 이상, 데이터 조작이 불가능합니다. 

 

 자 탈취 피해가 최소화 됐습니다! 이를 웹에 적용하니 해킹 피해는 줄었습니다. 하지만 사용자들의 불만이 폭주합니다! 30분 마다 로그인 해야 해서 더이상 웹페이지를 안쓰겠다고 합니다! 깜빡하고 있었습니다. 위에서 말했듯, 보안과 사용자 UX는 보통 반비례하기 마련입니다. 사용자 UX를 전혀 고려하지 않은 결과입니다. 사용자 UX를 늘려줘야겠군요. 사용자 UX를 늘려주는 방법을 선택하기 전에 장단점을 정리해봅시다!

 

장점

  • 서버에 부담이 없다.(서버는 발행과 인증만 하면 되기 때문)
  • 데이터의 조작이 감지될 수 있다.
  • 탈취로 인한 피해 최소화(짧은 만료기한)

단점

  • 사용자 UX 감소(만료기한이 지날 때 마다 로그인 해야함)

 


Sliding session

 보안을 높이려다 사용자 UX가 줄었으니, 사용자 UX를 높여봅시다! 사용자 UX를 어떻게 높일까요? 웹페이지를 계속 사용하는 사용자는 계속 접근할 수 있게 하고, 급한 용무를 하러 자리를 비웠던 사용자는 다시 로그인하게 하면 괜찮을 것 같습니다. 마치 블로그로 글을 작성하거나 쇼핑하던 사용자는 계속 할 수 있게 만들어주는 거죠. 오케이! 사용자가 유의미한 이벤트를 서버로 보내면 Access token의 만료 기한을 늘려주면 되겠군요. 그러면 웹을 잘 사용하던 사용자가 재로그인 하는 경우는 줄어들겁니다! <그림2>은 슬라이딩 세션의 예시입니다:)

 

<그림2> Sliding session 예시

 

 Sliding session을 이용하니 사용자들의 불만이 크게 줄었습니다. 사용자 UX를 높였군요! 하지만 해킹 피해가 다시 발생했습니다. 특히 해킹 피해가 발생한 클라이언트는 주기적으로 활동 기록이 있는 공통점이 있더군요. 그렇습니다. Sliding session은 사용자의 유의미한 이벤트로 만료기한을 늘려주기 때문에 자칫하면 만료기한이 무한정 늘어날 수 있는 단점이 있습니다. 큰일이군요! 사용자 UX를 개선시키니 보안이 다시 악화됐습니다. 보안과 사용자 UX는 반비례 하는 경우가 많군요. 토큰 하나 만으로는 보안과 사용자UX를 둘다 좋게 만드는데는 한계가 있습니다. 그 결과 많은 사람들은 refresh token을 사용하죠. 이에 대해 알아보기 전에 Sliding session에 대해 먼저 알아봅시다!

 

장점

  • 서버에 부담이 없다.(서버는 발행과 인증만 하면 되기 때문)
  • 데이터의 조작이 감지될 수 있다.
  • 사용자 UX 감소(사용자 이벤트 발생으로 만료기한 연장)

단점

  • 탈취로 인한 피해 증가(만료기한 무한정 연장 가능)

Refresh token

 Refresh token은 access token보다 만료 기한을 더 크게 설정하여 그 결과 네트워크에 덜 보내게 되어 탈취 가능성을 낮추는 원리가 적용된 인증 데이터입니다. Refresh token을 이용하여 access token을 새로 발급 받는 방식이죠! refresh token은 만료 기한이 더 길기 때문에 네트워크에 노출되는 빈도가 access token 보다 훨씬 낮습니다(2주 > 30분). 따라서 탈취 가능성 자체도 낮아지게 되죠. 또한 Refresh token은 서버에도 저장됩니다. 클라이언트가 refresh token을 보내오면 server에 저장 돼있는 해당 클라이언트의 refresh token 데이터와 비교하여 검증 과정을 2중으로 수행하죠. 즉 Sliding session을 이용하지 않고도 refresh token을 이용하면 access token의 만료 기한이 마치 늘어난 효과를 누릴 수 있게 됩니다(실제론 새로운 access token을 발급 받습니다). 여기서 궁금증을 가질 수 있습니다!

 

<그림3> Refresh token 작동 과정

 

 

Q. 왜 Refresh token을 서버에 저장하죠?

A. 좋은 질문입니다! 서버에 특정 클라이언트의 refresh token을 저장하면, 해당 클라이언트의 refresh token을 서버측에서 만료 기한 전에 수동적으로 만료시킬 수 있습니다. JWT 토큰은 만료기한이 지나기 전까지 위험에 지속적으로 노출될 수 있는 단점이 있었습니다. 이를 해결하기 위해 Refresh token을 서버에 저장 시켜서 서버가 refresh token의 생명 주기를 관리하는 것이죠! 자연스레 또 다른 질문이 있을 수 있죠!

 

Q. 이전 글 에서 공유 세션 서버를 이용하여 사용자 로그인을 관리하는 방식은 서버에게 부담이라고 하지 않았나요? 결국 서버 단에 DB(보통 In-memory cache)를 새로 두게 되니까요!

A. 맞습니다! Refresh token도 결국 공유 세션 서버를 두긴 해야합니다. 클라이언트의 접속 정보를 저장하고 있어야 하기 때문이죠. 하지만 DB 접속 빈도에 큰 차이가 있습니다. 세션 로그인(공유 세션 서버) 방식은 클라이언트의 매 요청 마다 DB(보통 In-memory cache)에 접근하는 반면, access & refresh token을 이용한 로그인 방식은 access token이 만료될 때 DB에 접근 하게 됩니다. 100만 명의 클라이언트가 동시 요청을 하면,  세션 로그인(공유 세션 서버) 방식은 100만명 모두 DB에 접근하지만, refresh token 방식은 100만 명 중 access token이 만료된 일부만 DB에 접근하죠! DB 접근 빈도가 훨씬 줄어듭니다!(<그림4, 5> 참조)

 

<그림4> 세션을 이용한 로그인 방식

 

 

<그림5> access&refresh token이용한 로그인 방식

 

 

Refresh token을 이용하니 사용자 이벤트 발생으로 인한 만료 기한 로직을 구현하지 않아도 되서 개발자로서 너무 편하군요!

 

 

 어? 그렇다면 누군가 질문 할 수 있습니다! 만료기한이 길면 탈취될 시 악용 가능성이 높다고 하지 않았나요? JWT 토큰에서 access token으로 넘어온 이유도 이 때문인걸로 알고 있습니다! 맞습니다. Refresh token은 사용자의 UX를 늘려주는 장점이 있지만, refresh token 자체가 탈취된다면 큰 피해가 발생할 수 있습니다. Refresh token의 만료 기한은 보통 길게 설정되는데, 그렇다면 그 긴 시간 동안 악의적인 사용자가 사용자 데이터에 접근할 수 있다는 뜻입니다. 안좋은 상황이죠😞. 때문에 refresh token에도 적절한 조취가 취해져야합니다.


Refresh token rotation

 Refresh token은 토큰 자체가 탈취되면 큰 피해가 발생할 수 있는 단점이 있었습니다. 이는 refresh token의 작동 구조를 이용하여 해결할 수 있습니다. Refresh token은 서버에 저장되기 때문에 서버가 토큰의 생명 주기를 관리할 수 있습니다. 따라서 클라이언트가 access token의 갱신을 위해 refresh token을 보내오면, 기존 refresh token을 삭제하고 새로운 refresh token을 세션에 등록해주면 됩니다.(<그림6> 참조 출처: auth0.com) 말 그대로 refresh token 새롭게 바꾸기 위해 rotation 시키는거죠! 그렇다면 악의적인 사용자가 가지고 있던 refresh token은 더이상 유효하지 않은 refresh token이 되겠죠. 반대로 악의적인 사용자가 refresh token을 보내와서 제 refresh token이 만료가 되면요? 좋은 질문입니다! 이때는 사용자가 재 로그인만 하게 되면 악의적인 사용자의 refresh token이 만료가 되기 때문에 큰 문제가 없습니다. 

 

<그림6> Refresh token rotation

 


Refresh token automatic reuse detection

 Refresh token rotation까지 생각했지만, 여전히 해결하지 못한 본질적인 문제가 있습니다. 선량한 사용자의 네트워크가 현재 악의적인 사용자에게 노출돼있는게 큰 문제입니다. 이를 해결하지 못하면 다른 무언가를 해도 사용자는 해킹 위험에 노출될겁니다. Refresh token automatic reuse detection은 비정상적인 접근이 있을 때 선량한 사용자, 악의적인 사용자 모두 재로그인 시키는 보수적인 보안 방법입니다. 

 

  Refresh token automatic reuse detection은 특정 클라이언트가 발급 받은 refresh token들을 모아둔 refresh token family를 구현하여 비정상 적인 접근이 있을 때 해당 토큰 내 모든 refresh token을 invalidate 합니다. 구체적으로 설명드리겠습니다.

  1. 특정 클라이언트가 refresh token을 가지고 와서 새로운 access & refresh token을 발급 받습니다.
  2. Refresh token은 해당 클라이언트가 여태껏 발급한 refresh token이 모여져있는 refresh token family 스택의 맨 위로 올라갑니다. 즉 맨 위에 있는 refresh token이 가장 최근에 발급 받은 토큰입니다.
  3. 악의적인 사용자가 가장 최근에 발급 받은게 아닌 그 이전에 발급 받은 토큰을 가지고 서버에 접근합니다.
  4. 서버는 선량한 사용자의 네트워크가 악의적인 사용자에게 노출됐다고 판단하여 refresh token family를 모두 무효화시킵니다.  

해당 과정의 원문을 보고 싶으시다면, What Are Refresh Tokens and How to Use Them Securely (auth0.com)을 보시는 것을 추천합니다:) 

 

 Refresh token automatic reuse detection을 사용하면, 사용자의 데이터를 더 안전하게 지킬 수 있습니다. 해당 알고리즘은 물론 '사용자 입장에서는 자신의 네트워크가 악의적인 사용자에게 노출됐는데 위험을 무릅쓰고 서비스를 이용하길 원하지 않는다'를 가정하고 있습니다. 이러한 가정이 올바르다고 생각한다면 reuse detection 알고리즘을 사용하는게 올바르겠죠:) 하지만 단점 또한 존재합니다. refresh token family를 만들어야 하기 때문에 데이터 저장 공간이 늘어납니다. 서버 부하가 커지겠죠. 보안과 사용자 UX가 trade off 관계인 경우가 많은게 여기서도 보여지는군요. 장단점을 정리해봅시다!

 

장점

  • 데이터의 조작이 감지될 수 있다.
  • 사용자 UX 증가(네트워크가 감염된 사용자는 서비스 이용을 원하지 않는다고 가정)
  • 탈취로 인한 피해가 적다(By usage of refresh token family)

단점

  • 서버에 부담이 있다.(By usage of refresh token family)

3. 기술 선택 및 구현

기술 선택

 이제 저희가 사용할 기술을 선택할 시간입니다! 저는 모든 기술엔 pros & cons가 있습니다. 따라서 각자의 상황에 맞게 잘 선택하는게 중요하다고 생각합니다. 저희는 쿠키부터 refresh token automation reuse detection까지 살펴봤습니다. 보안을 최우선시 생각하고 서버 부하를 감당할 여건이 된다면 refresh token automation reuse detection를 선택하는게 가장 좋습니다. 하지만 light한 서버를 유지하고 싶다면 refresh token rotation도 좋은 선택지가 될수 있겠죠.

 

저희 팀은 refresh token rotation을 구현하기로 결정했습니다. refresh token의 탈취 가능성을 보완하기도 하면서 refresh token automation reuse detection와 달리 서버 부하가 덜한 장점이 있죠.


로그인 기능 구현

 로그인을 구현하려면 크게 두가지 기능이 필요합니다! 인증과 인가죠. 사용자가 서버와 연관된 등록된 사용자인가를 판별하는게 인증, 특정 리소스에 접근할 수 있는 권한이 있는가를 판별하는게 인가입니다. 인증과 인가를 둘다 필터 혹은 인터셉터를 사용해서 구현할 수 있지만, Filter를 사용하여 반복되는 응답 로직 제거 (feat.StandardResponse)에서 얘기했다 싶이, 사용자 인증과 인가 기능이 이미 구현돼있는 Spring security를 사용하는게 개발 편의성 향상으로 이어진다고 생각했습니다.

  1. Spring 사용 O,  Spring security 사용 X
    • 사용자 인증: 필터와 인터셉터 둘다 가능합니다.
    • 사용자 인가: 인터셉터에서 하는게 좋습니다. url 정보를 가지고 UrlResolve 제어를 통한 메소드 인가 여부를 판별해야 하는데, 이는 Dispatchet Servlet 이후에 가능하기 때문입니다.
    • Custom 응답 리턴: 필터와 인터셉터 둘다 가능합니다. 사용자 인증보다 앞에만 있으면 됩니다.
  2. Spring을 사용 O, Spring security 사용 O
    • 사용자 인증: Filter에서 하는게 좋습니다. Spring security는 인가를 Filter에서 합니다. 인가 기능을 이용하기 위해선 인증도 같은 level에 있는게 좋습니다.
    • 사용자 인가: Spring security의 인가 기능을 그대로 사용할 예정입니다. 인가 기능은 Filter에서 작동합니다.
    • Custom 응답 리턴: Spring security에서 응답을 수정할 일이 있으므로 Filter에서 해야합니다.

 

사용자 인증 과정

 Spring security 내에 '/login'으로 접속하게 되면, UsernamePasswordAuthenticationFilter를 상속 받은 객체가 요청에 작동됩니다. 이후 attemptAuthentication 함수를 실행하여 인증 과정을 시작합니다. 만약 spring security 프레임워크를 사용하지 않았다면 이러한 기능을 전부 구현했어야겠죠. 저는 프레임워크를 잘 사용하는 방법은 프레임워크의 전체적인 작동 원리의 이해에 있다고 생각합니다. 그 이해를 바탕으로 제가 원하는 기능을 구현하기 위해 어떻게 객체를 확장하여 기능을 적용할 수 있는지 판단할 수 있게 됩니다.

 

<그림7> JwtAuthenticationFilter의 attempAuthentication

 

 

사용자 인증 성공 후 토큰 생성 과정

 사용자 인증이 성공되면 토큰을 생성해야 합니다.  Spring security에서는 앞 attemptAuthentication 함수 성공하면, successfulAuthentication 함수를 실행하도록 설계돼있습니다. 편리하죠? 프레임워크는 진입 장벽을 넘으면 어렵지 유지보수하기에 편리한 도구입니다:) successfulAuthentication 함수에서 토큰을 만들어줍시다! createJwtToken에서는 com.auth0.jwt.JWT 라이브러리를 사용하여 토큰을 생성했습니다. 어렵지 않으니 시도해보세요! 

<그림8> successfulAuthenticaion 함수 내 jwt token 생성 코드

 

 여기서 해당 코드를 자세히 읽어보신 분들은 의문점이 있으실겁니다!

Q. token_type, expires_in 이런게 response body에 들어가는거죠? JWT 응답 규격은 어디서 정하셨나요?

A. 정말 정말 좋은 질문입니다! 모든 API는 규격이 아주 중요합니다. 저희 혼자서 일하는게 아니잖아요?(사실 혼자서 일해도 규격은 정해지는게 좋다고 생각합니다) JWT 규격은 어떻게 정하죠? JWT 또한 RFC 규격이 있으니, RFC 홈페이지 들어가면 있을겁니다. 이러한 기술 specification 홈페이지를 찾아서 권장하는 규격을 살펴본 후, 팀 모두가 동의하고 공유하는 규격을 정하는게 중요하다고 생각합니다. 팀 규격은 문서화 하는게 중요한데, 이는 '4.문서화'에서 살펴보고 저희는 규격에 대해 얘기해보도록하죠. 저희가 선택한 규격은 https://www.rfc-editor.org/rfc/rfc6750#section-4입니다! <그림8>은 아래 규격을 만족하는 response를 생성하는 함수입니다:)

4. Example Access Token Response

Typically, a bearer token is returned to the client as part of an OAuth 2.0 [RFC6749] access token response. An example of such a response is:
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
Cache-Control: no-store
Pragma: no-cache

{
    "access_token":"mF_9.B5f-4.1JqM",
    "token_type":"Bearer",
    "expires_in":3600,
    "refresh_token":"tGzv3JOkF0XG5Qx2TlKWIA"
}
출처: https://www.rfc-editor.org/rfc/rfc6750#section-4

 

 

토큰 생성 후 인증 정보를 세션에 저장하는 과정

successfulAuthentication 함수에서 JWT를 만들었습니다. 그렇다면 모든게 끝난걸까요? 아닙니다. 우리는 방금 인증을 처음 할 때만 얘기했습니다. Client가 server에게 접속하는 경우는 여러가지가 있습니다. 그게 최초 로그인 시와 로그인 상태로 나눌 수 있죠. 따라서 이제는 로그인 상태일 경우의 인증과 인가를 구현해야합니다. Spring security에서는 인증를 위한 추상 클래스 BasicAuthenticationFilter를 제공합니다. 모든 요청에 대해 해당 인증 및 인가 과정을 거치게 하죠. 이를 확장한 JwtAuthorizationFilter를 만들어서 사용자 인증 및 인가를 구현했습니다. 

 

우선 인증부터 살펴보죠! 사용자는 로그인 상태를 인증 받기 위해 http 메세지 내에 인증 데이터를 가져오게 될겁니다. 문제와 궁금증이 생겼습니다!

Q. 클라이언트는 어떤 규격으로 http 메세지 내에 인증 데이터를 가져오나요? 

A. 아주 좋은 질문입니다! 서버 단에서는 규격을 알아야 클라이언트에서 어떻게 데이터가 오는지 알겁니다! 이번에도 JWT RFC를 살펴봐서 좋은 규격 템플릿 혹은 권장 사항이 있나 살펴보죠! https://www.rfc-editor.org/rfc/rfc6750#section-2에 의하면, Authenticated Requests에는 3가지 방법이 사용된다고 합니다.

 

1. Authorization Request Header Field
2. Form-Encoded Body Parameter
3. URI Query Parameter

 

세가지의 장단점을 살펴본 뒤 저희 팀에 적합한 규격을 선정해보죠!

  1. Authorization Request Header Field
    • 장점
      • Body의 형식에 구애 받지 않음.
      • 헤더의 유무만으로 인증 가능 여부 판단 가능
    • 단점
  2. Form-Encoded Body Parameter
    • 장점
    • 단점
      • Body의 형식이 정해져있음.
  3. URI Query Parameter
    • 장점
    • 단점
      • 사용자 인증 방식이 URI에 노출됨

 

 세가지 방식 모두 장단점이 있을겁니다. 위 장단점은 저의 주관적인 판단이므로 참고만 하시길 바랍니다. 저는 Body의 형식에 구애 받지 않으면서도 URI에 노출되지 않는 Authorization request header field를 사용했습니다. 자! Authorization request header field(https://www.rfc-editor.org/rfc/rfc6750#section-2.1) 방식을 사용한 클라이언트 요청이 왔다고 가정해봅시다! 아래는 구현 코드입니다!

GET /resource HTTP/1.1
Host: server.example.com
Authorization: Bearer mF_9.B5f-4.1JqM
출처: https://www.rfc-editor.org/rfc/rfc6750#section-2.1
public class JwtAuthorizationFilter extends BasicAuthenticationFilter {
    // UserRepository는 service layer에서만 접근해야하지만, 이 필터가 요구하는 기능이 #27 브랜치에 있음.
    // 해당 브랜치가 머지되면 UserService로 교체 예정.
    private final UserRepository userRepository;
    private final Environment environment;
    private final MessageSourceAccessor messageSourceAccessor;

    public JwtAuthorizationFilter(AuthenticationManager authenticationManager, UserRepository userRepository, Environment environment, MessageSourceAccessor messageSourceAccessor) {
        super(authenticationManager);
        this.userRepository = userRepository;
        this.environment = environment;
        this.messageSourceAccessor = messageSourceAccessor;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {

        // jwt 설정 정보 불러오기
        String jwtSecretKey = environment.getProperty("jwt.secret.key");
        String jwtHeaderName = environment.getProperty("jwt.header.name");
        String jwtPrefix = environment.getProperty("jwt.prefix");
        String refreshTokenKey = environment.getProperty("jwt.refresh.key");

        // jwt 토큰 방식으로 로그인 시도하는지 확인
        String jwtRawData = request.getHeader(jwtHeaderName);
        if (jwtRawData == null) {
            chain.doFilter(request, response);
            return;
        }
        String[] jwtContents = jwtRawData.split(" ");
        String token_type = null;
        String token_data = null;
        try {
            token_type = jwtContents[1];
            token_data = jwtContents[2];
        } catch (ArrayIndexOutOfBoundsException e) {
            // 예외 처리 예정
            chain.doFilter(request, response);
            return;
        }

        if (token_type.equals(environment.getProperty("jwt.token_type.access"))) {
            String username = null;
            try {
                username = JWT.require(Algorithm.HMAC512(jwtSecretKey)).build().verify(token_data).getClaim("username").asString();

            } catch (TokenExpiredException e) {
                ThreadLocalStandardResponseBucketHolder.getResponse().getStandardResponse()
                        .setMessage(messageSourceAccessor.getMessage("jwt.access.token.expired"));
                return;
            }

            // 서명이 정상적으로 됨
            if (checkTokenIfVerified(username)) {
                Optional<UserEntity> userEntity = userRepository.findUser(username);
                PrincipleDetails principleDetails = new PrincipleDetails(userEntity.get());

                // 인증 객체 생성
                Authentication authentication = new UsernamePasswordAuthenticationToken(
                        principleDetails,
                        null,
                        principleDetails.getAuthorities());

                // Spring security의 권한 관리 기능을 사용하기 위해 security의 세션에 접근하여 Authentication 객체 저장
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }
        chain.doFilter(request, response);
    }

    private boolean checkTokenIfVerified(String username) {
        return username != null;
    }
}

 

 

 사용자 인가는 Security holder라는 ThreadLocal map을 사용하여 인가 정보를 저장합니다. 사용자 인가 여부는 요청이 들어왔을 때 부터 요청이 끝날때 까지만 존재하면 됩니다. 요청은 Thread와 함께 하므로 ThreadLocal 클래스가 해당 기능의 적합한 구현체죠. Spring security는 Security holder 내에 저장된 정보를 바탕으로 Controller의 메소드 접근 여부를 결정하여 Authorized 접근인지 아닌지 판단합니다:)

 

 

테스트

 필터도 객체입니다! 따라서 테스트를 할 수 있죠. 단위 테스트를 하기 위해 목객체를 만들어봐서 테스트 해봤습니다! 저희가 원하는대로 잘 작동할까요? 간단하게 두가지 테스트를 만들었습니다. 유저 정보가 없을 땐 로그인이 실패하고 유저 정보가 있을 땐 테스트가 성공합니다. 

@DisplayName("로그인 성공")
@WithMockUser
@Test
void login_success() throws Exception {
    // given
    String body = mappper.writeValueAsString(loginInfo);
    when(authenticationManager.authenticate(any())).thenReturn(authentication);

    // when
    ResultActions resultActions = mockMvc.perform(post("/login")
            .content(body)
            .contentType(MediaType.APPLICATION_JSON)
            .with(csrf()));

    // then
    StandardResponse standardResponse = StandardResponse.builder()
            .data(Map.of())
            .message(messageSourceAccessor.getMessage("common.login.succeeded"))
            .build();

    resultActions.andExpect(status().isOk());
}

@DisplayName("로그인 실패 by 유저 정보 없음")
@Test
void login_failure() throws Exception {
    // given
    String body = mappper.writeValueAsString(loginInfo);
    when(authenticationManager.authenticate(any())).thenAnswer(new Answer<Object>() {
        @Override
        public Object answer(InvocationOnMock invocation) throws Throwable {
            throw new BadCredentialsException("");
        }
    });

    // when
    ResultActions resultActions = mockMvc.perform(post("/login")
            .content(body)
            .contentType(MediaType.APPLICATION_JSON)
            .with(csrf()));

    // then
    StandardResponse standardResponse = StandardResponse.builder()
            .data(Map.of())
            .message(messageSourceAccessor.getMessage("common.login.needed"))
            .build();

    resultActions.andExpect(status().isUnauthorized());
}

 

 깔끔하게 모든 기능들이 돌아가는군요. 혹시 몰라 Postman으로 시도했을 때도 문제 없이 돌아갔습니다! 자! 이제 모든 구현이 끝났군요! 이제 문서화를 할 때가 됐습니다. 저만 알고 있는 규약은 아무 의미 없습니다. SW 개발은 팀워크이니까요! 팀 규약을 업데이트하러 WIKI로 가보죠!

<그림8> 로그인 기능 테스트

 

 


4. 문서화

WIKI

마지막으로 팀 규약을 업데이트 하도록 하죠! 저만 'Client에서 서버에 JWT를 보낼 땐 Authentication 헤더를 사용한다'와 'Server에서 client에 JWT를 보낼 땐 body에 json으로 보낸다'를 아는 것은 의미 없습니다! 팀 work이기 때문이죠! (사실 혼자 개발해도 규약 문서는 필요하다 생각합니다. 기억력은 신뢰할 수 없는 수단이기 때문이죠😢)

 

저희 팀의 WIKI에 있는 Coding convention입니다:)

 

<그림13 팀 코딩 규약>

 

저희 팀이 미리 작성한 팀 규약에 따라 Filter는 Common 폴더를 최상위 폴더로 가지기에, Common 목차에 번호 순으로 규약을 업데이트 하겠습니다. 

 

<그림14 Common 규약>


 

드디어 사용자의 UX를 고려한 로그인(JWT, 세션, 쿠키) 보안 전략 수립 및 구현의 모든 사고 과정 구현 결과까지 끝냈습니다😃!

긴 여정을 따라 와주셔서 감사합니다🙇‍♂️.

 

 부디 여러분들이 해당 글을 읽으셨을 때 초기에 제가 말씀 드렸던, 왜? Bullet point는? 색깔의 의미를 잘 이해하며 읽으셨으면 합니다. 사실상 두개를 하는 능력이 전 정말 중요하다고 생각합니다. 개념을 제대로 이해할 수 있기 때문이죠.

 

그럼 다음 번에 또 봅시다👋👋!

 

Thanks, im 김민석(갈레)!


출처

반응형