본문 바로가기
[Spring]/Spring Security

당신의 첫 프로젝트를 위한 스프링 시큐리티(4) - 로그인 테스트 및 JWT, redis 개념 정리

by 팡펑퐁 2024. 9. 8.
728x90
📌 이번 글에서는 지난 시간까지 만든 내용을 가볍게 정리하고, jwt 토큰 발급과 redis에 토큰을 저장하는 과정을 설명합니다.

🤗 저의 스프링 시큐리티 구현은 아래와 같은 시나리오를 기준으로 합니다.
- 프론트 엔드와 백엔드가 나뉘어 진행되는 프로젝트를 기반으로 하여 스프링 시큐리티 설정에서 로그인 페이지에 대한 설정을 따로 하지 않음
- JWT 토큰 인증 방식을 사용함
- 토큰 관리에 Redis를 이용함

 

🙉 이전 글 보기

첫 번째 글부터 정독하시면 보다 쉽게 이해하실 수 있습니다!

https://suzuworld.tistory.com/438 - 당신의 첫 프로젝트를 위한 스프링 시큐리티 톺아보기

이전 글

https://suzuworld.tistory.com/441 - AuthenticationManager, AuthenticationProvider, UserDetailsService, UserDetails

 

📖 목차

스프링 시큐리티 톺아보기

SecurityConfig 구성하기

인증 방식 개념과 AuthenticationFilter 

AuthenticationManager, AuthenticationProvider, UserDetailsService, UserDetails

로그인 테스트 및 JWT, Redis 개념 정리 (현재 글)

 

🚪들어가기 전에

지난 시간까지의 내용을 정리해 봅시다.

지난 시간의 글과 같은 내용이므로 이해하신 분들은 넘어가셔도 됩니다.

  • (0) 아키텍처에 등장하지 않지만 사용자가 아이디와 비밀번호를 정하고 회원가입을 했습니다. 이때 데이터베이스에는 회원 아이디와 비밀번호를 포함한 사용자 정보가 함께 저장되며 이때 비밀번호는 Bcrypt로 단방향 암호화되어 저장됩니다.
  • (1) Http Request로 사용자의 로그인 시도 요청이 왔습니다. 이 요청 메시지 안에는 사용자가 로그인 시 입력한 아이디와 비밀번호가 담겨있습니다.
  • (2) AuthenticationFilter는 사용자가 입력한 로그인 정보(아이디, 비밀번호)를 UsernamePasswordAuthenticationToken에 담습니다.
  • (3) AuthenticationManager에게 매니저 역할을 부여하고 UsernamePasswordAuthenticationToken을 주면서 로그인 검증을 하라고 시킵니다.
  • (4) AuthenticationManager는 지시자이며, 실제 검증 역할을 수행할 AuthenticationProvider에게 로그인 검증을 지시합니다. 저희는 이 중에 DaoAuthenticationProvider를 사용했습니다.
  • (5) AuthenticationProvider는 사용자가 로그인 시 입력한 정보인 UsernamePasswordAuthenticationToken을 가지고 있습니다. 이제 필요한 건 데이터베이스에 저장되어 있는 사용자의 정보입니다. 이 둘을 비교할 것이니까요.
    • 그런데, AuthenticationProvider가 데이터베이스에 접근하는 일을 직접 하지 않습니다. UserDetailsService라는 녀석에게 데이터베이스에 들어가서 정보를 가져오라고 시킵니다.(하청의 하청의 하청입니다.)
  • (6) UserDetailsService는 데이터베이스에 접근하여 정보를 가져오고 해당 정보를 UserDetails의 구현체에 담아 전달합니다.
  • (7) UserDetailsService는 이를 AuthenticationProvider에게 제공하여 실제 비교(로그인 검증)를 시작합니다. 이때 나온 결과가 로그인 성공/실패 여부이며 이를 리턴합니다.

 

지난 시간에 이미 로그인의 기본 구현이 끝났습니다. 그럼 포스트 맨을 통해 로그인이 되는지 확인해 봅시다!

 

