[Spring Security] JWT 인증(Authentication)
이전 유닛에서 여러분들은 Hello Spring Security 애플리케이션을 구현해 보면서 Spring Security의 기본적인 폼 로그인 인증 방식을 경험해 보았습니다.
그런데 Spring Security에서 지원하는 폼 로그인 방식은 기본적으로 서버 측 세션을 이용해 상태(Stateful)를 유지하는 방식이므로 REST API를 이용한 CSR 방식의 백엔드 서버와 어울리는 방식은 아닙니다.
따라서 이번 유닛에서는 REST API 통신을 사용하는 백엔드 서버에 사용하기 적합한 자격 증명 방식 중 가장 활용도가 높은 JWT에 대해서 학습할 것입니다.
이번 유닛을 통해 인증된 사용자를 증명하기 위한 토큰 방식이 무엇이며, 웹에서의 사용에 특화된 JWT를 이용한 자격 증명은 어떻게 이루어지는지 알아보도록 하겠습니다.
학습 목표
- JWT(JSON Web Token)를 사용하는 이유를 설명할 수 있다.
- JWT를 이용한 인증 방식을 이해할 수 있다.
- JWT를 사용하여 얻을 수 있는 장단점을 설명할 수 있다.
Chapter - JWT(JSON Web Token) 개요
이번 챕터에서는 인증된 사용자의 자격을 추가적으로 증명하는 토큰 방식 중에서 가장 범용적으로 사용되는 JWT를 통해 토큰을 이용한 자격 증명이 어떻게 이루어지는지 학습해 보도록 하겠습니다.
여러분들이 이전 유닛에서 학습한 폼 로그인 인증을 통한 세션 방식과 토큰 방식 간에 어떤 차이점이 있는지 고민해 보며 학습하길 바랍니다.
학습 목표
- 인증된 사용자인지를 증명하는 토큰 방식과 세션 방식의 차이점을 설명할 수 있다.
- JWT(JSON Web Token)가 무엇인지 설명할 수 있다.
- JWT의 구성 요소를 설명할 수 있다.
- JWT의 동작 방식을 이해할 수 있다.
토큰 기반 자격 증명
HTTP 프로토콜은 request를 전송한 후, response를 수신하게 되면 연결을 끊는 비 연결성(Connectionless)의 특성을 가지고 있고 또한 request와 response에 대한 상태를 저장하지 않는 비 상태성(Stateless)의 특성이 있기 때문에 로그인 인증이 성공적으로 수행되었다 하더라도 서버 측에서는 매번 request를 수신할 때마다 이 request가 인증된 사용자가 보낸 request인지 알 방법이 없습니다.
이러한 HTTP 특성으로 인해 사용자의 인증이 성공적으로 이루어졌을 때, 인증된 사용자 request의 상태를 유지하기 위한 수단이 필요하게 되었으며 대표적인 수단이 바로 세션입니다.
세션 기반 자격 증명 방식
세션 기반 자격 증명 방식은 서버 측에 인증된 사용자의 정보를 세션 형태로 세션 저장소에 저장하는 방식입니다.
즉, 클라이언트 측에서 서버 측의 리소스를 요청하면 서버 측에서는 "서버 측 리소스를 요청하는 클라이언트에게 우리가 정보를 줘도 괜찮은가?"를 확인하기 위해 서버 측 세션 저장소에 저장된 세션 정보와 사용자가 제공하는 정보가 일치하는지 확인하는 방식입니다.
그렇다면 세션 기반 자격 증명은 어떠한 특성이 있는지 간단하게 확인해 보겠습니다.
✅ 세션 기반 자격 증명의 특징
- 세션은 인증된 사용자 정보를 서버 측 세션 저장소에서 관리합니다.
- 생성된 사용자 세션의 고유 ID인 세션 ID는 클라이언트의 쿠키에 저장되어 request 전송 시, 인증된 사용자인지를 증명하는 수단으로 사용됩니다.
- 세션 ID만 클라이언트 쪽에서 사용하므로 상대적으로 적은 네트워크 트래픽을 사용합니다.
- 서버 측에서 세션 정보를 관리하므로 보안성 측면에서 조금 더 유리합니다.
- 서버의 확장성 면에서는 세션 불일치 문제가 발생할 가능성이 높습니다.
- 세션 데이터가 많아질수록 서버의 부담이 가중될 수 있습니다.
- SSR(Server Side Rendering) 방식의 애플리케이션에 적합한 방식입니다.
세션 기반의 자격 증명 방식은 인증된 사용자의 상태를 유지하기 위한 전통적인 방식입니다.
그런데 세션 방식 이외에 현대적인 웹 애플리케이션에서 점차 많이 사용되고 있는 방식이 있는데, 그것은 바로 토큰 기반의 자격 증명 방식입니다.
토큰 기반 자격 증명 방식
토큰 기반 자격 증명 방식이 무엇인지 알아보기 전에 우리가 일상생활에서 사용할 수 있는 토큰에 대해서 먼저 떠올려보도록 합시다.
토큰이 뭐죠?라는 질문을 한다면 여러분들은 위와 같은 동전을 흔하게 떠올릴 수 있을 겁니다.
- 대중교통을 이용할 때 사용하는 토큰
- 오락실 게임에 사용하는 토큰
- 행사에 입장하기 위해서 주최 측에서 나누어 준 토큰
- 놀이공원에 입장료를 내면 주는 토큰
위 토큰들은 공통으로 나는 돈을 지불했고, 이 시설(혹은 서비스)을 사용할 수 있어!라는 메시지를 담고 있습니다.
이 토큰이 황금색으로 빛난다면 더 프리미엄 기능을 사용하게끔 만들었을 수도 있을 테고요.
일상생활에서의 이러한 개념에 더해 애플리케이션 보안 측면에서의 토큰은 일종의 출입 카드에 비교될 수 있습니다.
여러분들이 특정 빌딩을 방문했을 때, 1층 로비 안내 데스크에서 임시 출입 카드를 발급해 주는 상황을 상상해 보세요.
여러분은 먼저 안내 데스크 직원의 요청에 따라서 방문자 목록에 여러분의 신원 정보(이름, 전화번호, 방문 회사, 방문 목적 등)를 입력하고, 임시 출입 카드를 건네받습니다.
여기에서 신원 정보는 여러분 자신을 증명하는 자격 증명 정보(Credential)에 비유될 수 있고, 임시 출입 카드는 인증된 여러분을 증명하는 토큰에 비유될 수 있습니다.
그런데 발급받은 임시 출입 카드로 빌딩 내에 있는 모든 장소의 출입문을 열 수 있나요? 아마도 여러분이 방문하는 층의 특정 사무실에만 출입이 가능할 것입니다.
이처럼 애플리케이션에서 사용되는 토큰 역시 인증된 사용자의 자격을 증명하는 동시에 접근 권한을 부여해 접근 권한이 부여된 특정 리소스에만 접근할 수 있게 하는 역할을 합니다.
애플리케이션 보안 측면에서의 토큰에 대해서 대략적인 개념을 이해했을 테니 토큰을 사용한 인증 방식은 어떠한 특징을 가지고 있는지 확인해 봅시다.
✅ 토큰 기반 자격 증명의 특징
- 토큰에 포함된 인증된 사용자 정보는 서버 측에서 별도의 관리를 하지 않습니다.
- 생성된 토큰을 헤더에 포함해 request 전송 시, 인증된 사용자인지를 증명하는 수단으로 사용됩니다.
- 토큰 내에 인증된 사용자 정보 등을 포함하고 있으므로 세션에 비해 상대적으로 많은 네트워크 트래픽을 사용합니다.
- 기본적으로 서버 측에서 토큰을 관리하지 않으므로 보안성 측면에서 조금 더 불리합니다.
- 인증된 사용자 request의 상태를 유지할 필요가 없기 때문에 서버의 확장성 면에서 유리하고, 세션 불일치 같은 문제가 발생하지 않습니다.
- 토큰에 포함되는 사용자 정보는 토큰의 특성상 암호화가 되지 않기 때문에 공격자에게 토큰이 탈취될 경우, 사용자 정보를 그대로 제공하는 셈이 됩니다. 따라서 민감한 정보는 토큰에 포함하지 말아야 합니다.
- 기본적으로 토큰이 만료되기 전까지는 토큰을 무효화시킬 수 없습니다.
- CSR(Client Side Rendering) 방식의 애플리케이션에 적합한 방식입니다.
세션의 경우 서버 확장 시, 세션 불일치 문제가 발생할 수 있지만 Sticky Session, Session Clustering, Session 저장소의 외부 분리 등의 작업을 통해 보완하고 있습니다.
그리고 토큰의 경우, 기본적으로 토큰 무효화를 할 수 없지만 key/value 쌍의 형태로 저장되는 Redis 같은 인메모리 DB에 무효화시키고자 하는 토큰의 만료 시간을 짧게 주어 해당 토큰을 사용하지 못하게 하는 등의 방법을 사용해 토큰 무효화 문제를 보완하고 있습니다.
핵심 포인트
- 세션 기반 자격 증명 방식은 서버 측에 인증된 사용자의 정보를 세션 형태로 세션 저장소에 저장하는 방식이다.
- 세션 기반 자격 증명의 특징
- 세션은 인증된 사용자 정보를 서버 측 세션 저장소에서 관리한다.
- 생성된 사용자 세션의 고유 ID인 세션 ID는 클라이언트의 쿠키에 저장되어 request 전송 시, 인증된 사용자인지를 증명하는 수단으로 사용된다.
- 세션 ID만 클라이언트 쪽에서 사용하므로 상대적으로 적은 네트워크 트래픽을 사용한다.
- 서버 측에서 세션 정보를 관리하므로 보안성 측면에서 조금 더 유리하다.
- 서버의 확장성 면에서는 세션 불일치 문제가 발생할 가능성이 높다.
- 세션 데이터가 많아지면 질수록 서버의 부담이 가중될 수 있다.
- SSR(Server Side Rendering) 방식의 애플리케이션에 적합한 방식이다.
- 토큰 기반 자격 증명 방식은 인증된 사용자의 정보를 토큰에 저장하고, 접근 권한을 부여해 접근 권한이 부여된 특정 리소스에만 접근할 수 있게 하는 방식이다.
- 토큰 기반 자격 증명의 특징
- 토큰에 포함된 인증된 사용자 정보는 서버 측에서 별도의 관리를 하지 않는다.
- 생성된 토큰을 헤더에 포함해 request 전송 시, 인증된 사용자인지를 증명하는 수단으로 사용된다.
- 토큰 내에 인증된 사용자 정보 등을 포함하고 있으므로 세션에 비해 상대적으로 많은 네트워크 트래픽을 사용한다.
- 기본적으로 서버 측에서 토큰을 관리하지 않으므로 보안성 측면에서 조금 더 불리하다.
- 인증된 사용자 request의 상태를 유지할 필요가 없기 때문에 서버의 확장성 면에서 유리하고, 세션 불일치 같은 문제가 발생하지 않는다.
- 토큰에 포함되는 사용자 정보는 토큰의 특성상 암호화가 되지 않기 때문에 공격자에게 토큰이 탈취될 경우, 사용자 정보를 그대로 제공하는 셈이 되므로 민감한 정보는 토큰에 포함하지 말아야 한다.
- 기본적으로 토큰이 만료되기 전까지는 토큰을 무효화시킬 수 없다.
- CSR(Client Side Rendering) 방식의 애플리케이션에 적합한 방식이다.
심화 학습
- Sticky Session에 대해서 더 알아보고 싶다면 아래 링크를 참고하세요.
- Session Clustering에 대해서 더 알아보고 싶다면 아래 링크를 참고하세요.
JWT(JSON Web Token)란?
JWT(JSON Web Token)
JWT는 데이터를 안전하고 간결하게 전송하기 위해 고안된 인터넷 표준 인증 방식으로써 토큰 인증 방식에서 가장 범용적으로 사용되며 JSON 포맷의 토큰 정보를 인코딩 후, 인코딩 된 토큰 정보를 Secret Key로 서명(Sign)한 메시지를 Web Token으로써 인증 과정에 사용합니다.
JWT 공식 사이트 : https://jwt.io/
JWT의 종류
JWT는 보통 다음과 같이 두 가지 종류의 토큰을 사용자의 자격 증명에 이용합니다.
- 액세스 토큰(Access Token)
- 리프레시 토큰(Refresh Token)
Access Token은 보호된 정보들(사용자의 이메일, 연락처, 사진 등)에 접근할 수 있는 권한 부여에 사용합니다.
클라이언트가 처음 인증을 받게 될 때(로그인 시), Access Token과 Refresh Token 두 가지를 다 받지만, 실제로 권한을 얻는 데 사용하는 토큰은 Access Token입니다.
그럼 Access Token 만 있으면 되는 것 아닌가요?🤔
맞습니다. 권한을 부여받는 데엔 Access Token만 가지고 있으면 됩니다.
하지만 Access Token을 만약 악의적인 사용자가 얻어냈다면 어떻게 될까요? 이 악의적인 사용자는 자신이 00 사용자인 것처럼 서버에 여러 가지 요청을 보낼 수 있습니다(만약 돈과 관련된 문제라면 큰일이 날 수 있겠네요!).
⭐ 그렇기 때문에 Access Token에는 비교적 짧은 유효 기간을 주어 탈취되더라도 오랫동안 사용할 수 없도록 하는 것이 좋습니다.
Access Token의 유효기간이 만료된다면 Refresh Token을 사용하여 새로운 Access Token을 발급받습니다. 이때, 사용자는 다시 로그인 인증을 할 필요가 없습니다.
Refresh Token 도 탈취당한다면요? 🤔
유효기간이 긴 Refresh Token 마저 악의적인 유저가 얻어낸다면 큰 문제가 될 것입니다.
Refresh Token을 이용해 Access Token을 다시 발급받으면 사용자에게 피해를 입힐 수 있기 때문입니다.
그렇기 때문에 사용자의 편의보다 정보를 지키는 것이 더 중요한 웹 애플리케이션은 Refresh Token을 사용하지 않는 곳이 많습니다.
세상에 완벽한 보안은 없으므로 (있다면 쿠키, 세션, JWT, OAuth 등 다양한 방법들을 공부하지 않아도 되겠죠!) 각 방법의 장단점을 참고하며 필요에 맞게 사용하는 것이 좋습니다.
JWT 구조
JWT는 위 그림과 같이 .으로 나누어진 세 부분이 존재합니다.
- Header
이 JSON 객체를 base64 방식으로 인코딩하면 JWT의 첫 번째 부분이 완성됩니다.{ "alg": "HS256", "typ": "JWT" }
- Header는 이것이 어떤 종류의 토큰인지(지금의 경우엔 JWT), 어떤 알고리즘으로 Sign할지 정의합니다. JSON Web Token이라는 이름에 걸맞게 JSON 포맷 형태로 정의한다는 것을 기억하세요.
- Payload어떤 정보에 접근 가능한지에 대한 권한을 담을 수도 있고, 사용자의 이름 등 필요한 데이터를 담을 수 있습니다.
첫 번째 부분과 마찬가지로, 위 JSON 객체를 base64로 인코딩하면 JWT의 두 번째 블록이 완성됩니다.{ "sub": "someInformation", "name": "phillip", "iat": 151623391 }
- Payload는 다음으로 설명할 Signature를 통해 유효성이 검증될 정보이긴 하지만, 민감한 정보는 담지 않는 것이 좋습니다.
- Payload에는 서버에서 활용할 수 있는 사용자의 정보가 담겨 있습니다.
- Signature이렇게 암호화된 메시지는 토큰의 위변조 유무를 검증하는 데 사용됩니다.
HMACSHA256(base64UrlEncode(header) + '.' + base64UrlEncode(payload), secret);
- 예를 들어, 만약 HMAC SHA256 알고리즘(암호화 방법 중 하나)을 사용한다면 Signature는 아래와 같은 방식으로 생성됩니다.
- base64로 인코딩 된 첫 번째, 그리고 두 번째 부분이 완성되었다면, Signature에서는 원하는 비밀 키(Secret Key)와 Header에서 지정한 알고리즘을 사용하여 Header와 Payload에 대해서 단방향 암호화를 수행합니다.
JWT 사용 예시
JWT는 권한 부여에 매우 유용합니다.
새로 다운로드한 A라는 앱이 Gmail과 연동되어 이메일을 읽어와야 한다고 생각해 봅시다.
이 경우, 사용자는
- Gmail 인증서버에 로그인 정보(아이디, 비밀번호)를 제공합니다.
- 인증에 성공할 경우, JWT를 발급받습니다.
- A 앱은 JWT를 사용해 해당 사용자의 이메일을 읽거나 사용할 수 있습니다.
토큰 기반 인증 절차
- 클라이언트가 서버에 아이디/비밀번호를 담아 로그인 요청을 보냅니다.
- 아이디/비밀번호가 일치하는지 확인하고, 클라이언트에게 보낼 암호화된 토큰을 생성합니다.
- Access Token과 Refresh Token을 모두 생성합니다.
- 토큰에 담길 정보(Payload)는 사용자를 식별할 정보, 사용자의 권한 정보 등이 될 수 있습니다.
- Refresh Token 이용해 새로운 Access Token을 생성할 것이므로 두 종류의 토큰이 같은 정보를 담을 필요는 없습니다.
- Access Token과 Refresh Token을 모두 생성합니다.
- 토큰을 클라이언트에게 전송하면, 클라이언트는 토큰을 저장합니다.
- 저장하는 위치는 Local Storage, Session Storage, Cookie 등이 될 수 있습니다.
- 클라이언트가 HTTP Header(Authorization Header) 또는 쿠키에 토큰을 담아 request를 전송합니다.
- 서버는 토큰을 검증하여 "아 우리가 발급해 준 토큰이 맞네!"라는 판단이 될 경우, 클라이언트의 요청을 처리한 후 응답을 보내준다.
핵심 포인트
- JWT는 일반적으로 다음과 액세스 토큰(Access Token)과 리프레시 토큰(Refresh Token)을 사용자의 자격 증명에 이용합니다.
- Access Token에는 비교적 짧은 유효 기간을 주어 탈취되더라도 오랫동안 사용할 수 없도록 하는 것이 권장된다.
- JWT는 Header.Payload.Signature의 구조로 이루어진다.
- Base64로 인코딩 되는 Payload는 손쉽게 디코딩이 가능하므로 민감한 정보는 포함하지 않아야 한다.
심화 학습
- JWT에 대해서 더 알아보고 싶다면 아래 링크를 참고하세요.
JWT의 장점과 단점
JWT를 통한 인증의 장점
- 상태를 유지하지 않고(Stateless), 확장에 용이한(Scalable) 애플리케이션을 구현하기 용이합니다.
- 서버는 클라이언트에 대한 정보를 저장할 필요 없습니다. (토큰이 정상적으로 검증되는지만 판단합니다)
- 클라이언트는 request를 전송할 때마다 토큰을 헤더에 포함시키면 됩니다.
- 여러 대의 서버를 이용한 서비스라면 하나의 토큰으로 여러 서버에서 인증이 가능하기 때문에 JWT를 사용하는 것이 효과적입니다.
- 클라이언트가 request를 전송할 때마다 자격 증명 정보를 전송할 필요가 없습니다.
- HTTP Basic 같은 인증 방식은 request를 전송할 때마다 자격 증명 정보를 포함해야 하지만 JWT의 경우 토큰이 만료되기 전까지는 한 번의 인증만 수행하면 됩니다.
- 인증을 담당하는 시스템을 다른 플랫폼으로 분리하는 것이 용이합니다.
- 사용자의 자격 증명 정보를 직접 관리하지 않고, Github, Google 등의 다른 플랫폼의 자격 증명 정보로 인증하는 것이 가능합니다.
- 토큰 생성용 서버를 만들거나, 다른 회사에서 토큰 관련 작업을 맡기는 것 등 다양한 활용이 가능합니다.
- 권한 부여에 용이하다
- 토큰의 Payload(내용물) 안에 해당 사용자의 권한 정보를 포함하는 것이 용이합니다.
JWT를 통한 인증의 단점
- Payload는 디코딩이 용이합니다.
- Payload는 base64로 인코딩 되기 때문에 토큰을 탈취하여 Payload를 디코딩하면 토큰 생성 시 저장한 데이터를 확인할 수 있습니다. 따라서 Payload에는 민감한 정보를 포함하지 않아야 합니다.
- 토큰의 길이가 길어지면 네트워크에 부하를 줄 수 있습니다.
- 토큰에 저장하는 정보의 양이 많아질수록 토큰의 길이는 길어집니다.
- 따라서 request를 전송할 때마다 길이가 긴 토큰을 함께 전송하면 네트워크에 부하를 줄 수 있습니다.
- 토큰은 자동으로 삭제되지 않습니다.
- 즉 한 번 생성된 토큰은 자동으로 삭제되지 않기 때문에 토큰 만료 시간을 반드시 추가해야 합니다.
- 또한 토큰이 탈취된 경우 토큰의 기한이 만료될 때까지 토큰 탈취자가 해당 토큰을 정상적으로 이용할 수 있으므로 만료 시간을 너무 길게 설정하지 않아야 합니다.
핵심 포인트
- JWT는 많은 네트워크 트래픽을 사용하지 않도록 Payload에 많은 정보를 포함하지 않는 것이 바람직하다.
- Payload는 base64로 인코딩 되기 때문에 토큰을 탈취하여 Payload를 디코딩하면 토큰 생성 시 저장한 데이터를 손쉽게 확인할 수 있으므로 Payload에는 민감한 정보를 포함하지 않아야 한다.
- 토큰은 자동으로 삭제되지 않으므로 반드시 토큰 만료 시간을 지정해야 한다.
- 토큰이 탈취될 경우를 대비해서 토큰 만료 시간을 너무 길게 설정하지 않는 것이 바람직하다.
JWT 생성 및 검증 테스트
이전 챕터에서 웹 애플리케이션 보안 측면에서의 토큰이 무엇인지 JWT가 무엇이고, JWT가 어떤 구조로 이루어지는지를 살펴보았습니다.
이번 챕터에서는 여러분들이 JWT를 직접 생성하고, 검증해 보면서 JWT를 조금 더 현실적으로 이해할 수 있는 시간을 가져보도록 하겠습니다.
이번 챕터에서 여러분들이 JWT의 생성, 검증에 대한 구현 코드를 이해할 수 있다면 여러분들이 구현하고자 하는 애플리케이션에 JWT 기반의 인증 프로세스를 적용할 수 있을 거라고 생각합니다
JWT 생성 및 검증 테스트를 위한 프로젝트 설정
JWT 생성 및 검증 테스트를 위해서 먼저 다음과 같이 프로젝트 설정을 진행하면 됩니다.
설정은 어렵지 않습니다. 여러분들이 이미 모두 다 알고 있는 방식입니다.
- Spring Boot 기반의 템플릿 프로젝트 생성 및 의존 라이브러리 추가
JWT 생성 및 검증 테스트 자체는 Spring Framework와 상관없습니다.
다만 우리가 이후 학습에서 Spring MVC 기반의 애플리케이션에 Spring Security를 적용하고 인증 프로세스에 JWT를 이용할 것이므로 아래의 spring-starter를 포함한 Spring Boot 기반의 템플릿 프로젝트를 생성하겠습니다.
(Spring Boot 기반의 템플릿 프로젝트 생성은 Spring Boot Initializr를 이용하면 됩니다.)
dependencies {
// (1)
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
// (2)
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
}
[코드 4-55] JWT 생성 및 검증 테스트를 위한 의존 라이브러리 추가
코드 4-55는 JWT 생성 및 검증 테스트를 위해 템플릿 프로젝트에 필요한 의존 라이브러리입니다.
(1)은 이후 학습에서 필요한 Spring Framework와 관계된 라이브러리이며, 지금은 사용되지 않습니다.
(2)는 여러분들이 JWT 생성 및 검증 테스트를 수행하기 위해 필요한 JWT 라이브러리입니다.
JWT를 위한 대표적인 라이브러리에는 jjwt와 Java JWT가 있는데 이번 챕터에서는 Java 진영에서 가장 많이 사용되는 jjwt를 사용하겠습니다.
구글이나 스택오버플로우 등에서 jjwt 라이브러리에 대한 API 사용 방법을 검색하면 0.9 버전에 대한 내용이 대부분입니다.
이번 챕터에서는 0.11.5로 버전업 된 jjwt를 사용하므로, 검색을 통해 추가 학습을 하고자 할 때 혼동이 없길 바랍니다.
최신 버전의 jjwt 사용법을 더 알아보고 싶다면 [심화 학습]을 참고하세요.
JWT 생성
✅ JWT 생성 기능 구현
먼저 JWT(JSON Web Token)를 생성하고 검증하는 역할을 수행하는 클래스를 정의하고, JWT 생성 메서드를 추가해 보겠습니다.
package com.springboot.auth;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.io.Encoders;
import io.jsonwebtoken.security.Keys;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Date;
import java.util.Map;
public class JwtTokenizer {
// (1)
public String encodeBase64SecretKey(String secretKey) {
return Encoders.BASE64.encode(secretKey.getBytes(StandardCharsets.UTF_8));
}
// (2)
public String generateAccessToken(Map<String, Object> claims,
String subject,
Date expiration,
String base64EncodedSecretKey) {
Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey); // (2-1)
return Jwts.builder()
.setClaims(claims) // (2-2)
.setSubject(subject) // (2-3)
.setIssuedAt(Calendar.getInstance().getTime()) // (2-4)
.setExpiration(expiration) // (2-5)
.signWith(key) // (2-6)
.compact(); // (2-7)
}
// (3)
public String generateRefreshToken(String subject, Date expiration, String base64EncodedSecretKey) {
Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
return Jwts.builder()
.setSubject(subject)
.setIssuedAt(Calendar.getInstance().getTime())
.setExpiration(expiration)
.signWith(key)
.compact();
}
...
...
// (4)
private Key getKeyFromBase64EncodedKey(String base64EncodedSecretKey) {
byte[] keyBytes = Decoders.BASE64.decode(base64EncodedSecretKey); // (4-1)
Key key = Keys.hmacShaKeyFor(keyBytes); // (4-2)
return key;
}
}
[코드 4-56] JWT 생성을 위한 JwtTokenizer 클래스
코드 4-56은 JWT 생성을 위한 JwtTokenizer 클래스의 코드 일부입니다.
코드의 설명은 다음과 같습니다.
- (1)의 encodeBase64SecretKey() 메서드는 Plain Text 형태인 Secret Key의 byte[]를 Base64 형식의 문자열로 인코딩해줍니다.
- jjwt가 버전업 되면서 Plain Text 자체를 Secret Key로 사용하는 것은 암호학(cryptographic)적인 작업에 사용되는 Key가 항상 바이너리(byte array)라는 사실과 맞지 않는 것을 감안하여 Plain Text 자체를 Secret Key로 사용하는 것을 권장하지 않고 있습니다.
- (2)의 generateAccessToken()은 인증된 사용자에게 JWT를 최초로 발급해 주기 위한 JWT 생성 메서드입니다.
- (2-1)에서는 Base64 형식 Secret Key 문자열을 이용해 Key(java.security.Key) 객체를 얻습니다.
- (2-2)의 setClaims()에는 JWT에 포함시킬 Custom Claims를 추가합니다. Custom Claims에는 주로 인증된 사용자와 관련된 정보를 추가합니다.
- (2-3)의 setSubject()에는 JWT에 대한 제목을 추가합니다.
- (2-4)의 setIssuedAt()에는 JWT 발행 일자를 설정하는데 파라미터 타입은 java.util.Date 타입입니다.
- (2-5)의 setExpiration() 에는 JWT의 만료일시를 지정합니다. 파라미터 타입은 역시 java.util.Date 타입입니다.
- (2-6)의 signWith()에 서명을 위한 Key(java.security.Key) 객체를 설정합니다.
- (2-7)의 compact()를 통해 JWT를 생성하고 직렬화합니다.
- (3)의 generateRefreshToken() 메서드는 Access Token이 만료되었을 경우, Access Token을 새로 생성할 수 있게 해주는 Refresh Token을 생성하는 메서드입니다.
- Refresh Token의 경우 Access Token을 새로 발급해 주는 역할을 하는 Token이기 때문에 별도의 Custom Claims는 추가할 필요가 없습니다.
- (4)의 getKeyFromBase64EncodedKey() 메서드는 JWT의 서명에 사용할 Secret Key를 생성해 줍니다.
- (4-1)의 Decoders.BASE64.decode() 메서드는 Base64 형식으로 인코딩 된 Secret Key를 디코딩한 후, byte array를 반환합니다.
- (4-2)의 Keys.hmacShaKeyFor() 메서드는 key byte array를 기반으로 적절한 HMAC 알고리즘을 적용한 Key(java.security.Key) 객체를 생성합니다.
- 💡 jjwt 0.9.x 버전에서는 서명 과정에서 HMAC 알고리즘을 직접 지정해야 했지만 최신 버전에서는 내부적으로 적절한 HMAC 알고리즘을 지정해 준다는 사실을 기억하기 바랍니다.
JWT를 생성하는 기능은 모두 구현했습니다.
이제 JwtTokenizer가 JWT를 정상적으로 잘 생성해 주는지 테스트해 보도록 합시다.
✅ JWT 생성 기능 테스트
package com.springboot.auth;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import java.util.*;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.notNullValue;
@TestInstance(TestInstance.Lifecycle.PER_CLASS) // 테스트 학습이 핵심이 아니니 구글에서 검색해서 학습하세요.
public class JwtTokenizerTest {
private static JwtTokenizer jwtTokenizer;
private String secretKey;
private String base64EncodedSecretKey;
// (1)
@BeforeAll
public void init() {
jwtTokenizer = new JwtTokenizer();
secretKey = "kevin1234123412341234123412341234"; // encoded "a2V2aW4xMjM0MTIzNDEyMzQxMjM0MTIzNDEyMzQxMjM0"
base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(secretKey);
}
// (2)
@Test
public void encodeBase64SecretKeyTest() {
System.out.println(base64EncodedSecretKey);
assertThat(secretKey, is(new String(Decoders.BASE64.decode(base64EncodedSecretKey))));
}
// (3)
@Test
public void generateAccessTokenTest() {
Map<String, Object> claims = new HashMap<>();
claims.put("memberId", 1);
claims.put("roles", List.of("USER"));
String subject = "test access token";
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.MINUTE, 10);
Date expiration = calendar.getTime();
String accessToken = jwtTokenizer.generateAccessToken(claims, subject, expiration, base64EncodedSecretKey);
System.out.println(accessToken);
assertThat(accessToken, notNullValue());
}
// (4)
@Test
public void generateRefreshTokenTest() {
String subject = "test refresh token";
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.HOUR, 24);
Date expiration = calendar.getTime();
String refreshToken = jwtTokenizer.generateRefreshToken(subject, expiration, base64EncodedSecretKey);
System.out.println(refreshToken);
assertThat(refreshToken, notNullValue());
}
}
[코드 4-57] JWT 생성 기능을 테스트하기 위한 테스트 케이스
코드 4-57은 JwtTokenizer 클래스가 JWT를 정상적으로 잘 생성하는지를 테스트하기 위한 테스트 케이스입니다.
우리가 단위 테스트를 학습하는 시간은 아니기 때문에 테스트 케이스 코드 자체를 구체적으로 설명하기보다는 테스트 시나리오만 간단하게 설명하도록 하겠습니다.
- 먼저 (1)에서 테스트에 사용할 Secret Key를 Base64 형식으로 인코딩한 후, 인코딩 된 Secret Key를 각 테스트 케이스에서 사용합니다.
- (2)에서는 Plain Text인 Secret Key가 Base64 형식으로 인코딩이 정상적으로 수행이 되는지 테스트하고 있습니다.
- Base64 형식으로 인코딩 된 Secret Key를 디코딩한 값이 원본 Plain Text Secret Key가 일치하는지를 테스트하면 됩니다.
- (3)에서는 JwtTokenizer가 Access Token을 정상적으로 생성하는지 테스트합니다.생성 과정에서 Exception이 발생하지 않았기 때문에 정상적으로 생성이 되었다고 봐도 무방하며, 더 정확한 테스트는 JWT의 서명 검증에서 확인할 수 있습니다.
- JWT는 생성할 때마다 그 값이 바뀌기 때문에 우선 생성된 Access Token이 null이 아닌지 여부만 테스트하고 있습니다.
- (4)에서는 JwtTokenizer가 Refresh Token을 정상적으로 생성하는지 테스트합니다. Custom Claims가 필요하지 않다는 것 외에는 Access Token과 테스트 과정은 동일합니다.
코드 4-57의 테스트 케이스가 모두 통과된다면 이제 JWT를 검증하는 기능만 구현하면 됩니다.
JWT의 검증 기능은 인증된 사용자가 애플리케이션의 리소스에 접근할 때마다 request의 header에 포함된 JWT를 검증할 때 사용된다는 사실을 기억하기 바랍니다.
JWT 검증
✅ JWT 검증 기능 구현
package com.springboot.auth;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.io.Encoders;
import io.jsonwebtoken.security.Keys;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Calendar;
import java.util.Date;
import java.util.Map;
public class JwtTokenizer {
...
...
public void verifySignature(String jws, String base64EncodedSecretKey) {
Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
Jwts.parserBuilder()
.setSigningKey(key) // (1)
.build()
.parseClaimsJws(jws); // (2)
}
...
...
}
[코드 4-58] JWT 검증을 위한 verifySignature() 메서드 추가
코드 4-58에서는 JwtTokenizer 클래스에 JWT 검증을 위한 verifySignature() 메서드가 추가되었습니다.
코드의 설명은 다음과 같습니다.
JWT는 JWT에 포함된 Signature를 검증함으로써 JWT의 위/변조 여부를 확인할 수 있습니다.
jjwt에서는 JWT를 생성할 때 서명에 사용된 Secret Key를 이용해 내부적으로 Signature를 검증한 후, 검증에 성공하면 JWT를 파싱 해서 Claims를 얻을 수 있습니다.
- (1)의 setSigningKey() 메서드로 서명에 사용된 Secret Key를 설정합니다.
- (2)의 parseClaimsJws() 메서드로 JWT를 파싱해서 Claims를 얻습니다.💡파라미터로 사용한 jws는 Signature가 포함된 JWT라는 의미이니 참고 바랍니다.
- verifySignature() 메서드는 Signature를 검증하는 용도이므로 Claims를 리턴할 필요는 없습니다.
이제 JWT 검증 기능이 잘 동작하는지 테스트해 보도록 하겠습니다.
✅ JWT 검증 기능 테스트
package com.springboot.auth;
import io.jsonwebtoken.ExpiredJwtException;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import java.util.*;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class JwtTokenizerTest {
private static JwtTokenizer jwtTokenizer;
private String secretKey;
private String base64EncodedSecretKey;
...
...
// (1)
@DisplayName("does not throw any Exception when jws verify")
@Test
public void verifySignatureTest() {
String accessToken = getAccessToken(Calendar.MINUTE, 10);
assertDoesNotThrow(() -> jwtTokenizer.verifySignature(accessToken, base64EncodedSecretKey));
}
// (2)
@DisplayName("throw ExpiredJwtException when jws verify")
@Test
public void verifyExpirationTest() throws InterruptedException {
String accessToken = getAccessToken(Calendar.SECOND, 1);
assertDoesNotThrow(() -> jwtTokenizer.verifySignature(accessToken, base64EncodedSecretKey));
TimeUnit.MILLISECONDS.sleep(1500);
assertThrows(ExpiredJwtException.class, () -> jwtTokenizer.verifySignature(accessToken, base64EncodedSecretKey));
}
...
...
private String getAccessToken(int timeUnit, int timeAmount) {
Map<String, Object> claims = new HashMap<>();
claims.put("memberId", 1);
claims.put("roles", List.of("USER"));
String subject = "test access token";
Calendar calendar = Calendar.getInstance();
calendar.add(timeUnit, timeAmount);
Date expiration = calendar.getTime();
String accessToken = jwtTokenizer.generateAccessToken(claims, subject, expiration, base64EncodedSecretKey);
return accessToken;
}
}
[코드 4-59] JWT를 검증하기 위한 테스트 케이스
코드 4-59에서는 두 가지 측면에서 JWT에 대한 검증 테스트를 수행합니다.
- (1)에서는 우리가 구현한 JwtTokenizer의 verifySignature() 메서드가 Signature를 잘 검증하는지 테스트합니다.
- 테스트 자체는 간단합니다. 생성된 JWT를 verifySignature()로 전달해서 Exception이 발생하지 않는다면 Signature에 대한 검증이 잘 수행된 것으로 볼 수 있습니다.
- (2)에서는 JWT 생성 시 지정한 만료일시가 지나면 JWT가 정말 만료되는지를 테스트합니다.
- 생성되는 JWT의 만료 주기를 아주 짧게 준 후에 첫 번째 Signature 검증을 수행하고, 만료일시가 지나도록 지연시간을 준 뒤, 두 번째 Signature 검증을 수행했을 경우 ExpiredJwtException이 발생하면 JWT가 정상적으로 만료된다고 볼 수 있습니다.
우리가 이번 챕터에서 구현한 JwtTokenizer는 이어지는 챕터에서 JWT 자격 증명 방식을 사용한 인증에 사용되기 때문에 잘 알아두길 바라면서 이번 챕터는 마무리하도록 하겠습니다.
핵심 포인트
- Plain Text 자체를 Secret Key로 사용하는 것은 권장되지 않는다.
- jjwt 최신 버전(0.11.5)에서는 서명 과정에서 HMAC 알고리즘을 직접 지정하지 않고, 내부적으로 적절한 HMAC 알고리즘을 지정해 준다.
심화 학습
- jjwt에 대해서 더 알아보고 싶다면 아래 링크를 참고하세요.
- Java JWT에 대해서 알아보고 싶다면 아래 링크를 참고하세요.
Chapter - Spring Security에서의 JWT 인증
이전 유닛을 통해 여러분들은 JWT의 개념적인 부분을 살펴보았으며 또한 프로그래밍 코드를 통해 JWT를 직접 생성하고, 생성된 토큰을 검증해 보았습니다.
그렇다면 이제 우리가 할 일은 JWT를 이용한 자격 증명 방식을 실제 애플리케이션에 적용해 보는 것입니다.
우리가 앞에서 만들어 본 커피 주문 샘플 애플리케이션에는 애플리케이션의 마지막 퍼즐 조각인 보안이 빠져 있습니다.
이번 유닛에서 우리가 만든 커피 주문 샘플 애플리케이션에 JWT를 적용함으로써 애플리케이션의 마지막 퍼즐 조각을 맞춰보도록 하겠습니다.
JWT를 애플리케이션에 적용하기 위해서는 JWT 생성 및 갱신 기능, 클라이언트 측에서 전송되는 JWT가 유효한지 검증하는 기능 등 JWT 자체의 기능이 필요하며, 이 기능을 Spring Security의 Filter Chain에서 사용할 수 있도록 연계하는 것이 핵심입니다.
이번 시간을 통해 Spring Security에서 JWT를 적용해 보안을 강화하는 방법을 차근차근 익혀 나가길 바랍니다.
[Spring Security] JWT 적용을 위한 사전 준비 사항
이번 유닛의 학습을 원활하게 진행하기 위해 Spring Security 그리고 JWT와 관련된 의존 라이브러리 이외에 구성 요소(Controller, Service, Repository 등)들이 포함된 템플릿 프로젝트를 사용하도록 하겠습니다.
데이터 액세스 계층은 Spring Data JPA의 리포지토리로 구성이 되어 있습니다.
- 템플릿 프로젝트 복제
- 아래 github 링크에서 실습용 repository를 clone합니다.
- IntelliJ IDE로 clone 받은 local repository 디렉토리의 프로젝트를 Open합니다.
- 학습을 진행하며 학습 내용에 따라 예제 코드를 타이핑해 봅니다.
[Spring Security] JWT 적용을 위한 참고용 레퍼런스 코드
이번 유닛에서 학습한 예제 코드는 아래 github에서 확인할 수 있습니다.
챕터에서 사용한 예제 코드는 챕터에 있는 코드들을 직접 타이핑해 본 후, 학습 내용을 조금 더 구체적으로 이해하기 위한 용도로만 활용해 주세요.
- JWT 적용에 사용한 예제 코드
학습 목표
- Spring Security 기반의 애플리케이션에 필요한 JWT 구성 요소를 이해할 수 있다.
- Spring Security 기반의 애플리케이션에 JWT를 적용할 수 있다.
- Spring Security 기반의 애플리케이션에서의 JWT 동작 흐름을 이해할 수 있다.
JWT 적용을 위한 사전 작업
이번 시간에는 우리가 앞에서 만들어 본 커피 주문 샘플 애플리케이션에 JWT를 적용해 보면서 우리가 만든 애플리케이션의 보안을 강화해 보도록 하겠습니다.
JWT를 본격적으로 애플리케이션에 적용하기 전에 먼저 JWT를 적용하기 위한 사전 준비 작업부터 해보도록 하겠습니다.
의존 라이브러리 추가
여러분들이 이번 유닛에서 제공되는 템플릿 프로젝트에 JWT를 적용하기 위해서는 아래의 코드 4-60과 같이 Spring Security 스타터와 jjwt 라이브러리를 추가해야 합니다.
build.gradle
...
...
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
annotationProcessor 'org.projectlombok:lombok'
implementation 'org.mapstruct:mapstruct:1.5.2.Final'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.2.Final'
implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'com.google.code.gson:gson'
implementation 'org.springframework.boot:spring-boot-starter-security' // (1)
// (2) JWT 기능을 위한 jjwt 라이브러리
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
}
...
...
[코드 4-60] Spring Security와 JWT 적용을 위한 의존 라이브러리 추가
- (1)과 같이 우리가 만든 애플리케이션에 Spring Security를 적용하기 위해 spring-boot-starter-security를 추가합니다.
- (2)와 같이 Spring Security 기반의 애플리케이션에 JWT를 적용하기 위해 jjwt 라이브러리를 추가합니다.
애플리케이션 실행
애플리케이션을 실행한 후, http://localhost:8080로 접속하면 [그림 4-19]와 같이 Spring Security에서 제공하는 로그인 화면이 보여야 합니다.
[그림 4-19] Spring Security에서 제공하는 로그인 화면
로그인 화면에서 아래의 Username과 Password를 입력합니다.
- Username - user
- Password - intelliJ 로그에 출력되는 Using generated security password: d07746bc-ca8c-4f3b-a8ff-e31b648e0034 부분을 복사/붙여 넣기 합니다.
- 💡 intelliJ 로그에 출력되는 Password는 애플리케이션을 실행할 때마다 바뀌니 참고하세요.
- 학습 콘텐츠에 있는 Password를 복사하면 안 됩니다.
정상적으로 로그인에 성공했다면 아래의 [그림 4-20]과 같은 에러 화면이 보일 것입니다.
[그림 4-20] 로그인 인증에 성공한 후의 에러 화면
[그림 4-20]의 화면은 우리가 로그인 인증에는 성공했지만 기본적으로 Controller 같은 엔드포인트가 없어서 발생하는 404 에러이지 Spring Security에 문제가 있어서 발생한 에러가 아닙니다.
💡 즉, Spring Security 기반의 기본적인 프로젝트 설정은 정상적으로 완료가 되었다는 의미입니다.
SecurityConfiguration 추가
다음으로 애플리케이션에 JWT를 적용해 보기 전에 Spring Security를 이용한 보안 강화를 위해 최소한의 보안 구성을 먼저 하겠습니다.
package com.springboot.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import static org.springframework.security.config.Customizer.withDefaults;
@Configuration
public class SecurityConfiguration {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.headers().frameOptions().sameOrigin() // (1)
.and()
.csrf().disable() // (2)
.cors(withDefaults()) // (3)
.formLogin().disable() // (4)
.httpBasic().disable() // (5)
.authorizeHttpRequests(authorize -> authorize
.anyRequest().permitAll() // (6)
);
return http.build();
}
// (7)
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
// (8)
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("*")); // (8-1)
configuration.setAllowedMethods(Arrays.asList("GET","POST", "PATCH", "DELETE")); // (8-2)
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); // (8-3)
source.registerCorsConfiguration("/**", configuration); // (8-4) 주의 사항: 콘텐츠 표시 오류로 인해 '/**'를 '\\/**'로 표기했으니 실제 코드 구현 시에는 '\\(역슬래시)'를 빼 주세요.
return source;
}
}
[코드 4-61] SecurityConfiguration(V1) 설정
코드 4-61은 Spring Security를 통해 보안을 강화하기 위한 초기 Security Configuration(V1)입니다.
코드의 설명은 다음과 같습니다.
- H2 웹 콘솔의 화면 자체가 내부적으로 <frame> 태그를 사용하고 있기 때문에 개발 환경에서는 H2 웹 콘솔을 정상적으로 사용할 수 있도록 (1)과 같이 .frameOptions().sameOrigin() 을 추가합니다.
- .frameOptions().sameOrigin() 을 호출하면 동일 출처로부터 들어오는 request만 페이지 렌더링을 허용합니다.
- (2)와 같이 CSRF(Cross-Site Request Forgery) 공격에 대한 Spring Security에 대한 설정을 비활성화합니다.만약, csrf().disable() 설정하지 않는다면 403 에러로 인해 정상적인 접속이 불가능합니다.
- 우리는 로컬 환경에서 Spring Security에 대한 학습을 진행하므로, CSRF 공격에 대한 설정이 필요하지 않습니다.
- (3)에서는 CORS 설정을 추가합니다. .cors(withDefaults()) 일 경우, corsConfigurationSource라는 이름으로 등록된 Bean을 이용합니다.
- CORS를 처리하는 가장 쉬운 방법은 CorsFilter를 사용하는 것인데 CorsConfigurationSource Bean을 제공함으로써 CorsFilter를 적용할 수 있습니다.
💡 CORS(Cross-Origin Resource Sharing) 애플리케이션 간에 출처(Origin)가 다를 경우 스크립트 기반의 HTTP 통신(XMLHttpRequest, Fetch API)을 통한 리소스 접근이 제한되는데, CORS는 출처가 다른 스크립트 기반 HTTP 통신을 하더라도 선택적으로 리소스에 접근할 수 있는 권한을 부여하도록 브라우저에 알려주는 정책입니다.
로컬 환경에서 Postman을 사용하여 애플리케이션의 엔드포인트를 호출할 경우에는 CORS 설정이 필요 없지만 여러분들이 프로젝트를 수행할 때 프론트엔드 웹앱과의 HTTP 통신에서 에러를 만나게 될 수 있으므로 사전 학습 차원에서 설정했음을 참고하기 바랍니다.
CORS에 대해서 더 알아보고 싶다면 [심화 학습]을 참고하세요.
- 여러분들이 Hello Spring Security 챕터에서 사용한 로그인 인증 방식이 SSR(Server Side Rendering) 애플리케이션에서 주로 사용하는 폼 로그인 방식입니다.
- 이번 챕터에서는 CSR(Client Side Rendering) 방식에서 주로 사용하는 JSON 포맷으로 Username과 Password를 전송하는 방식을 사용할 것이므로 (4)와 같이 폼 로그인 방식을 비활성화합니다.
- HTTP Basic 인증은 request를 전송할 때마다 Username/Password 정보를 HTTP Header에 실어서 인증을 하는 방식으로 우리 코스에서는 사용하지 않으므로 (5)와 같이 HTTP Basic 인증 방식을 비활성화합니다.
💡 폼 로그인과 HTTP Basic 인증을 disable하면 해당 인증과 관련된 Security Filter(UsernamePasswordAuthenticationFilter, BasicAuthenticationFilter 등)가 비활성화된다는 사실을 참고하기 바랍니다.
- (6)에서는 JWT를 적용하기 전이므로 우선은 모든 HTTP request 요청에 대해서 접근을 허용하도록 설정했습니다.
💡 JWT 적용 후, URL 별로 적절한 권한을 적용할 예정입니다. 우선은 JWT 적용을 위한 학습에 집중해 주세요.
- (7)에서 PasswordEncoder Bean 객체를 생성합니다.
- (8)에서는 CorsConfigurationSource Bean 생성을 통해 구체적인 CORS 정책을 설정합니다.
- (8-1)에서 setAllowedOrigins()을 통해 모든 출처(Origin)에 대해 스크립트 기반의 HTTP 통신을 허용하도록 설정합니다. 이 설정은 운영 서버 환경에서 요구사항에 맞게 변경이 가능합니다.
- (8-2)에서는 setAllowedMethods()를 통해 파라미터로 지정한 HTTP Method에 대한 HTTP 통신을 허용합니다.
- (8-3)에서는 CorsConfigurationSource 인터페이스의 구현 클래스인 UrlBasedCorsConfigurationSource 클래스의 객체를 생성합니다.
- (8-4)에서는 모든 URL에 앞에서 구성한 CORS 정책(CorsConfiguration)을 적용합니다.
회원 가입 로직 수정
Spring Security가 적용되지 않았던 여러분들의 커피 주문 샘플 애플리케이션에는 회원 등록 시, 회원의 인증과 관련된 정보(패스워드, 사용자 권한)가 필요 없었지만 이번 챕터에는 필요합니다.
따라서 회원 가입 로직에 인증과 관련된 약간의 코드가 추가됩니다.
1️⃣ MemberDto.Post 클래스에 패스워드 필드 추가
public class MemberDto {
@Getter
@AllArgsConstructor
public static class Post {
@NotBlank
@Email
private String email;
// (1) 패스워드 필드 추가
@NotBlank
private String password;
@NotBlank(message = "이름은 공백이 아니어야 합니다.")
private String name;
@Pattern(regexp = "^010-\\\\d{3,4}-\\\\d{4}$",
message = "휴대폰 번호는 010으로 시작하는 11자리 숫자와 '-'로 구성되어야 합니다.")
private String phone;
}
...
...
}
[코드 4-62] 패스워드 필드가 추가된 MemberDto.Post
코드 4-62에서는 회원 등록 시, 회원의 패스워드 정보를 전달받기 위해 MemberDto.Post 클래스에 (1)과 같이 password 필드가 추가되었습니다.
💡 실제 서비스에서는 사용자가 회원 가입 시, 패스워드를 한 번만 입력하는 것이 아니라 사용자가 입력한 패스워드가 맞는지 재확인하기 위해 패스워드 입력 확인 필드가 추가로 존재하는 경우가 대부분이고, 입력한 두 패스워드가 일치하는지를 검증하는 로직이 필요합니다.
또한 패스워드의 생성 규칙(대/소문자, 패스워드 길이, 특수 문자 포함 여부 등)에 대한 유효성 검증도 실시한다는 사실을 기억하기 바랍니다.
우리는 JWT 적용이 이번 챕터의 핵심이므로 사용자의 패스워드는 password 필드 하나를 사용하겠습니다.
2️⃣ Member 엔티티 클래스에 패스워드 필드 추가
@NoArgsConstructor
@Getter
@Setter
@Entity
public class Member extends Auditable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long memberId;
@Column(nullable = false, updatable = false, unique = true)
private String email;
// (1) 추가
@Column(length = 100, nullable = false)
private String password;
...
...
// (2) 추가
@ElementCollection(fetch = FetchType.EAGER)
private List<String> roles = new ArrayList<>();
...
...
}
[코드 4-63] Member 엔티티 클래스에 패스워드 필드 추가
- 코드 4-63의 (1)과 같이 Member 엔티티 클래스에도 패스워드 필드를 추가합니다.
- password는 암호화되어 저장되기 때문에 열의 길이는 100으로 지정했습니다.
💡 패스워드 입력 규칙에 따라서 password 길이는 달라질 수 있음을 참고하세요.
- (2)에서는 @ElementCollection 애너테이션을 이용해 사용자 등록 시, 사용자의 권한을 등록하기 위한 권한 테이블을 생성합니다.
사용자의 권한 테이블 생성 관련해서는 Hello, Spring Security로 알아보는 Spring Security의 기본 구조 (2)를 참고하세요.
3️⃣ 사용자 등록 시, 패스워드와 사용자 권한 저장
import com.springboot.auth.utils.CustomAuthorityUtils;
...
...
@Transactional
@Service
public class MemberService {
private final MemberRepository memberRepository;
private final ApplicationEventPublisher publisher;
// (1) 추가
private final PasswordEncoder passwordEncoder;
private final CustomAuthorityUtils authorityUtils;
// (2) 생성자 DI용 파라미터 추가
public MemberService(MemberRepository memberRepository,
ApplicationEventPublisher publisher,
PasswordEncoder passwordEncoder,
CustomAuthorityUtils authorityUtils) {
this.memberRepository = memberRepository;
this.publisher = publisher;
this.passwordEncoder = passwordEncoder;
this.authorityUtils = authorityUtils;
}
public Member createMember(Member member) {
verifyExistsEmail(member.getEmail());
// (3) 추가: Password 암호화
String encryptedPassword = passwordEncoder.encode(member.getPassword());
member.setPassword(encryptedPassword);
// (4) 추가: DB에 User Role 저장
List<String> roles = authorityUtils.createRoles(member.getEmail());
member.setRoles(roles);
Member savedMember = memberRepository.save(member);
publisher.publishEvent(new MemberRegistrationApplicationEvent(this, savedMember));
return savedMember;
}
...
...
}
[코드 4-64] 사용자 등록 시, 패스워드와 권한 정보 추가
코드 4-64에서는 사용자 등록 시, 패스워드와 권한 정보가 함께 DB에 저장되도록 관련 코드를 추가합니다.
- (1)과 (2)에서는 PasswordEncoder와 CustomAuthorityUtils 클래스를 DI 받도록 필드를 추가합니다. (CustomAuthorityUtils는 Hello, Spring Security로 알아보는 Spring Security의 기본 구조 (2)에서 사용하는 HelloAuthorityUtils과 유사하므로 해당 콘텐츠를 참고할 수 있으며, 좀 더 정확한 코드를 확인하고 싶다면 레퍼런스 코드의 CustomAuthorityUtils 코드를 참고하세요)
- (3)에서는 패스워드를 단방향 암호화합니다.
- (4)에서는 등록하는 사용자의 권한 정보를 생성합니다.
여러분이 눈치챘을 수도 있겠지만 MemberService 클래스에 추가된 코드는 Hello, Spring Security로 알아보는 Spring Security의 기본 구조 (2)에서 사용한 코드와 거의 동일합니다.
여러분은 DB에 저장되는 사용자 패스워드의 암호화와 사용자의 권한 정보 생성에 관해서 이미 학습했다는 사실을 기억하기를 바라며, 기억나지 않는다면 Hello, Spring Security로 알아보는 Spring Security의 기본 구조 (2)를 참고 바랍니다.
JWT 적용을 위한 사전 작업은 끝났습니다.
이제 이어지는 챕터에서 JWT를 커피 주문 샘플 애플리케이션에 적용해 봅시다.
핵심 포인트
- Spring Security 기반의 애플리케이션에 JWT를 적용하기 위해서는 jjwt나 Java JWT 같은 별도의 라이브러리가 필요하다.
심화 학습
- CORS(Cross-Origin Resource Sharing)에 대해서 더 알아보고 싶다면 아래 링크를 참고하세요.
JWT 자격 증명을 위한 로그인 인증 구현
이제 JWT 자격 증명을 위해 로그인 인증 기능부터 먼저 구현해 봅시다.
이번 챕터의 핵심은 사용자의 Username(이메일 주소)과 Password로 로그인 인증에 성공하면 로그인 인증에 성공한 사용자에게 JWT를 생성 및 발급하는 것입니다.
JWT 자격 증명을 위한 로그인 인증 기능을 구현하기 전에 먼저 로그인 인증 흐름을 간단하게 확인해 봅시다.
사용자의 로그인 인증 성공 후, JWT가 클라이언트에게 전달되는 과정은 다음과 같습니다.
- 클라이언트가 서버 측에 로그인 인증 요청(Username/Password를 서버 측에 전송)
- 로그인 인증을 담당하는 Security Filter(JwtAuthenticationFilter)가 클라이언트의 로그인 인증 정보 수신
- Security Filter가 수신한 로그인 인증 정보를 AuthenticationManager에게 전달해 인증 처리를 위임
- AuthenticationManager가 Custom UserDetailsService(MemberDetailsService)에게 사용자의 UserDetails 조회를 위임
- Custom UserDetailsService(MemberDetailsService)가 사용자의 크리덴셜을 DB에서 조회한 후, AuthenticationManager에게 사용자의 UserDetails를 전달
- AuthenticationManager가 로그인 인증 정보와 UserDetails의 정보를 비교해 인증 처리
- JWT 생성 후, 클라이언트의 응답으로 전달
1번부터 7번 과정 중에서 우리는 JwtAuthenticationFilter 구현(2번 ~ 3번, 7번), MemberDetailsService(5번)을 구현합니다.
4번과 6번은 Spring Security의 AuthenticationManager가 대신 처리해 주므로 신경 쓸 필요가 없습니다.
JWT 자격 증명을 위한 로그인 인증 구현
1️⃣ Custom UserDetailsService 구현
Spring Security에서 사용자의 로그인 인증을 처리하는 가장 단순하고 효과적인 방법은 데이터베이스에서 사용자의 크리덴셜을 조회한 후, 조회한 크리덴셜을 AuthenticationManager에게 전달하는 Custom UserDetailsService를 구현하는 것입니다.
MemberDetailsService
package com.springboot.auth.userdetails;
import com.springboot.auth.utils.CustomAuthorityUtils;
import com.springboot.exception.BusinessLogicException;
import com.springboot.exception.ExceptionCode;
import com.springboot.member.entity.Member;
import com.springboot.member.repository.MemberRepository;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component;
import java.util.Collection;
import java.util.Optional;
@Component
public class MemberDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
private final CustomAuthorityUtils authorityUtils;
public MemberDetailsService(MemberRepository memberRepository, CustomAuthorityUtils authorityUtils) {
this.memberRepository = memberRepository;
this.authorityUtils = authorityUtils;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Optional<Member> optionalMember = memberRepository.findByEmail(username);
Member findMember = optionalMember.orElseThrow(() -> new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND));
return new MemberDetails(findMember);
}
private final class MemberDetails extends Member implements UserDetails {
// (1)
MemberDetails(Member member) {
setMemberId(member.getMemberId());
setEmail(member.getEmail());
setPassword(member.getPassword());
setRoles(member.getRoles());
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorityUtils.createAuthorities(this.getRoles());
}
@Override
public String getUsername() {
return getEmail();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
}
[코드 4-65] UserDetailsService를 구현한 MemberDetailsService
코드 4-65는 UserDetailsService를 구현한 MemberDetailsService 클래스입니다.
MemberDetailsService 클래스는 여러분들이 앞에서 학습했던 ‘Hello, Spring Security로 알아보는 Spring Security의 기본 구조 (2)에서 학습했던 HelloUserDetailsService(코드 4-28)와 거의 동일한 코드이므로 MemberDetailsService의 코드에 대한 이해는 ‘Hello, Spring Security로 알아보는 Spring Security의 기본 구조 (2)의 HelloUserDetailsService(코드 4-28)를 확인하기 바랍니다.
2️⃣ 로그인 인증 정보 역직렬화(Deserialization)를 위한 LoginDTO 클래스 생성
LoginDTO 클래스는 클라이언트가 전송한 Username/Password 정보를 Security Filter에서 사용할 수 있도록 역직렬화(Deserialization) 하기 위한 DTO 클래스입니다.
LoginDto
@Getter
public class LoginDto {
private String username;
private String password;
}
[코드 4-66] 클라이언트의 인증 정보를 수신할 LoginDto
LoginDto 클래스는 클라이언트의 Username과 Passoword 정보만 담는 단순한 DTO 클래스이므로 다른 설명은 생략하겠습니다.
3️⃣ JWT를 생성하는 JwtTokenizer 구현
JwtTokenizer 클래스는 로그인 인증에 성공한 클라이언트에게 JWT를 생성 및 발급하고 클라이언트의 요청이 들어올 때마다 전달된 JWT를 검증하는 역할을 합니다.
JwtTokenizer
package com.springboot.auth.jwt;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.io.Encoders;
import io.jsonwebtoken.security.Keys;
import lombok.Getter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Calendar;
import java.util.Date;
import java.util.Map;
// (1)
@Component
public class JwtTokenizer {
@Getter
@Value("${jwt.key}")
private String secretKey; // (2)
@Getter
@Value("${jwt.access-token-expiration-minutes}")
private int accessTokenExpirationMinutes; // (3)
@Getter
@Value("${jwt.refresh-token-expiration-minutes}")
private int refreshTokenExpirationMinutes; // (4)
public String encodeBase64SecretKey(String secretKey) {
return Encoders.BASE64.encode(secretKey.getBytes(StandardCharsets.UTF_8));
}
public String generateAccessToken(Map<String, Object> claims,
String subject,
Date expiration,
String base64EncodedSecretKey) {
Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
return Jwts.builder()
.setClaims(claims)
.setSubject(subject)
.setIssuedAt(Calendar.getInstance().getTime())
.setExpiration(expiration)
.signWith(key)
.compact();
}
public String generateRefreshToken(String subject, Date expiration, String base64EncodedSecretKey) {
Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
return Jwts.builder()
.setSubject(subject)
.setIssuedAt(Calendar.getInstance().getTime())
.setExpiration(expiration)
.signWith(key)
.compact();
}
public Jws<Claims> getClaims(String jws, String base64EncodedSecretKey) {
Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
Jws<Claims> claims = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(jws);
return claims;
}
public void verifySignature(String jws, String base64EncodedSecretKey) {
Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(jws);
}
// (5)
public Date getTokenExpiration(int expirationMinutes) {
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.MINUTE, expirationMinutes);
Date expiration = calendar.getTime();
return expiration;
}
private Key getKeyFromBase64EncodedKey(String base64EncodedSecretKey) {
byte[] keyBytes = Decoders.BASE64.decode(base64EncodedSecretKey);
Key key = Keys.hmacShaKeyFor(keyBytes);
return key;
}
}
[코드 4-77] JWT 생성 및 검증 역할을 하는 JwtTokenizer
코드 4-77은 JwtTokenizer 코드입니다.
여러분들이 JWT의 학습을 열심히 하고 있다면 코드 4-77의 코드가 여러분들에게 익숙한 느낌을 주는 코드라고 생각합니다.
코드 4-77은 여러분들이 Chapter - JWT(JSON Web Token) 개요 > JWT 생성 및 검증 테스트챕터에서 학습했던 JwtTokenizer 클래스의 코드를 그대로 가져왔으며, 단지 몇 가지의 설정만 추가되었습니다.
따라서 이번 챕터에서는 추가된 부분에 대해서만 설명합니다.
코드 4-77에 대한 설명은 다음과 같습니다.
- (1)에서는 JwtTokenizer 클래스를 Spring Container(ApplicationContext)에 Bean으로 등록하기 위해 @Component 애너테이션을 추가합니다.
- (2), (3), (4)는 JWT 생성 시 필요한 정보이며, 해당 정보는 application.yml 파일에서 로드합니다.
- (2)는 JWT 생성 및 검증 시 사용되는 Secret Key 정보입니다.
- (3)은 Access Token에 대한 만료 시간 정보입니다
- (4)는 Refresh Token에 대한 만료 시간 정보입니다.
- (5)의 getTokenExpiration() 메서드는 JWT의 만료 일시를 지정하기 위한 메서드로 JWT 생성 시 사용됩니다.
JwtTokenizer 코드에 대한 이해가 더 필요하다면 Chapter - JWT(JSON Web Token) 개요 > JWT 생성 및 검증 테스트챕터를 확인해 주세요.
application.yml
...
...
jwt:
key: ${JWT_SECRET_KEY} # 민감한 정보는 시스템 환경 변수에서 로드한다.
access-token-expiration-minutes: 30
refresh-token-expiration-minutes: 420
[코드 4-78] JWT 정보가 포함된 application.yml
코드 4-78은 JWT 생성 및 검증에 사용되는 정보가 추가된 application.yml 파일의 코드 일부입니다.
- JWT의 서명에 사용되는 Secret Key 정보는 민감한(sensitive) 정보이므로 시스템 환경 변수의 변수로 등록합니다.
- ${JWT_SECRET_KEY}는 단순한 문자열이 아니라 OS의 시스템 환경 변수의 값을 읽어오는 일종의 표현식입니다.
- 사용 중인 운영체제에 따라 아래 안내를 참고하여 환경변수를 설정합니다.
- access-token-expiration-minutes는 Access Token의 만료 시간이며, 30분으로 설정합니다.
- refresh-token-expiration-minutes는 Refresh Token의 만료 시간이며, 420분으로 설정합니다.
Window OS
Windows의 경우 아래의 그림과 같이 환경 변수를 설정할 수 있습니다.
⭐환경변수를 설정한 이후엔 사용중인 IntelliJ IDE를 반드시 Restart해야 합니다.⭐
- 자세한 방법은 [Java 기초]에서 환경변수 설정을 참고해 주세요.
Mac OS
Mac의 경우, 사용 중인 쉘에 따라 환경변수를 설정할 수 있습니다.
아래 이미지 및 안내는 zsh를 기준으로 안내하고 있습니다. bash 쉘을 사용하는 경우 zshrc 대신 bash_profile에 작성 바랍니다.
- echo $SHELL 명령어로 사용중인 쉘을 확인할 수 있습니다.
- 에디터를 이용해 환경변수 설정 파일을 수정합니다.
- nano ~/.zshrc (혹은 bash 쉘인 경우 nano ~/.bash_profile)
아래 그림과 같이 환경 변수 이름과 값을 작성합니다. export 환경변수_이름="환경변수_값"
sourece ~/.zshrc(혹은 bash 쉘인 경우 source ~/.bash_profile)명령어를 이용해 작성한 환경변수를 적용합니다.
⭐ 환경변수를 설정한 이후엔 사용중인 IntelliJ IDE를 반드시 Restart해야 합니다. ⭐
💡⭐ 시스템 환경 변수에 등록한 변수를 사용할 때는 applicatioin.yml 파일의 프로퍼티 명과 동일한 문자열을 사용하지 않도록 주의해야 합니다.
시스템 환경 변수와 application.yml에 정의한 프로퍼티 명의 문자열이 동일할 경우 application.yml 파일에 정의된 프로퍼티를 클래스의 필드에서 참조할 때(예: ${jwt.key.secret}) 시스템 환경 변수의 값으로 채워지므로 개발자가 의도하지 않은 값으로 채워질 수 있습니다.
따라서 가급적 시스템 환경 변수의 값도 application.yml에서 먼저 로드한 뒤에 application.yml에서 일관성 있게 프로퍼티 값을 읽어오는 것이 좋습니다.
시스템 환경 변수에 정의한 변수명과 application.yml에 정의한 프로퍼티명의 이름을 각각 다르게 추가하면 디버깅 시, application.yml에서 값을 로드하는지 시스템 환경 변수에서 값을 로드하는지 직관적으로 알 수 있습니다.
💡 Access Token과 Refresh Token의 만료 시간은 애플리케이션 서비스를 제작하면서 적절하게 설정하면 되는 값이기 때문에 딱히 적절한 시간이 정해져 있는 것은 아니므로 애플리케이션의 요구 사항에 맞게 적절한 값을 지정하면 됩니다.
다만 일반적으로 보안상 Access Token의 만료 시간이 Refresh Token의 만료 시간보다 짧은 것이 권장되며, 보안 강화를 이유로 Refresh Token을 제공하지 않는 애플리케이션도 있다는 사실을 기억하기 바랍니다.
4️⃣ 로그인 인증 요청을 처리하는 Custom Security Filter 구현
이제 클라이언트의 로그인 인증 정보를 직접적으로 수신하여 인증 처리의 엔트리포인트(Entrypoint) 역할을 하는 Custom Filter를 구현해 봅시다.
package com.springboot.auth.filter;
import com.springboot.auth.dto.LoginDto;
import com.springboot.auth.jwt.JwtTokenizer;
import com.springboot.member.entity.Member;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.SneakyThrows;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.*;
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter { // (1)
private final AuthenticationManager authenticationManager;
private final JwtTokenizer jwtTokenizer;
// (2)
public JwtAuthenticationFilter(AuthenticationManager authenticationManager, JwtTokenizer jwtTokenizer) {
this.authenticationManager = authenticationManager;
this.jwtTokenizer = jwtTokenizer;
}
// (3)
@SneakyThrows
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) {
ObjectMapper objectMapper = new ObjectMapper(); // (3-1)
LoginDto loginDto = objectMapper.readValue(request.getInputStream(), LoginDto.class); // (3-2)
// (3-3)
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginDto.getUsername(), loginDto.getPassword());
return authenticationManager.authenticate(authenticationToken); // (3-4)
}
// (4)
@Override
protected void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain,
Authentication authResult) {
Member member = (Member) authResult.getPrincipal(); // (4-1)
String accessToken = delegateAccessToken(member); // (4-2)
String refreshToken = delegateRefreshToken(member); // (4-3)
response.setHeader("Authorization", "Bearer " + accessToken); // (4-4)
response.setHeader("Refresh", refreshToken); // (4-5)
}
// (5)
private String delegateAccessToken(Member member) {
Map<String, Object> claims = new HashMap<>();
claims.put("username", member.getEmail());
claims.put("roles", member.getRoles());
String subject = member.getEmail();
Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getAccessTokenExpirationMinutes());
String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());
String accessToken = jwtTokenizer.generateAccessToken(claims, subject, expiration, base64EncodedSecretKey);
return accessToken;
}
// (6)
private String delegateRefreshToken(Member member) {
String subject = member.getEmail();
Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getRefreshTokenExpirationMinutes());
String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());
String refreshToken = jwtTokenizer.generateRefreshToken(subject, expiration, base64EncodedSecretKey);
return refreshToken;
}
}
[코드 4-79] 로그인 인증을 처리하는 JwtAuthenticationFilter
코드 4-79는 클라이언트의 로그인 인증 요청을 처리하는 JwtAuthenticationFilter의 코드입니다.
JwtAuthenticationFilter는 클라이언트의 로그인 인증 요청을 처리하는 엔트리포인트(Entrypoint)의 역할을 하며, 코드에 대한 설명은 다음과 같습니다.
- (1)에서는 UsernamePasswordAuthenticationFilter를 상속하고 있습니다. UsernamePasswordAuthenticationFilter는 폼 로그인 방식에서 사용하는 디폴트 Security Filter로써, 폼 로그인이 아니더라도 Username/Password 기반의 인증을 처리하기 위해 UsernamePasswordAuthenticationFilter를 확장해서 구현할 수 있습니다.
- (2)에서는 AuthenticationManager와 JwtTokenizer를 DI 받고 있습니다.
- DI 받은 AuthenticationManager는 로그인 인증 정보(Username/Password)를 전달받아 UserDetailsService와 인터랙션 한 뒤 인증 여부를 판단합니다.
- DI 받은 JwtTokenizer는 클라이언트가 인증에 성공할 경우, JWT를 생성 및 발급하는 역할을 합니다.
- (3)의 attemptAuthentication()는 메서드 이름에서도 알 수 있듯이 메서드 내부에서 인증을 시도하는 로직을 구현하면 됩니다.
- (3-1)에서는 클라이언트에서 전송한 Username과 Password를 DTO 클래스로 역직렬화(Deserialization) 하기 위해 ObjectMapper 인스턴스를 생성합니다.
- (3-2)에서는 objectMapper.readValue(request.getInputStream(), LoginDto.class)를 통해 ServletInputStream을 LoginDto 클래스의 객체로 역직렬화**(Deserialization)**합니다.
- (3-3)에서는 Username과 Password 정보를 포함한 UsernamePasswordAuthenticationToken을 생성합니다.
- (3-4)에서는 UsernamePasswordAuthenticationToken을 AuthenticationManager에게 전달하면서 인증 처리를 위임합니다.
- (4)의 successfulAuthentication() 메서드는 클라이언트의 인증 정보를 이용해 인증에 성공할 경우 호출됩니다.
- (4-1)에서 authResult.getPrincipal()로 Member 엔티티 클래스의 객체를 얻습니다.
- AuthenticationManager 내부에서 인증에 성공하면 인증된 Authentication 객체가 생성되면서 principal 필드에 Member 객체가 할당됩니다.
- (4-2)에서 delegateAccessToken(member) 메서드를 이용해 Access Token을 생성합니다.
- (4-3)에서 delegateRefreshToken(member) 메서드를 이용해 Refresh Token을 생성합니다.
- (4-4)에서 response header(Authorization)에 Access Token을 추가합니다. Access Token은 클라이언트 측에서 백엔드 애플리케이션 측에 요청을 보낼 때마다 request header에 추가해서 클라이언트 측의 자격을 증명하는 데 사용됩니다.
- (4-5)에서 response header(Refresh)에 Refresh Token을 추가합니다. Refresh Token은 Access Token이 만료될 경우, 클라이언트 측이 Access Token을 새로 발급받기 위해 클라이언트에게 추가적으로 제공될 수 있으며 Refresh Token을 Access Token과 함께 클라이언트에게 제공할지 여부는 애플리케이션의 요구 사항에 따라 달라질 수 있습니다.
- (5)와 (6)은 Access Token과 Refresh Token을 생성하는 구체적인 로직입니다.자세한 설명은 Chapter - JWT(JSON Web Token) 개요 > JWT 생성 및 검증 테스트챕터의 JwtTokenizerTest 클래스의 테스트 케이스를 확인하세요.
- delegateAccessToken(member) 메서드와 delegateRefreshToken(member) 메서드의 구체적인 로직은 여러분들이 이미 Chapter - JWT(JSON Web Token) 개요 > JWT 생성 및 검증 테스트챕터에서 확인한 로직입니다.
5️⃣ Custom Filter 추가를 위한 SecurityConfiguration 설정 추가
Custom Filter인 JwtAuthenticationFilter의 구현이 끝났다면 JwtAuthenticationFilter를 Spring Security Filter Chain에 추가해서 로그인 인증을 처리하도록 해야 합니다.
SecurityConfiguration(V2)
package com.springboot.config;
import com.springboot.auth.filter.JwtAuthenticationFilter;
import com.springboot.auth.jwt.JwtTokenizer;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
import static org.springframework.security.config.Customizer.withDefaults;
@Configuration
public class SecurityConfiguration {
private final JwtTokenizer jwtTokenizer;
public SecurityConfiguration(JwtTokenizer jwtTokenizer) {
this.jwtTokenizer = jwtTokenizer;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.headers().frameOptions().sameOrigin()
.and()
.csrf().disable()
.cors(withDefaults())
.formLogin().disable()
.httpBasic().disable()
.apply(new CustomFilterConfigurer()) // (1)
.and()
.authorizeHttpRequests(authorize -> authorize
.anyRequest().permitAll()
);
return http.build();
}
...
...
// (2)
public class CustomFilterConfigurer extends AbstractHttpConfigurer<CustomFilterConfigurer, HttpSecurity> { // (2-1)
@Override
public void configure(HttpSecurity builder) throws Exception { // (2-2)
AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class); // (2-3)
JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager, jwtTokenizer); // (2-4)
jwtAuthenticationFilter.setFilterProcessesUrl("/v11/auth/login"); // (2-5)
builder.addFilter(jwtAuthenticationFilter); // (2-6)
}
}
}
[코드 4-80] JwtAuthenticationFilter 추가를 위한 SecurityConfiguration 설정 추가
코드 4-80은 JwtAuthenticationFilter를 Spring Security Filter Chain에 추가하기 위한 코드가 포함된 SecurityConfiguration의 코드 일부입니다.
코드의 설명은 다음과 같습니다.
- Spring Security에서는 개발자가 직접 Custom Configurer를 구성해 Spring Security의 Configuration을 커스터마이징(customizations) 할 수 있습니다.💡 Custom Configurer는 쉽게 말해서 Spring Security의 Configuration을 개발자 입맛에 맞게 정의할 수 있는 기능이라고 생각하면 될 것 같습니다.
- (1)과 같이 apply() 메서드에 Custom Configurer를 추가해 커스터마이징(customizations)된 Configuration을 추가할 수 있습니다.
- (2)는 Custom Configurer인 CustomFilterConfigurer 클래스입니다. CustomFilterConfigurer는 우리가 구현한 JwtAuthenticationFilter를 등록하는 역할을 합니다.
- (2-1)과 같이 AbstractHttpConfigurer를 상속해서 Custom Configurer를 구현할 수 있습니다.
- (2-2)와 같이 configure() 메서드를 오버라이드해서 Configuration을 커스터마이징할 수 있습니다.
- (2-3)과 같이 getSharedObject(AuthenticationManager.class)를 통해 AuthenticationManager의 객체를 얻을 수 있습니다.
- getSharedObject()를 통해서 Spring Security의 설정을 구성하는 SecurityConfigurer 간에 공유되는 객체를 얻을 수 있습니다.
- (2-4)에서 JwtAuthenticationFilter를 생성하면서 JwtAuthenticationFilter에서 사용되는 AuthenticationManager와 JwtTokenizer를 DI 해줍니다.
- (2-5)에서는 setFilterProcessesUrl() 메서드를 통해 디폴트 request URL인 “/login”을 “/v11/auth/login”으로 변경합니다.
- (2-6)에서 addFilter() 메서드를 통해 JwtAuthenticationFilter를 Spring Security Filter Chain에 추가합니다.
이제 우리가 구현한 JwtAuthenticationFilter를 SecurityConfiguration에 추가했으니 로그인 인증 시, JWT 토큰이 response로 잘 전달되는지 테스트를 통해 확인해 봅시다.
로그인 인증 테스트
애플리케이션 실행 후, 아래의 단계로 테스트를 진행해 보겠습니다.
1️⃣ 회원 가입 요청
먼저 [그림 4-21]과 같이 Postman에서 회원 가입 request를 전송해서 회원 가입을 합니다.
[그림 4-21] 회원 가입 request 전송
회원 가입에 대한 request는 여러분들이 Spring MVC 섹션에서 무수히 많이 전송했던 request이기 때문에 달리 설명할 내용이 없습니다.
만약 회원 가입 시 등록하는 이메일 주소가 로그인 인증 시에만 필요한 용도라면 로그인 인증 시 사용할 username 같은 별도의 필드를 사용할 수 있습니다.
다만 이 경우 회원의 실제 이름을 나타내는 필드 및 고객 발송 전용 이메일 주소가 혼동되지 않도록 적절한 필드명으로 구분해야 할 것입니다.
2️⃣ 로그인 인증 요청
이제 회원 가입 request 전송 시 입력한 이메일 주소와 패스워드로 로그인 인증 request를 전송해 보겠습니다.
[그림 4-22] JWT 자격 증명을 위한 로그인 인증
그림 4-22에서는 회원 가입 시, 등록한 이메일 주소와 패스워드로 로그인 인증을 위한 request를 전송하는 모습입니다.
💡 우리가 SecurityConfiguration에서 변경한 URL(/v11/auth/login)로 로그인 인증 request를 전송해야 한다는 사실을 잊지 말길 바랍니다.
애플리케이션에서 로그인 인증이 성공적으로 수행되면 [그림 4-22]의 아래쪽 Headers 탭에서 Authorization 키의 값으로 Access Token이, Refresh 키의 값으로 Refresh Token이 포함된 것을 알 수 있습니다.
클라이언트 쪽에서는 서버 측의 리소스를 사용하기 위한 request를 전송할 때마다 전달받은 JWT를 request header에 포함 후, 클라이언트의 자격 증명 정보로 사용하면 됩니다.
로그인 인증 성공 및 실패에 따른 추가 처리
여러분이 이 정도까지만 구현하더라도 JWT 자격 검증을 위한 로그인 인증 기능을 사용하는 데 문제는 없습니다.
하지만 조금 더 나은 애플리케이션 구현을 위해 한 가지 기능만 더 추가해 보도록 하겠습니다.
Spring Security에서는 Username/Password 기반의 로그인 인증에 성공했을 때, 로그를 기록한다거나 로그인에 성공한 사용자 정보를 response로 전송하는 등의 추가 처리를 할 수 있는 핸들러(AuthenticationSuccessHandler)를 지원하며, 로그인 인증 실패 시에도 마찬가지로 인증 실패에 대해 추가 처리를 할 수 있는 핸들러(AuthenticationFailureHandler)를 지원합니다.
1️⃣ AuthenticationSuccessHandler 구현
package com.springboot.auth.handler;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Slf4j
public class MemberAuthenticationSuccessHandler implements AuthenticationSuccessHandler { // (1)
// (2)
@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException {
// 인증 성공 후, 로그를 기록하거나 사용자 정보를 response로 전송하는 등의 추가 작업을 할 수 있다.
log.info("# Authenticated successfully!");
}
}
[코드 4-81] 로그인 인증 성공 시 추가 작업을 할 수 있는 MemberAuthenticationSuccessHandler
코드 4-81은 로그인 인증 성공 시 추가 작업을 할 수 있는 MemberAuthenticationSuccessHandler 코드입니다.
코드의 설명은 다음과 같습니다.
- 우리가 직접 정의하는 Custom AuthenticationSuccessHandler는 (1)과 같이 AuthenticationSuccessHandler 인터페이스를 구현해야 합니다.
- AuthenticationSuccessHandler 인터페이스에는 onAuthenticationSuccess() 추상 메서드가 정의되어 있으며, onAuthenticationSuccess() 메서드를 구현해서 추가 처리를 하면 됩니다.
- (2)에서는 단순히 로그만 출력하고 있지만 💡 Authentication 객체에 사용자 정보를 얻은 후, HttpServletResponse로 출력 스트림을 생성하여 response를 전송할 수 있다는 사실을 기억하기 바랍니다.
2️⃣ AuthenticationFailureHandler 구현
package com.springboot.auth.handler;
import com.springboot.response.ErrorResponse;
import com.google.gson.Gson;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Slf4j
public class MemberAuthenticationFailureHandler implements AuthenticationFailureHandler { // (1)
@Override
public void onAuthenticationFailure(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException exception) throws IOException {
// 인증 실패 시, 에러 로그를 기록하거나 error response를 전송할 수 있다.
log.error("# Authentication failed: {}", exception.getMessage());
sendErrorResponse(response); // (2)
}
private void sendErrorResponse(HttpServletResponse response) throws IOException {
Gson gson = new Gson(); // (2-1)
ErrorResponse errorResponse = ErrorResponse.of(HttpStatus.UNAUTHORIZED); // (2-2)
response.setContentType(MediaType.APPLICATION_JSON_VALUE); // (2-3)
response.setStatus(HttpStatus.UNAUTHORIZED.value()); // (2-4)
response.getWriter().write(gson.toJson(errorResponse, ErrorResponse.class)); // (2-5)
}
}
[코드 4-82] 로그인 인증 실패 시, 추가 작업을 할 수 있는 MemberAuthenticationFailureHandler
코드 4-82는 로그인 인증 실패 시 추가 작업을 할 수 있는 MemberAuthenticationFailureHandler 코드입니다.
코드의 설명은 다음과 같습니다.
- 우리가 직접 정의하는 Custom AuthenticationFailureHandler는 (1)과 같이 AuthenticationFailureHandler 인터페이스를 구현해야 합니다.
- AuthenticationSuccessHandler 인터페이스에는 onAuthenticationFailure() 추상 메서드가 정의되어 있으며, onAuthenticationFailure() 메서드를 구현해서 추가 처리를 하면 됩니다.
- (2)에서는 바로 아래에 있는 sendErrorResponse() 메서드를 호출해 출력 스트림에 Error 정보를 담고 있습니다.
- (2-1)에서는 Error 정보가 담긴 객체(ErrorResponse)를 JSON 문자열로 변환하는 데 사용되는 Gson 라이브러리의 인스턴스를 생성합니다.
- (2-2)에서는 ErrorResponse 객체를 생성합니다. ErrorResponse.of() 메서드로 HttpStatus.UNAUTHORIZED 상태 코드를 전달합니다.ErrorResponse 클래스는 여러분들이 Spring MVC 섹션의 예외 처리 유닛에서 이미 사용해 본 조금은 익숙한 클래스입니다. ^^
- ErrorResponse 클래스가 기억나지 않는다면 [Spring MVC] 예외 처리 유닛을 참고하기 바랍니다.
- 💡 HttpStatus.UNAUTHORIZED(401) 상태 코드는 인증에 실패할 경우 전달할 수 있는 HTTP status라는 것을 기억하기 바랍니다.
- (2-3)에서는 response의 Content Type이 “application/json”이라는 것을 클라이언트에게 알려줄 수 있도록 MediaType.APPLICATION_JSON_VALUE를 HTTP Header에 추가합니다.
- (2-4)에서는 response의 status가 401 임을 클라이언트에게 알려줄 수 있도록 HttpStatus.UNAUTHORIZED.value()을 HTTP Header에 추가합니다.
- (2-5)에서는 Gson을 이용해 ErrorResponse 객체를 JSON 포맷 문자열로 변환 후, 출력 스트림을 생성합니다.
3️⃣ AuthenticationSuccessHandler와 AuthenticationFailureHandler 추가
이제 AuthenticationSuccessHandler 인터페이스와 AuthenticationFailureHandler 인터페이스의 구현 클래스를 JwtAuthenticationFilter에 등록하면 로그인 인증 시, 두 핸들러를 사용할 수 있습니다.
SecurityConfiguration(V3)
package com.springboot.config;
import com.springboot.auth.filter.JwtAuthenticationFilter;
import com.springboot.auth.jwt.JwtTokenizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import com.springboot.auth.handler.MemberAuthenticationFailureHandler;
import com.springboot.auth.handler.MemberAuthenticationSuccessHandler;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
import static org.springframework.security.config.Customizer.withDefaults;
/**
* JwtAuthenticationFilter 추가
*/
@Configuration
public class SecurityConfiguration {
private final JwtTokenizer jwtTokenizer;
public SecurityConfiguration(JwtTokenizer jwtTokenizer) {
this.jwtTokenizer = jwtTokenizer;
}
...
...
public class CustomFilterConfigurer extends AbstractHttpConfigurer<CustomFilterConfigurer, HttpSecurity> {
@Override
public void configure(HttpSecurity builder) throws Exception {
AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class);
JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager, jwtTokenizer);
jwtAuthenticationFilter.setFilterProcessesUrl("/v11/auth/login");
jwtAuthenticationFilter.setAuthenticationSuccessHandler(new MemberAuthenticationSuccessHandler()); // (3) 추가
jwtAuthenticationFilter.setAuthenticationFailureHandler(new MemberAuthenticationFailureHandler()); // (4) 추가
builder.addFilter(jwtAuthenticationFilter);
}
}
}
[코드 4-83] JwtAuthenticationFilter에 AuthenticationSuccessHandler와 AuthenticationFailureHandler 등록
AuthenticationSuccessHandler와 AuthenticationFailureHandler를 JwtAuthenticationFilter에 등록하는 방법은 어렵지 않습니다.
(3), (4)와 같이 JwtAuthenticationFilter에 등록해주기만 하면 됩니다.
"Spring에서는 객체를 생성할 때 new 키워드 사용을 자제하라고 했는데 new를 사용했네?"라고 의아해하는 분이 있을지도 모르겠습니다.
AuthenticationSuccessHandler와 AuthenticationFailureHandler 인터페이스의 구현 클래스가 다른 Security Filter에서 사용이 된다면 ApplicationContext에 Bean으로 등록해서 DI 받는 게 맞습니다.
💡 하지만 일반적으로 인증을 위한 Security Filter마다 AuthenticationSuccessHandler와 AuthenticationFailureHandler의 구현 클래스를 각각 생성할 것이므로 new 키워드를 사용해서 객체를 생성해도 무방합니다.
4️⃣ AuthenticationSuccessHandler 호출
AuthenticationSuccessHandler와 AuthenticationFailureHandler도 구현했고, jwtAuthenticationFilter에서 AuthenticationSuccessHandler와 AuthenticationFailureHandler를 사용할 수 있도록 SecurityConfiguration에 추가도 했습니다.
이제 우리가 jwtAuthenticationFilter에서 해당 핸들러의 구현 메서드를 호출해서 사용하기만 하면 됩니다.
JwtAuthenticationFilter(AuthenticationSuccessHandler 호출 코드 추가)
package com.springboot.auth.filter;
import com.springboot.auth.dto.LoginDto;
import com.springboot.auth.jwt.JwtTokenizer;
import com.springboot.member.entity.Member;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.SneakyThrows;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
...
...
@Override
protected void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain,
Authentication authResult) throws ServletException, IOException {
Member member = (Member) authResult.getPrincipal();
String accessToken = delegateAccessToken(member);
String refreshToken = delegateRefreshToken(member);
response.setHeader("Authorization", "Bearer " + accessToken);
response.setHeader("Refresh", refreshToken);
this.getSuccessHandler().onAuthenticationSuccess(request, response, authResult); // (1) 추가
}
...
...
}
코드 4-84에서는 로그인 인증에 성공하고, JWT를 생성해서 response header에 추가한 뒤, (1)과 같이 AuthenticationSuccessHandler의 onAuthenticationSuccess() 메서드를 호출하고 있습니다.
이렇게 onAuthenticationSuccess() 메서드를 호출하면 앞에서 우리가 구현한 MemberAuthenticationSuccessHandler의 onAuthenticationSuccess() 메서드가 호출됩니다.
AuthenticationFailureHandler는 별도의 코드를 추가하지 않아도 로그인 인증에 실패하면 우리가 구현한 MemberAuthenticationFailureHandler의 onAuthenticationFailure() 메서드가 알아서 호출됩니다.
현재 코드상에서는 로그인 인증에 성공한 후, JwtAuthenticationFilter의 successfulAuthentication() 메서드에서 JWT를 생성하고 있지만 AuthenticationSuccessHandler에서 JWT를 생성하는 것 역시 나쁘지 않은 선택입니다.
클래스의 역할 분담을 어디까지 하느냐는 여러분들의 생각에 달려있습니다.
이제 애플리케이션을 다시 실행한 뒤 회원 가입에 전송했던 이메일 주소와 패스워드로 로그인 인증을 위한 request(localhost:8080/v11/auth/login)를 전송해 보세요.
AuthenticationSuccessHandler가 정상 동작한다면 아래와 같은 로그가 출력됩니다.
2022-09-20 16:33:15.273 INFO 10948 --- [nio-8080-exec-4] c.a.h.MemberAuthenticationSuccessHandler : # Authenticated successfully!
이번에는 로그인 인증 패스워드를 다르게 해서 로그인 인증 request를 전송해 보세요.
AuthenticationFailureHandler가 정상 동작한다면 아래의 [그림 4-23]과 같은 response body를 확인할 수 있습니다.
[그림 4-23] AuthenticationFailureHandler가 전송한 error response
[그림 4-23]을 보면 회원 가입 시 등록한 비밀번호가 아닌 다른 비밀번호로 로그인 인증을 시도하면 AuthenticationFailureHandler에서 전송한 ErrorResponse가 전송되는 것을 확인할 수 있습니다.
JWT 자격 증명을 위한 로그인 인증 기능에 대한 학습을 진행하느라 수고 많았습니다.
이제 마지막 단계만 남았습니다.
로그인 인증 후, response로 전달받은 JWT를 HTTP request header에 포함하여 request를 전송할 때마다 서버 측에서 request header에 포함된 JWT를 검증하는 기능만 구현하면 JWT를 이용한 인증 및 자격 검증 기능이 완성됩니다.
이어지는 챕터에서 바로 확인해 보겠습니다.
이번 챕터에서는 로그인 인증 기능을 구현하기 위해 Spring Security에서 지원하는 UsernamePasswordAuthenticationFilter를 사용했습니다.
그런데 로그인 인증 기능은 반드시 UsernamePasswordAuthenticationFilter만 이용해서 구현해야 한다고 정해져 있는 건 없습니다.
예를 들어 OncePerRequestFilter 같은 Filter를 이용해서 구현할 수도 있으며, Controller에서 API 엔드포인트로 구현하는 방법도 많이 사용하는 방법입니다.
어떤 방법이 더 좋다고 정해진 건 없으며, 애플리케이션 서비스의 요구 사항에 적절한 방법을 선택해서 구현할 수 있음을 기억하기 바랍니다.
핵심 포인트
- UsernamePasswordAuthenticationFilter를 이용해서 JWT 발급 전의 로그인 인증 기능을 구현할 수 있다.
- Spring Security에서는 개발자가 직접 Custom Configurer를 구성해 Spring Security의 Configuration을 커스터마이징(customizations) 할 수 있다.
- Username/Password 기반의 로그인 인증은 OncePerRequestFilter 같은 Spring Security에서 지원하는 다른 Filter를 이용해서 구현할 수 있으며, Controller에서 REST API 엔드포인트로 구현하는 것도 가능하다.
- Spring Security에서는 Username/Password 기반의 로그인 인증에 성공했을 때, 로그를 기록하거나 로그인에 성공한 사용자 정보를 response로 전송하는 등의 추가 처리를 할 수 있는 AuthenticationSuccessHandler를 지원하며, 로그인 인증 실패 시에도 마찬가지로 인증 실패에 대해 추가 처리를 할 수 있는 AuthenticationFailureHandler를 지원한다.
심화 학습
- OncePerRequestFilter에 대해서 더 알아보고 싶다면 아래 링크를 참고하세요.
- AuthenticationSuccessHandler에 대해서 더 알아보고 싶다면 아래 링크를 참고하세요.
- AuthenticationFailureHandler에 대해서 더 알아보고 싶다면 아래 링크를 참고하세요.
JWT를 이용한 자격 증명 및 검증 구현
이전 챕터에서 회원 가입 시 등록한 이메일 주소와 패스워드를 이용해 로그인 인증을 성공적으로 수행하면 response header(Authorization, Refresh)를 통해 JWT를 전달받을 수 있었습니다.
이번 시간에는 클라이언트 측에서 JWT를 이용해 자격 증명이 필요한 리소스에 대한 request 전송 시, request header를 통해 전달받은 JWT를 서버 측에서 검증하는 기능을 구현해 보겠습니다.
JWT 검증 기능 구현
1️⃣ JWT 검증 필터 구현
JWT 검증을 위해 가장 먼저 해야 할 작업은 JWT를 검증하는 전용 Security Filter를 구현하는 것입니다.
JwtVerificationFilter
package com.springboot.auth.filter;
import com.springboot.auth.jwt.JwtTokenizer;
import com.springboot.auth.utils.CustomAuthorityUtils;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;
import java.util.Map;
public class JwtVerificationFilter extends OncePerRequestFilter { // (1)
private final JwtTokenizer jwtTokenizer;
private final CustomAuthorityUtils authorityUtils;
// (2)
public JwtVerificationFilter(JwtTokenizer jwtTokenizer,
CustomAuthorityUtils authorityUtils) {
this.jwtTokenizer = jwtTokenizer;
this.authorityUtils = authorityUtils;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
Map<String, Object> claims = verifyJws(request); // (3)
setAuthenticationToContext(claims); // (4)
filterChain.doFilter(request, response); // (5)
}
// (6)
@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
String authorization = request.getHeader("Authorization"); // (6-1)
return authorization == null || !authorization.startsWith("Bearer"); // (6-2)
}
private Map<String, Object> verifyJws(HttpServletRequest request) {
String jws = request.getHeader("Authorization").replace("Bearer ", ""); // (3-1)
String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey()); // (3-2)
Map<String, Object> claims = jwtTokenizer.getClaims(jws, base64EncodedSecretKey).getBody(); // (3-3)
return claims;
}
private void setAuthenticationToContext(Map<String, Object> claims) {
String username = (String) claims.get("username"); // (4-1)
List<GrantedAuthority> authorities = authorityUtils.createAuthorities((List)claims.get("roles")); // (4-2)
Authentication authentication = new UsernamePasswordAuthenticationToken(username, null, authorities); // (4-3)
SecurityContextHolder.getContext().setAuthentication(authentication); // (4-4)
}
}
[코드 4-84] JWT 검증을 위한 JwtVerificationFilter
코드 4-84는 클라이언트 측에서 전송된 request header에 포함된 JWT에 대해 검증 작업을 수행하는 JwtVerificationFilter의 코드입니다.
코드의 설명은 다음과 같습니다.
- Spring Security에서는 (1)과 같이 OncePerRequestFilter를 확장해서 request 당 한 번만 실행되는 Security Filter를 구현할 수 있습니다.💡 인증과 관련된 Filter는 성공이냐 실패냐를 단 한 번만 판단하면 됩니다. 성공도 아니고 실패도 아닌 어중간한 결과는 존재하지 않으며 여러 번 판단할 필요도 없는 것입니다.
- JWT의 검증은 request 당 단 한 번만 수행하면 되기 때문에 JWT 전용 Filter로 만들기에는 OncePerRequestFilter를 이용하는 것이 적절하다고 볼 수 있습니다.
- (2)와 같이 JwtTokenizer와 CustomAuthorityUtils를 DI 받습니다.
- JwtTokenizer는 JWT를 검증하고 **Claims(토큰에 포함된 정보)**를 얻는 데 사용됩니다.
- CustomAuthorityUtils는 JWT 검증에 성공하면 Authentication 객체에 채울 사용자의 권한을 생성하는 데 사용됩니다.
- (3)의 verifyJws() 메서드는 JWT를 검증하는 데 사용되는 private 메서드입니다.
- (3-1)에서는 request의 header에서 JWT를 얻고 있습니다.💡 이전 챕터에서 Authorization header에 추가된 JWT는 response header에 포함된 것이고, (3-1)에서의 JWT는 클라이언트가 response header로 전달받은 JWT를 request header에 추가해서 서버 측에 전송한 것이라는 사실을 잊지 마세요!💡 (3-1)에서 변수명을 jws로 지정한 이유는 서명된 JWT를 JWS(JSON Web Token Signed)라고 부르기 때문이라는 점 참고하세요.
- 그리고 String 클래스의 replace() 메서드를 이용해 “Bearer “부분을 제거합니다.
- 이전 챕터에서 클라이언트의 로그인 인증이 성공적으로 수행되면, 서버 측에서 Authorization header에 JWT를 추가했던 로직을 떠올려 보기 바랍니다.
- (3-2)에서는 JWT 서명(Signature)을 검증하기 위한 Secret Key를 얻습니다.
- (3-3)에서는 JWT에서 Claims를 파싱 합니다.⭐ 즉, verify() 같은 검증 메서드가 따로 존재하는 것이 아니라 Claims가 정상적으로 파싱이 되면 서명 검증 역시 자연스럽게 성공했다는 사실을 꼭 기억하기 바랍니다.
- 여기서 기억해야 할 부분은 JWT에서 Claims를 파싱 할 수 있다는 의미는 내부적으로 서명(Signature) 검증에 성공했다는 의미입니다.
- (4)의 setAuthenticationToContext() 메서드는 Authentication 객체를 SecurityContext에 저장하기 위한 private 메서드입니다.
- (4-1)에서는 JWT에서 파싱 한 Claims에서 username을 얻습니다.
- (4-2)에서는 JWT의 Claims에서 얻은 권한 정보를 기반으로 List<GrantedAuthority를 생성합니다.
- (4-3)에서는 username과 List<GrantedAuthority를 포함한 Authentication 객체를 생성합니다.
- (4-4)에서는 SecurityContext에 Authentication 객체를 저장합니다.
⭐ JWT는 클라이언트 정보 등의 상태를 저장하지 않는 Stateless한 방식인데 SecurityContext에 Authentication을 저장하게 되면 세션의 상태는 어떻게 되는지 궁금해할 수도 있습니다.
SecurityContext에 Authentication을 저장하게 되면 Spring Security의 세션 정책(Session Policy)에 따라서 세션을 생성할 수도 있고, 그렇지 않을 수도 있습니다.
JWT 환경에서는 세션 정책(Session Policy) 설정을 통해 세션 자체를 생성하지 않도록 설정합니다.
세션 정책(Session Policy)에 대해서는 이어서 바로 확인해 보겠습니다.
- 문제없이 JWT의 서명 검증에 성공하고, Security Context에 Authentication을 저장한 뒤에는 (5)와 같이 다음(Next) Security Filter를 호출합니다.
- (6)은 OncePerRequestFilter의 shouldNotFilter()를 오버라이드 한 것으로, 특정 조건에 부합하면(true이면) 해당 Filter의 동작을 수행하지 않고 다음 Filter로 건너뛰도록 해줍니다.
- (6-1)에서 Authorization header의 값을 얻은 후에
- (6-2)에서는 Authorization header의 값이 null이거나 Authorization header의 값이 “Bearer”로 시작하지 않는다면 해당 Filter의 동작을 수행하지 않도록 정의합니다.
- ⭐ 만일 JWT 자격 증명이 필요한 리소스 요청인데 실수로 JWT를 포함하지 않았다 하더라도 이 경우에는 Authentication이 정상적으로 SecurityContext에 저장되지 않은 상태이기 때문에 다른 Security Filter를 거쳐 결국 Exception을 던지게 될 것입니다.
2️⃣ SecurityConfiguration 설정 업데이트
JwtVerificationFilter를 사용하기 위해서는 아래와 같은 두 가지 설정을 SecurityConfigruation에 추가해야 합니다.
- 세션 정책 설정 추가
- JwtVerificationFilter 추가
JwtVerificationFilter에서 JWT의 자격 검증에 성공하게 되면 인증된 Authentication 객체를 SecurityContext에 저장합니다. (코드 4-84의 (4)를 참고해 주세요)
그런데 앞에서 언급한 것처럼 stateless한 애플리케이션을 유지하기 위해 세션 유지 시간을 아주 짧게 가져가기 위한(거의 무상태) 설정을 SecurityConfigruation에 추가할 필요가 있습니다.
그리고 추가적으로 JwtVerificationFilter를 Security Filter Chain에 추가해 보겠습니다.
SecurityConfiguration(V4)
package com.springboot.config;
import com.springboot.auth.filter.JwtAuthenticationFilter;
import com.springboot.auth.filter.JwtVerificationFilter;
import com.springboot.auth.handler.MemberAuthenticationFailureHandler;
import com.springboot.auth.handler.MemberAuthenticationSuccessHandler;
import com.springboot.auth.jwt.JwtTokenizer;
import com.springboot.auth.utils.CustomAuthorityUtils;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
import static org.springframework.security.config.Customizer.withDefaults;
/**
* SessionCreationPolicy 설정 추가
*/
@Configuration
public class SecurityConfiguration {
private final JwtTokenizer jwtTokenizer;
private final CustomAuthorityUtils authorityUtils; // 추가
public SecurityConfigurationV6(JwtTokenizer jwtTokenizer,
CustomAuthorityUtils authorityUtils) {
this.jwtTokenizer = jwtTokenizer;
this.authorityUtils = authorityUtils;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.headers().frameOptions().sameOrigin()
.and()
.csrf().disable()
.cors(withDefaults())
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // (1) 추가
.and()
.formLogin().disable()
.httpBasic().disable()
.apply(new CustomFilterConfigurer())
.and()
.authorizeHttpRequests(authorize -> authorize
.anyRequest().permitAll()
);
return http.build();
}
...
...
public class CustomFilterConfigurer extends AbstractHttpConfigurer<CustomFilterConfigurer, HttpSecurity> {
@Override
public void configure(HttpSecurity builder) throws Exception {
AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class);
JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager, jwtTokenizer);
jwtAuthenticationFilter.setFilterProcessesUrl("/v11/auth/login");
jwtAuthenticationFilter.setAuthenticationSuccessHandler(new MemberAuthenticationSuccessHandler());
jwtAuthenticationFilter.setAuthenticationFailureHandler(new MemberAuthenticationFailureHandler());
JwtVerificationFilter jwtVerificationFilter = new JwtVerificationFilter(jwtTokenizer, authorityUtils); // (2) 추가
builder
.addFilter(jwtAuthenticationFilter)
.addFilterAfter(jwtVerificationFilter, JwtAuthenticationFilter.class); // (3)추가
}
}
}
[코드 4-85] JWT 검증을 위한 설정 추가(V4)
코드 4-85는 JWT 검증을 위한 추가 설정이 포함된 SecurityConfiguration의 코드 일부입니다.
코드의 설명은 다음과 같습니다.
- (1)의 .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)를 통해서 세션을 생성하지 않도록 설정합니다.
- SessionCreationPolicy.*ALWAYS*
- 항상 세션을 생성합니다.
- SessionCreationPolicy.NEVER
- 세션을 생성하지 않지만 만약에 이미 생성된 세션이 있다면 사용합니다.
- SessionCreationPolicy.*IF_REQUIRED*
- 필요한 경우에만 세션을 생성합니다.
- SessionCreationPolicy.*STATELESS*
- 세션을 생성하지 않으며, SecurityContext 정보를 얻기 위해 결코 세션을 사용하지 않습니다.
- SessionCreationPolicy.*ALWAYS*
- SessionCreationPolicy의 설정값으로는 아래와 같이 총 네 개의 값을 사용할 수 있습니다.
- (2)에서는 JwtVerificationFilter의 인스턴스를 생성하면서 JwtVerificationFilter에서 사용되는 객체들을 생성자로 DI 해줍니다.
- (3)에서는 JwtVerificationFilter를 JwtAuthenticationFilter 뒤에 추가합니다.
- JwtVerificationFilter는 JwtAuthenticationFilter에서 로그인 인증에 성공한 후 발급받은 JWT가 클라이언트의 request header(Authorization 헤더)에 포함되어 있을 경우에만 동작합니다.
서버 측 리소스에 역할(Role) 기반 권한 적용
로그인 인증 후, JWT도 무사히 잘 발급되고 발급된 JWT를 이용해서 클라이언트의 자격 증명에 대한 검증까지 잘 되는 걸 확인했습니다.
그런데 Spring Security 쪽에서 서버 측 리소스에 적절한 접근 권한 설정을 하지 않는다면 아무리 JWT를 사용하여 클라이언트의 자격 증명이 확인된다 하더라도 그 의미가 퇴색됩니다.
JWT를 이용한 자격 증명이라는 의미에는 특정 리소스에 접근할 수 있는 적절한 권한을 가졌는지를 판단해야 한다는 의미도 포함하고 있으니까요.
현재까지의 SecurityConfiguration에는 아래의 코드 4-86과 같이 사용자의 권한 여부에 상관없이 클라이언트 측에서 애플리케이션의 모든 리소스에 접근할 수 있도록 설정된 상태입니다.
접근 권한 설정이 되어 있지 않은 SecurityConfiguration(V4)
package com.springboot.config;
import com.springboot.auth.filter.JwtAuthenticationFilter;
import com.springboot.auth.handler.MemberAuthenticationFailureHandler;
import com.springboot.auth.handler.MemberAuthenticationSuccessHandler;
import com.springboot.auth.jwt.JwtTokenizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
import static org.springframework.security.config.Customizer.withDefaults;
@Configuration
public class SecurityConfiguration {
...
...
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.headers().frameOptions().sameOrigin()
.and()
.csrf().disable()
.cors(withDefaults())
.formLogin().disable()
.httpBasic().disable()
.apply(new CustomFilterConfigurer())
.and()
.authorizeHttpRequests(authorize -> authorize
.anyRequest().permitAll() // (1)
);
return http.build();
}
...
...
}
[코드 4-86] 접근 권한 여부와 상관없이 모든 리소스에 대한 접근이 허용된 상태
코드 4-86을 보면 (1)과 같이 .anyRequest().permitAll()를 통해 서버 측으로 들어오는 모든 request에 대해서 접근을 허용하고 있습니다.
이제 우리가 여태까지 구현해 본 커피 주문 샘플 애플리케이션의 리소스 중에서 MemberController를 통해 접근할 수 있는 리소스에 대한 접근 권한을 부여해 보도록 하겠습니다.
수정된 SecurityConfiguration(V5)
package com.springboot.config;
import com.springboot.auth.filter.JwtAuthenticationFilter;
import com.springboot.auth.filter.JwtVerificationFilter;
import com.springboot.auth.handler.MemberAccessDeniedHandler;
import com.springboot.auth.handler.MemberAuthenticationEntryPoint;
import com.springboot.auth.handler.MemberAuthenticationFailureHandler;
import com.springboot.auth.handler.MemberAuthenticationSuccessHandler;
import com.springboot.auth.jwt.JwtTokenizer;
import com.springboot.auth.utils.CustomAuthorityUtils;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
import static org.springframework.security.config.Customizer.withDefaults;
@Configuration
public class SecurityConfiguration {
...
...
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.headers().frameOptions().sameOrigin()
.and()
.csrf().disable()
.cors(withDefaults())
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.formLogin().disable()
.httpBasic().disable()
.apply(new CustomFilterConfigurer())
.and()
.authorizeHttpRequests(authorize -> authorize
.antMatchers(HttpMethod.POST, "/*/members").permitAll() // (1) 추가
.antMatchers(HttpMethod.PATCH, "/*/members/**").hasRole("USER") // (2) 추가
.antMatchers(HttpMethod.GET, "/*/members").hasRole("ADMIN") // (3) 추가
.antMatchers(HttpMethod.GET, "/*/members/**").hasAnyRole("USER", "ADMIN") // (4) 추가
.antMatchers(HttpMethod.DELETE, "/*/members/**").hasRole("USER") // (5) 추가
.anyRequest().permitAll()
);
return http.build();
}
...
...
}
[코드 4-87] MemberController에 대한 역할(Role) 기반의 접근 권한을 부여
코드 4-87은 MemberController를 통해 접근할 수 있는 리소스에 대해 접근 권한을 부여한 SecurityConfiguration 코드의 일부입니다.
코드에 대한 설명은 다음과 같습니다.
- 회원 등록의 경우, 접근 권한 여부와 상관없이 누구나 접근이 가능해야 하므로 (1)과 같이 회원등록에 사용되는 URL(”/v11/members”)과 HTTP Method(여기서는 POST)에 해당된다면 접근을 허용합니다.
- 여러분들이 MemberController의 postMember() 핸들러 메서드의 URL과 HTTP Method를 확인해 본다면 (1)에서 설정한 조건이 이해가 되리라 생각합니다.
- 회원 정보 수정의 경우, (2)와 같이 일반 사용자(USER) 권한만 가진 사용자만 접근이 가능하도록 허용합니다..antMatchers(HttpMethod.PATCH, "/*/members/**")에서 ‘**’는 하위 URL로 어떤 URL이 오더라도 매치가 된다는 의미입니다.
- 회원 정보 수정 요청을 처리하는 MemberController의 patchMember() 핸들러 메서드에 대한 접근 권한 부여 설정이라는 사실을 기억하세요.
- 모든 회원 정보의 목록은 (3)과 같이 관리자(ADMIN) 권한을 가진 사용자만 접근이 가능하여야 할 것입니다.
- 회원 정보 목록 조회 요청을 처리하는 MemberController의 getMembers() 핸들러 메서드에 대한 접근 권한 부여 설정에 해당합니다.
- 특정 회원에 대한 정보 조회는 (4)와 같이 일반 사용자(USER)와 관리자(ADMIN) 권한을 가진 사용자 모두 접근이 가능하면 될 것 같군요.
- 특정 회원 정보 조회 요청을 처리하는 MemberController의 getMember() 핸들러 메서드에 대한 접근 권한 부여 설정에 해당합니다.
- 특정 회원을 삭제하는 요청은 (5)와 같이 해당 사용자가 탈퇴 같은 처리를 할 수 있어야 하므로 일반 사용자(USER) 권한만 가진 사용자만 접근이 가능하도록 허용합니다.
- 특정 회원 정보 삭제 요청을 처리하는 MemberController의 deleteMember() 핸들러 메서드에 대한 접근 권한 부여 설정에 해당합니다.
✅ 셀프 미니 실습
코드 4-87에서는 MemberController를 통해 접근할 수 있는 리소스에 대해 접근 권한만 부여된 채로 나머지 리소스에 대한 요청은 모드 접근이 가능한 상태입니다.
그런데 우리가 지금껏 구현한 커피 주문 샘플 애플리케이션에는 다른 리소스가 더 있다는 사실을 여러분들도 알고 있을 거라 생각합니다.
CoffeeController와 OrderController를 통해 접근할 수 있는 리소스에 대한 접근 권한은 여러분들이 직접 한번 추가해 볼 수 있도록 셀프 미니 실습으로 남겨 두도록 하겠습니다.
JWT 검증 테스트
이제 JWT를 사용한 자격 검증이 정상적으로 잘 이루어지는지 간단하게 테스트해 보도록 하겠습니다.
애플리케이션 실행 후, 아래와 같은 조건으로 테스트를 수행해 보겠습니다.
1️⃣ JWT를 Authorization header에 포함하지 않을 경우
[그림 4-24] JWT를 Authorization header에 포함하지 않은 경우
[그림 4-24]는 JWT를 Authorization header에 포함하지 않은 채 MemberController의 getMember() 핸들러 메서드에 request를 전송한 모습입니다.
response 결과를 보면 Spring Security에서 403 status를 전달한 것을 볼 수 있습니다.
JWT가 Authorization header에 포함되지 않으면 JwtVerificationFilter를 건너뛰게 되고, 나머지 Security Filter에서 권한 체크를 하면서 적절한 권한이 부여되지 않았기 때문에 403 status가 전달됩니다.
2️⃣ 유효하지 않은 JWT를 Authorization header에 포함할 경우
[그림 4-25] 유효하지 않은 JWT를 Authorization header에 포함할 경우
이번에는 [그림 4-25]와 같이 유효하지 않은 JWT를 Authorization header에 포함하여 MemberController의 getMember() 핸들러 메서드에 request를 전송한 모습입니다.
역시 Spring Security에서 403 status를 전달한 것을 볼 수 있습니다.
유효하지 않은 JWT의 경우 접근 권한에 대한 에러를 나타내는 403 status보다는 JWT의 검증에 실패했기 때문에 자격 증명에 실패한 것과 같으므로 UNAUTHORIZED를 의미하는 401 status가 더 적절할 것 같습니다.
이 부분은 이어지는 예외 처리 부분에서 설명하겠습니다.
3️⃣ 권한이 부여되지 않은 리소스에 request를 전송할 경우
[그림 4-26] 권한이 부여되지 않은 리소스에 request를 전송할 경우
[그림 4-26]에서는 USER 권한만 부여된 사용자의 JWT를 Authorization header에 추가하고, ADMIN 권한에만 접근이 허용된 MemberController의 getMembers() 핸들러 메서드에 request를 전송할 때의 결과입니다.
JwtVerificationFilter에서 JWT의 자격 증명은 정상적으로 수행되었지만 ADMIN 권한이 없는 사용자이므로 403 status가 전달됩니다.
예외 처리
자, 이제 JWT를 사용하는 자격 검증에 대한 구현은 끝난 걸까요?
이 상태에서 마무리하더라도 보안적으로 크게 문제 될 건 없습니다.
하지만 조금 더 깔끔한 마무리를 위해서 예외 처리와 관련된 로직을 추가해 보도록 하겠습니다.
1️⃣ JwtVerificationFilter에 예외 처리 로직 추가
JwtVerificationFilter의 경우, 클라이언트로부터 전달받은 JWT의 Claims를 얻는 과정에서 내부적으로 JWT에 대한 서명(Signature)을 검증합니다.
그런데 현재 JwtVerificationFilter에서는 JWT에 대한 서명(Signature) 검증에 실패할 경우 throw되는 SignatureException에 대해서 어떤 처리도 하지 않고 있습니다.
그리고 JWT가 만료될 경우, 발생하는 ExpiredJwtException에 대한 처리도 이루어지지 않았습니다.
JWT 검증 과정에서 발생할 수 있는 Exception을 처리할 수 있는 예외 처리 로직을 JwtVerificationFilter에 추가해 보도록 하겠습니다.
package com.springboot.auth.filter;
import com.springboot.auth.jwt.JwtTokenizer;
import com.springboot.auth.utils.CustomAuthorityUtils;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.security.SignatureException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;
import java.util.Map;
public class JwtVerificationFilter extends OncePerRequestFilter {
...
...
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// (1)
try {
Map<String, Object> claims = verifyJws(request);
setAuthenticationToContext(claims);
} catch (SignatureException se) {
request.setAttribute("exception", se);
} catch (ExpiredJwtException ee) {
request.setAttribute("exception", ee);
} catch (Exception e) {
request.setAttribute("exception", e);
}
filterChain.doFilter(request, response);
}
...
...
}
[코드 4-88] 예외 처리 로직이 추가된 JwtVerificationFilter
코드 4-88에서는 JwtVerificationFilter의 doFilterInternal() 메서드에 예외 처리 로직이 추가되었습니다.
예외 처리 로직은 간단합니다.
(1)과 같이 try~catch 문으로 특정 예외 타입의 Exception이 catch 되면 해당 Exception을 request.setAttribute("exception", Exception 객체)와 같이 HttpServletRequest의 애트리뷰트(Attribute)로 추가됩니다.
이렇게 추가된 애트리뷰트는 바로 아래에서 설명하는 AuthenticationEntryPoint에서 사용할 수 있습니다.
💡JwtVerificationFilter 예외 처리의 키포인트는 우리가 일반적으로 알고 있는 예외 처리 방식과는 다르게 Exception을 catch한 후에 Exception을 다시 throw 한다든지 하는 처리를 하지 않고, 단순히 request.setAttribute()를 설정하는 일밖에 하지 않는다는 것입니다.
이런 식으로 예외 처리를 하게 되면 어떻게 될까요?
예외가 발생하게 되면 SecurityContext에 클라이언트의 인증 정보(Authentication 객체)가 저장되지 않습니다.
⭐ SecurityContext에 클라이언트의 인증 정보(Authentication 객체)가 저장되지 않은 상태로 다음(next) Security Filter 로직을 수행하다 보면 결국에는 Filter 내부에서 AuthenticationException이 발생하게 되고, 이 AuthenticationException은 바로 아래에서 설명하는 AuthenticationEntryPoint가 처리하게 됩니다.
⭐ SecurityContext에 클라이언트의 인증 정보가 채워지지 않은 상태에서 Security Filter 로직을 수행하게 되면 Security Filter 체인의 Filter 내부에서 AuthenticationException이 발생한다는 사실을 꼭 기억하세요!
2️⃣ AuthenticationEntryPoint 구현
AuthenticationEntryPoint는 SignatureException, ExpiredJwtException 등 Exception 발생으로 인해 SecurityContext에 Authentication이 저장되지 않을 경우 등 AuthenticationException이 발생할 때 호출되는 핸들러 같은 역할을 합니다.
MemberAuthenticationEntryPoint
package com.springboot.auth.handler;
import com.springboot.auth.utils.ErrorResponder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Slf4j
@Component
public class MemberAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
Exception exception = (Exception) request.getAttribute("exception");
ErrorResponder.sendErrorResponse(response, HttpStatus.UNAUTHORIZED);
logExceptionMessage(authException, exception);
}
private void logExceptionMessage(AuthenticationException authException, Exception exception) {
String message = exception != null ? exception.getMessage() : authException.getMessage();
log.warn("Unauthorized error happened: {}", message);
}
}
[코드 4-89] AuthenticationEntryPoint를 구현한 MemberAuthenticationEntryPoint
코드 4-89는 AuthenticationEntryPoint를 구현한 MemberAuthenticationEntryPoint입니다.
MemberAuthenticationEntryPoint 클래스는 인증 과정에서 AuthenticationException 이 발생할 경우 호출되며, 처리하고자 하는 로직을 commence() 메서드에 구현하면 됩니다.
코드 4-89에서는 인증 과정에서 AuthenticationException 발생하면 ErrorResponse를 생성해서 클라이언트에게 전송합니다.
ErrorResponder
package com.springboot.auth.utils;
import com.springboot.response.ErrorResponse;
import com.google.gson.Gson;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
public class ErrorResponder {
public static void sendErrorResponse(HttpServletResponse response, HttpStatus status) throws IOException {
Gson gson = new Gson();
ErrorResponse errorResponse = ErrorResponse.of(status);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(status.value());
response.getWriter().write(gson.toJson(errorResponse, ErrorResponse.class));
}
}
[코드 4-90] ErrorResponse를 클라이언트에게 전송하기 위한 ErrorResponder 클래스
ErrorResponder 클래스는 ErrorResponse를 출력 스트림으로 생성하는 역할을 합니다.
3️⃣ AccessDeniedHandler 구현
AccessDeniedHandler는 인증에는 성공했지만 해당 리소스에 대한 권한이 없으면 호출되는 핸들러입니다.
package com.springboot.auth.handler;
import com.springboot.auth.utils.ErrorResponder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Slf4j
@Component
public class MemberAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
ErrorResponder.sendErrorResponse(response, HttpStatus.FORBIDDEN);
log.warn("Forbidden error happened: {}", accessDeniedException.getMessage());
}
}
[코드 4-91] AccessDeniedHandler를 구현한 MemberAccessDeniedHandler
코드 4-91은 AccessDeniedHandler를 구현한 MemberAccessDeniedHandler입니다.
MemberAccessDeniedHandler클래스는 요청한 리소스에 대해 적절한 권한이 없으면 호출되는 핸들러로서, 처리하고자 하는 로직을 handle() 메서드에 구현하면 됩니다.
코드 4-91에서는 적절한 권한인지 확인하는 과정에서 AccessDeniedException이 발생하면 ErrorResponse를 생성해서 클라이언트에게 전송합니다.
4️⃣ SecurityConfiguration에 AuthenticationEntryPoint 및 AccessDeniedHandler 추가
앞에서 구현한 MemberAuthenticationEntryPoint와 MemberAccessDeniedHandler를 사용할 수 있도록 SecurityConfiguration에 추가해 보겠습니다.
SecurityConfiguration(V6)
package com.springboot.config;
import com.springboot.auth.filter.JwtAuthenticationFilter;
import com.springboot.auth.filter.JwtVerificationFilter;
import com.springboot.auth.handler.MemberAccessDeniedHandler;
import com.springboot.auth.handler.MemberAuthenticationEntryPoint;
import com.springboot.auth.handler.MemberAuthenticationFailureHandler;
import com.springboot.auth.handler.MemberAuthenticationSuccessHandler;
import com.springboot.auth.jwt.JwtTokenizer;
import com.springboot.auth.utils.CustomAuthorityUtils;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
import static org.springframework.security.config.Customizer.withDefaults;
/**
* authenticationEntryPoint와 accessDeniedHandler 추가
*/
@Configuration
public class SecurityConfiguration {
...
...
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.headers().frameOptions().sameOrigin()
.and()
.csrf().disable()
.cors(withDefaults())
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.formLogin().disable()
.httpBasic().disable()
.exceptionHandling()
.authenticationEntryPoint(new MemberAuthenticationEntryPoint()) // (1) 추가
.accessDeniedHandler(new MemberAccessDeniedHandler()) // (2) 추가
.and()
.apply(new CustomFilterConfigurer())
.and()
.authorizeHttpRequests(authorize -> authorize
.antMatchers(HttpMethod.POST, "/*/members").permitAll()
.antMatchers(HttpMethod.PATCH, "/*/members/**").hasRole("USER")
.antMatchers(HttpMethod.GET, "/*/members").hasRole("ADMIN")
.antMatchers(HttpMethod.GET, "/*/members/**").hasAnyRole("USER", "ADMIN")
.antMatchers(HttpMethod.DELETE, "/*/members/**").hasRole("USER")
.anyRequest().permitAll()
);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("*"));
configuration.setAllowedMethods(Arrays.asList("GET","POST", "PATCH", "DELETE"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("\\/**", configuration); // 주의 사항: 콘텐츠 표시 오류로 인해 '/**'를 '\\/**'로 표기했으니 실제 코드 구현 시에는 '\\(역슬래시)'를 빼 주세요.
return source;
}
public class CustomFilterConfigurer extends AbstractHttpConfigurer<CustomFilterConfigurer, HttpSecurity> {
@Override
public void configure(HttpSecurity builder) throws Exception {
AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class);
JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager, jwtTokenizer);
jwtAuthenticationFilter.setFilterProcessesUrl("/v11/auth/login");
jwtAuthenticationFilter.setAuthenticationSuccessHandler(new MemberAuthenticationSuccessHandler());
jwtAuthenticationFilter.setAuthenticationFailureHandler(new MemberAuthenticationFailureHandler());
JwtVerificationFilter jwtVerificationFilter = new JwtVerificationFilter(jwtTokenizer, authorityUtils);
builder
.addFilter(jwtAuthenticationFilter)
.addFilterAfter(jwtVerificationFilter, JwtAuthenticationFilter.class);
}
}
}
[코드 4-92] AuthenticationEntryPoint 및 AccessDeniedHandler 추가한 SecurityConfiguration
코드 4-92의 SecurityConfiguration에서는 (1), (2)와 같이 MemberAuthenticationEntryPoint와 MemberAccessDeniedHandler가 추가되었습니다.
애플리케이션을 실행한 후, 유효하지 않은 JWT 또는 만료된 JWT를 Authorization header에 추가해서 request를 전송하면 아래의 [그림 4-27]과 같은 response를 전송합니다.
[그림 4-27] AuthenticationEntryPoint에서 전송된 Error response
또한, 권한이 없는 리소스에 request 전송 시, 아래의 [그림 4-28]과 같은 response를 전송합니다.
[그림 4-28] AccessDeniedHandler에서 전송된 Error response
드디어 JWT에 대한 기능이 완성되었습니다. 👏👏
JWT는 웹 애플리케이션에 간단하게 적용할 수 있지만 웹 애플리케이션 보안에 있어 굉장히 중요한 역할을 합니다.
따라서 이번 시간에 여러분들이 애플리케이션에 JWT를 직접 적용해 봄으로써 가까운 미래에 있을 여러분들의 프로젝트 또는 실무에 JWT를 직접 응용하는 데 도움이 되길 바랍니다.
만약 실무에서 Username/Password 기반 로그인 인증과 JWT 자격 증명에 대한 기능이 추가되었다면 여러분들이 해야 할 작업이 하나 더 있습니다.
바로 Spring Rest Docs를 이용해 API 문서를 업데이트하는 것입니다. Spring Security를 통해 보안이 적용된 상태에서 API 문서를 업데이트할지는 여러분들의 몫으로 남겨두도록 하겠습니다.
핵심 포인트
- JWT는 JWS(JSON Web Token Signed)라고도 불린다.
- SecurityContext에 Authentication을 저장하게 되면 Spring Security의 세션 정책(Session Policy)에 따라서 세션을 생성할 수도 있고, 그렇지 않을 수도 있다.
- SecurityContext에 클라이언트의 인증 정보(Authentication 객체)가 저장되지 않은 상태로 다음(next) Security Filter 로직을 수행하다 보면 결국에는 AuthenticationException이 발생하게 되고, 이 AuthenticationException은 AuthenticationEntryPoint가 처리하게 된다.
- AccessDeniedHandler는 인증에는 성공했지만 해당 리소스에 대한 권한이 없으면 호출되는 핸들러이다.
심화 학습
- AuthenticationEntryPoint에 대해서 더 알아보고 싶다면 아래 링크를 참고해 주세요.
- AccessDeniedHandler에 대해서 더 알아보고 싶다면 아래 링크를 참고해 주세요.