🙆🏻‍♂️ 로그인 테스트

MemberController

  • 우리가 이전에 만든  회원가입 로직입니다.
  • 또한, 지난 시간에 기존 패스워드를 평문으로 저장하는 로직을 passwordEncoder를 통해 BCypt 암호화하여 저장하도록 변경했었죠.

 

  • 예상대로 회원가입이 정상적으로 동작합니다.
  • 비밀번호 역시 "bbb" 평문이 암호화되어 저장됨을 확인할 수 있습니다.
  • h2 데이터베이스에도 정상적으로 저장되네요.
  • 이제 로그인을 시도해 봅시다.
  • 로그인의 엔드포인트는 어떻게 지정했었죠?

 

SecurityConfig

  • SpringConfig의 scurityFilterChain에 "/login"이라고 설정해 두었습니다.
  • 여기서 한 가지 설명을 드리겠습니다.
    • 위의 "/enroll"과 "/login"의 차이는 무엇일까?
    • 이 부분을 이해하지 못하면 스프링 MVC의 공부가 필요합니다.
  • "/enroll" 엔드포인트는 컨트롤러 영역에서 지정한 엔드포인트이며, "/login"의 경우 컨트롤러에 도달하기 전 필터에 처리되는 엔드포인트입니다.
  • 따라서 "/login"이라는 엔드포인트를 컨트롤러에서 지정하여 로직을 만들게 되면 추가적인 비즈니스 로직을 구현할 수 있습니다.
  • 그러나, 로그인 처리과정이 모두 필터단에서 동작하므로 컨트롤러에 따로 구현을 하지 않았습니다.
  • 그렇기 때문에 응답값에 대한 부분을 컨트롤러에서 스프링이 제공하는 ResponseEntity 등을 사용할 수 없고 직접 응답값을 만들어내야 합니다.

 

  • 위의 가입정보로 로그인을 시도했더니 403 Forbidden이 뜨네요.
  • 분명 로그인은 성공했을 터입니다.
  • 로그에는 어떤 에러도 남지 않는데 사실 이 이유까지는 정확히 설명을 드릴 수 없으나 로그인이란 행위 자체가 사용자가 로그인 시 입력한 정보와 데이터베이스의 저장된 정보를 비교하는 과정이고, 비교가 끝났을 때의 성공/실패 처리가 아직 구현이 되지 않아 생긴 문제라는 것이라고만 말씀드릴 수 있습니다.
    • 혹시 정확한 이유를 알고 계신다면 댓글 부탁드립니다. ㅠ

 

AuthenticaionSuccessHandler & AuthenticaionFailureHandler

  • 위 두 인터페이스를 구현하는 구현체를 만듭니다.
    • 인증 성공/실패에 따른 핸들러가 이미 인터페이스로 존재합니다.
  • 이 과정은 제가 정확히 찾아보지 않아서 이렇게 밖에 설명을 드리지 못하는 점 양해 부탁드립니다.
  • 성공 or 실패했을 때의 각각 응답값은 테스트를 위해 로그만 찍어두었습니다.
  • 다음 글에 로그인 성공/실패 여부에 따른 응답값을 만들어보겠습니다.

 

  • 핵심은 passwordEncoder의 matches 메서드를 통해 로그인 시 입력한 비밀번호와 데이터베이스에 있는 사용자 정보의 비밀번호를 비교하여 true가 나오면 성공했을 때의 handler로 가고, false일 경우 실패했을 때의 handler로 가게 해야 정상적으로 작동한다는 것입니다.
  • 테스트 코드를 통해 인코딩 후 matches() 메서드로 비교 시 true 결과가 나오는지 확인해 보았습니다.

 

SecurityConfig - securityFilterChain

  • SecurityConfig의 securityFilterChain에 jwyAuthenticationFilter에 대한 세팅 정보를 만들어두었는데요. 여기서 위에 만들어두었던 성공 핸들러와 실패 핸들러를 추가합니다.

 

💙 로그인 시도

  • 비밀번호를 올바르게 입력했을 경우에는 성공 핸들러로 빠져 "로그인 성공!"이란 로그가 찍히고, 틀리게 입력했을 때는 실패 핸들러로 빠져 "로그인 실패!"란 로그가 찍힘을 확인할 수 있고 둘 다 응답은 200으로 변경되었음을 확인할 수 있습니다.

 

🔑 JWT 발급과 관리

 이제 우리가 생각해 볼 내용은 로그인 이후의 과정입니다. 우리가 그동안 사용했던 웹사이트를 생각해 봅시다. 일단 로그인을 하면 내 정보 수정에 들어가도, 자신의 블로그에 글을 쓰려고 해도, 결제 내역이나 장바구니 등에 들어가도 별도의 인증과정을 거치지 않습니다.(물론, 비밀번호를 한 번 더 입력하는 경우가 있긴 합니다.) 이것이 가능한 이유에 대해 생각해 봅시다.

 

 기본적으로 HTTP 통신은 비연결성과 무상태성이란 특징을 가지고 있습니다. 즉, HTTP는 연결을 지속하지도 않으며 상태를 유지하지도 않습니다. 우리가 로그인을 처리하여 성공하게 되면 로그인을 했다는 연결과 상태를 다음 통신에 유지하지 않는다는 것이죠. 단순히 생각해 이 방식대로라면 모든 요청에 로그인을 계속하여 검증을 거치고 원하는 작업을 수행하도록 해야 합니다. 그런데, 어떠한 웹사이트도 이러한 방식으로 운영되지는 않죠.

 

이와 관련된 내용은 이미 지난 글에서 다루었으므로 넘어가도록 하겠습니다. 우리는 JWT를 발급하고 관리하여 클라이언트가 토큰의 유효 시간 내에 재 로그인 없이 자유롭게 통신하도록 할 것입니다. 그럼 지금까지 만든 로직에 새로운 기능을 추가해야 합니다. 그것은 바로 로그인이 성공하면 JWT를 발급하여 클라이언트에게 응답값으로 전송하는 것입니다.

 

⚙️ JWT의 동작 원리

여기선 간단히 요약된 내용을 설명드리니 JWT의 내부 구조나 자세한 내용이 궁금하신 분들은 검색을 통해 해결해 주시면 됩니다.

JWT에는 Access Token과 Refresh Token이 존재합니다.

로그인이 성공하여 이 두 개의 토큰이 발급이 되면 서버에서는 응답값으로 클라이언트에 토큰을 전달합니다.

Access Token은 유효 시간이 짧은 편이며, Refresh Token은 Access Token에 비해 상대적으로 유효 시간이 깁니다.

클라이언트는 발급받은 Access Token으로 권한이 필요한 리소스에 접근합니다.

Access Token이 만료되었다면 서버에서는 Access Token이 만료되었기 때문에 에러 응답을 리턴할 것입니다.

보통은 클라이언트에서 먼저 Access Token이 만료되었음을 감지하고, 서버에 Refresh Token을 포함하여 새로운 Access Token을 요청합니다.

클라이언트에서 Access Token 재발급을 위해 Refresh Token을 보내 서버로부터 새로운 Access Token을 발급받게 됩니다.

이를 명시적 Refresh Token 요청이라고 합니다.

이때, Refresh Token의 유효 시간도 끝나게 되면 재로그인을 통해 두 토큰을 재발급받습니다.

 

자동으로 Refresh Token을 요청하게 구현할 수도 있습니다. 모든 요청에 Access Token과 Refresh Token을 싣고 서버에게 Access Token의 유효 시간 체크를 맡겨 만료되었을 경우 함께 보낸 Refresh Token으로 Access Token을 재발급 처리하도록 구현할 수 있습니다.

 

일반적으로 첫 번째 방법인 명시적 Refresh Token 요청이 더 많이 사용됩니다. 

이 방법은 클라이언트가 Access Token의 만료를 감지하고, 필요한 경우에만 Refresh Token을 사용하여 새로운 Access Token을 요청하는 방식으로 불필요한 요청을 줄일 수 있습니다.

두 번째 방법은 구현이 간단할 수 있지만 매 요청마다 Refresh Token을 전송하는 것은 보안상 위험할 수 있습니다.

Refresh Token을 사용하는 의미가 퇴색됩니다.

보안과 효율성을 고려할 때 첫 번째 방법을 사용하는 것이 좋습니다.

 

👮🏻‍♂️ 토큰의 구조와 관리

토큰 구조

Access Token

  • 일반적으로 JWT(JSON Web Token) 형식으로 발급됩니다.
  • JWT는 세 부분으로 구성되어 있습니다: Header, Payload, Signature
  • 각 부분은 점(.)으로 구분되어 있으며, 전체 JWT는 다음과 같은 형식으로 나타납니다
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

 

JWT 웹사이트에서 디코딩해 보기

https://jwt.io/

  • 사진과 같이 세 부분으로 나뉘어 있으며 디코딩 시 각 세 부분에 들어있는 내용을 확인할 수 있습니다.

Header

  • JWT의 타입과 서명 알고리즘을 정의합니다.
  • 구성: 일반적으로 JSON 객체로 구성되며 다음과 같은 정보를 포함합니다.
    • alg: 사용된 서명 알고리즘 (예: HMAC SHA256, RSA 등)
    • typ: 토큰의 타입 (보통 "JWT"로 설정)

Payload 

  • 토큰에 담길 클레임(claims)을 포함합니다.
  • 클레임은 사용자 정보나 권한, 토큰의 유효 기간 등을 나타냅니다.
    • iss(발급자), exp(만료 시간), sub(주제), aud(대상) 등이 있습니다.

Signature

  • JWT의 무결성을 보장하고 토큰이 변조되지 않았음을 확인합니다.
  • Header와 Payload를 인코딩한 후, 비밀 키를 사용하여 서명 알고리즘에 따라 서명합니다.
    • 여기서 비밀 키는 서버에서 임의로 만듭니다. 이 과정은 구현 과정에 자세히 보여드립니다.
  • 이 과정에서 사용된 알고리즘은 위에 Header에 명시된 alg와 같습니다.

 

복잡하다고 느끼겠지만 스프링 시큐리티에서 많은 부분을 자동으로 해주기 때문에 여기서는 개념만 살펴보고 실제로 구현을 통해 이해하는 것이 더 빠릅니다. 따라서 가볍게 읽어보시면 됩니다!

빨간색으로 표시한 부분이 핵심 내용입니다.

Refresh Token

  • Refresh Token의 구조 역시 JWT를 따릅니다.
  • Access Token의 재발급에 목적이 있기 때문에 클레임 등의 내용이 Access Token과 차이가 있습니다.
  • 서버에서 관리되며 데이터베이스에 저장하기도 하지만, 여기서는 redis에 저장하도록 구현할 예정입니다.

 

검증 과정

Access Token 검증

Signature 검증

  • 서버는 Access Token의 Signature를 검증하여 토큰이 변조되지 않았는지 확인합니다.
  • 이 과정에서 서버는 토큰의 Header에 포함된 알고리즘과 비밀 키를 사용합니다.
    • 토큰에 들어있는 Header와 Payload를 서버의 비밀 키로 해싱하여 토큰의 Signature와 같은지 확인하는 작업입니다.
    • 내용이 조금이라도 조작되었다면 결괏값이 완전 달라지는 해시 함수의 특징을 이용해 Header와 Payload의 조작 여부를 확인할 수 있습니다.
      • 여기서 한 가지 특징은 Header와 Payload의 내용이 오픈되어 있다는 것입니다. 즉, JWT 안에 비밀번호와 같은 보안 정보를 담아서는 안됩니다.
    • 서버의 비밀 키로 해싱한 해시값이 서로 같다는 이야기는 두 해시값 모두 서버에서 만들어진 비밀 키를 사용했다는 이야기이므로 서버에서 발급하였다는 것을 증명하게 됩니다.

유효 기간 확인

  • Payload에 포함된 exp(만료 시간) 필드를 확인하여 토큰이 만료되었는지 확인합니다.

발급자 확인

  • Payload에 포함된 iss(발급자) 필드를 확인하여 해당 토큰이 신뢰할 수 있는 서버에서 발급된 것인지 확인합니다.

 

Refresh Token 검증

데이터 저장소

  • Refresh Token은 데이터 저장소에 저장됩니다.
  • 서버는 클라이언트가 보낸 Refresh Token을 데이터 저장소에서 조회하여 해당 토큰이 존재하는지 확인합니다.
    • 데이터 저장소는 RDBMS일수도 있고, redis, memcached와 같은 인메모리 저장소를 사용할 수도 있습니다.

유효성 확인

  • redis에서 조회한 Refresh Token이 유효한지, 만료되었는지, 또는 이미 사용되었는지 확인합니다.
    • 일반적으로 Refresh Token은 한 번 사용되면 무효화되도록 설계되지만 구현하기 나름입니다.

사용자 정보 확인

  • Redis에서 Refresh Token과 함께 저장된 사용자 정보(예: 사용자 ID)를 확인하여, 해당 토큰이 어떤 사용자와 연결되어 있는지 확인합니다.

 

🟥 redis

  • redis는 오픈 소스 인메모리 데이터 구조 저장소로 주로 데이터베이스, 캐시, 메시지 브로커로 사용됩니다.
  • Redis는 키-값 저장소로 작동하며 다양한 데이터 구조(문자열, 해시, 리스트, 셋 등)를 지원합니다.
  • 빠른 읽기 및 쓰기 성능을 제공하며 데이터가 메모리에 저장되기 때문에 높은 속도를 자랑합니다.

 

Refresh Token을 관리할 때 RDBMS가 아닌 Redis를 사용하는 이유

  • 위에 언급했듯이 Refresh Token을 관리할 때 기존에 사용 중인 RDBMS(ex. mysql)이 아닌 redis를 사용한다고 했습니다.
  • 생각해 보면 데이터 저장소의 역할을 하는 무엇이든 Refresh Token을 저장할 수 있습니다.
  • 그런데, 왜 redis를 사용하는 것일까요?

속도

  • redis는 인메모리 데이터베이스로 데이터 접근 속도가 매우 빠릅니다.
  • Refresh Token의 검증 및 발급 과정에서 빠른 응답이 필요하기 때문에 Redis가 적합합니다.

TTL(시간 제한)

  • Redis는 각 키에 대해 TTL을 설정할 수 있어 Refresh Token의 만료 시간을 쉽게 관리할 수 있습니다.
  • 만료된 토큰은 redis에서 자동으로 삭제되므로 관리가 간편합니다.

비용 효율성

  • redis는 메모리 기반이기 때문에 자주 접근하는 데이터(예: Refresh Token)를 저장하는 데 비용 효율적입니다.
  • RDBMS는 디스크 기반으로 자주 읽고 쓰는 데이터에 대해 성능 저하가 발생할 수 있습니다. 또한, 기존의 여러 데이터를 다룰 때 JPA 등과 같은 쿼리문을 날리는 데에도 비용이 많이 드는데 로그인 처리 하나를 위해 RDBMS에 저장하는 건 비효율적이라고 봅니다.

간단한 데이터 구조

  • Refresh Token은 간단한 키-값 쌍으로 저장할 수 있어 redis의 데이터 구조와 잘 맞습니다. RDBMS에서는 불필요하게 복잡한 테이블 구조가 필요할 수 있습니다.

 

👨🏻‍💻 다음 시간

  • 서버에서 직접 구현하면서 JWT와 redis에 대해 자세히 다뤄보겠습니다.
728x90