이번 글에서는
JWT & Spring Security 에 대해서 정리해서 샘플코드와 함께 공유합니다.
외부에 공유되는 이 글에서는, 회사의 소스 코드 및 비즈니스 내용은 보안상 전혀 다루지 않습니다. 모든 코드는 필자가 주말에 집에서 직접 작성한 소스코드이며, (회사에서 사용하는 기술이지만) 공개 가능한 일반적인 이론 중심의 글을 작성하였습니다.
1장. Spring Session
2장. JWT & Spring Boot
3장. JWT & Spring Security
1장에서는 Spring Session 을 사용해서 세션 저장소를 구축하고, 인증 프로세스를 구현한다.
2장에서는 Spring Boot 환경에서 JWT 를 사용해서 인증 기능을 구현한다.
3장에서는 Spring Security 환경에서 JWT 를 사용해서 인증 기능을 구현한다.
주니어 개발자 및 취준생은 모르는 내용은 구글에서 검색하면서 공부하길 바랍니다.
이 글에서 소개하는 모든 샘플코드는 프론트엔드와 분리된 백엔드 API 서버입니다. 백엔드 서버에서는 화면 템플릿팅 기능을 하지 않으며, RestAPI 에 의해서 JSON 포맷의 데이터 리소스를 반환합니다. 즉, MVC 패턴을 구현하지 않으며, 템플릿팅 엔진(JSP, 타임리프, 프리마커 등)을 사용하지 않습니다. 필자는 테스트를 위해서 Postman 이라는 도구를 사용해서 HTTP 요청 테스트를 진행하였습니다.
주말에 반나절(6시간) 시간을 투자해서 아주 빠르게 작성한 글입니다. 필자는 Spring Security 를 3년 만에 사용합니다. 혹시라도 필자의 글에 잘못된 내용이 있다면 꼭 댓글 부탁드려요.
코드리뷰 환영합니다..(소정의 기프티콘 드림)
https://github.com/sieunkr/spring-jwt
HTTP는 상태가 유지되지 않는 Stateless한 비접속형 프로토콜이다. 그래서 서버는 클라이언트의 보통 이전 상태를 기억하기 위한 다양한 방법이 존재한다. 이 글에서는 JWT 에 대해서 집중적으로 얘기할 예정이지만, 그 전에 다른 방법인 Session을 활용하는 방법에 대해서 먼저 알아보도록 하자. 임베디드 톰캣은 고유한 세션 ID(JSESSIONID)를 웹 브라우저에 쿠키로 전달하고, 클라이언트에서는 서버에 요청 시 해당 세션ID(JSESSIONID)를 함께 전달한다. 서버는 전달받은 세션ID를 서버에 이미 저장 되어있는 정보와 비교해서 클라이언트의 상태를 지속적으로 유지하게 된다. 스프링 엔드포인트(컨트롤러)에서 HttpSession 매개변수를 정의하면, HttpSession 인터페이스의 구현체인 StandardSession 클래스 객체를 주입받는다. HttpSession 인터페이스의 getId() 메서드를 호출하면 JSESSIONID 쿠키의 정보를 확인할 수 있다.
HttpSession은 톰캣 라이브러리에서 정의된 인터페이스이다. HttpSession 인터페이스에 정의된 주요 메서드는 아래와 같다.
- setAttribute(String name, Object value)
- getAttribute(String name)
- removeAttribute(String name)
- getAttributeNames()
- setMaxInactiveInterval(int second)
- getId()
자세한 내용은 이글에서 생략한다. 공식 레퍼런스를 읽어보길 바라며..
https://tomcat.apache.org/tomcat-9.0-doc/servletapi/javax/servlet/http/HttpSession.html
간단한 RestController 를 만들어보자. (Spring Session 을 사용하지 않는 경우에는) 스프링 컨트롤러 메서드에 HttpSession 인터페이스를 정의하면, 스프링 프레임워크에서 HttpSession 의 구현체 중에서 StandardSessionFacade 를 주입해준다. 아래 샘플 코드를 보자.
로그를 찍어보면, 세션 ID 를 확인할 수 있다.
위 세션ID 는, 클라이언트 사용자 브라우저의 쿠키에도 동일한 세션ID 정보를 확인할 수 있다.
두번 째 호출에서는 사용자는 HttpHeader 에 세션 ID 정보를 포함해서 전달한다. 디버깅으로 HttpRequest 객체를 확인해보면 아래와 같이 JSESSIONID 의 값이 동일하다는 것을 알 수 있다.
처음 Request 에서는 세션 정보가 없어서, 서버에서 세션 ID 를 발급해준다. 두번 째 Request 에서는 이미 발급받은 세션ID 를 HttpHeader 에 담아서 호출하기 때문에, 서버에서는 세션을 추가로 발급할 필요가 없다. 사용자의 세션 ID 를 알고 있기 때문에,
서버에서는 클라이언트를 세션 ID 를 사용해서 사용자를 식별할 수 있다.
HTTP 는 상태가 유지되지 않는 Stateless한 비접속형 프로토콜이다. 아래 그림과 같이 첫 호출 때 서버에서 세션 ID 를 발급해주고, 두번 째 호출부터는 이미 발급받은 세션 ID 를 기반으로 사용자를 식별하게 된다.
스프링에서는 Spring Session 이라는 모듈을 제공한다.
자세한 설명은 생략한다...
서버 한 대로 운영하는 웹서비스가 있다고 가정하자. 아주 일반적인 "클라이언트-서버 아키텍처"이고, 클라이언트는 서버에서 발급한 세션 쿠키 정보를 Http Header에 같이 전달하고, 서버는 클라이언트가 전달한 세션쿠키 정보를 사용해서 클라이언트의 상태를 식별 및 유지한다.
그런데 문제가 발생했다.
서비스 사용자가 급증해서, 서버로 유입되는 트래픽이 급증하였다. 서버 1대로 운영하기에는 서버에 부담이 많다. 백엔드 서버의 부하를 줄이기 위해서, 아래와 같이 서버를 증설하였다.
프론트엔드(인터넷 브라우저, 클라이언트)에서는 L4 또는 L7 에 백엔드 API 요청을 하고, L4는 트래픽을 분산하여 두 대의 백엔드 서버에 분산해서 호출한다. 각각의 백엔드 서버에서 관리하는 세션 정보는 서로 공유를 하고 있지 않다. 즉, 세션 정보가 일치하지 않는 상황이 발생한다.
AWS ELB 에서는 Sticky Session 기능을 제공한다.
https://aws.amazon.com/ko/blogs/aws/new-elastic-load-balancing-feature-sticky-sessions/
Sticky Session은 로드 밸런서에서 클라이언트가 동일한 백엔드 API 서버에 접근할 수 있도록 해준다. 즉, 클라이언트는 자신의 세션 정보를 갖고 있는 특정 백엔드 서버에만 접속한다. 세션이 일치하지 않는 상황이 발생하지 않는다. 하지만, 해당 기능은 로드 밸런싱의 기본적인 역할인 트래픽 부하 분산 역할을 완벽하게 수행할 수 없다는 단점이 있다.
해결책은 아주 간단하다. 세션을 공유할 수 있는 별도의 세션 저장소를 구축하면 된다. 필자의 글에서는 Redis을 사용해서 세션 저장소를 구축하였다. 스프링 세션은 세션 공유 저장소를 심플하게 구축할 수 있는 기능을 제공한다. (이 글의 핵심 주제는 Spring Session 이 아니다.. 대충 설명하고 넘어가겠다.)
이 글을 읽는 개발자는 필자의 의견에 의문점이 생길 수 있다. 별도의 세션 저장소를 구축하지 않고, 백엔드 서버 간에 세션 정보를 공유할 수 있는 방법이 있지 않을까? 방법을 아는 개발자는 댓글로 의견을 남겨주길 바란다.
어쨋든, 필자는 세션 저장소는 백엔드 서버와 별도로 분리하는게 좋다는 생각이다.
첫번 째 이유
그럴 일은 거의 없겠지만 모든 서버가 죽는다고 가정해보자. 서버 간에 세션 정보를 공유할수 있는 기능이 존재하더라도, 모든 서버가 죽어버리면 공유할 수 있는 세션 정보는 없게 된다.
두번 째 이유
필자가 경험했던 대용량 트래픽 환경에서는 백엔드 서버의 확장 속도와, 저장소의 확장 속도는 항상 비례하지 않았다.
웹서버가 발전하고 확장되는 속도는,, 저장소가 확장되는 속도는 비례하지 않는다.
즉, 웹서버만 확장을 해야하는 경우가 발생할 수도.. 세션 저장소만 확장해야 하는 경우도 있다. 웹서버와 세션 저장소가 강하게 의존성을 맺고 있다면 효율적인 서비스 확장이 불가능하다. 분산 환경에서 안정적인 서비스 운영을 하기 위해서, 세션 저장소 레이어는 반드시 분리해야 한다.
자... 간단하게 세션 클러스터링에 대해서 설명하였다. 스프링은 세션 클러스터링을 심플하게 지원하는 스프링세션 이라는 모듈을 제공하고, 스프링 세션과 Redis 저장소를 연동할 수 있다. 물론, Redis 외에 Mongo DB 또는 다른 저장소를 선택할 수도 있다.
스프링 프레임워크의 핵심 전략은 바로 추상화인데..
(스프링의 철학에 대해서는 자세한 내용은 생략한다.)
필자의 허접한 샘플 코드는...
https://github.com/sieunkr/spring-jwt/tree/master/spring-session
디펜던시에 spring-session-data-redis 를 추가한다.
이 글에서는 두 개의 엔드포인트 컨트롤러를 제공한다.
- 로그인 요청(모든 사용자에게 제공)
- (리소스)컨텐츠 조회 요청(인증&인가 통과한 사용자에게 제공)
컨텐츠 조회는 인증,인가 모두 통과한 사용자에게만 제공한다.
인증 및 인가를 구현하기 위해서 인터셉터를 작성해보자.
위 코드와 같이
/api/v1/login 으로의 유입은 인터셉터를 거치지 않도록 설정하였다. (로그인 시도는 인증 상관없이 가능하도록)
/api/v1/coffees 으로의 유입은 인터셉터를 통과해야 한다. (인증,인가 통과한 사용자만 접근)
인터셉터에서는 세션 데이터에 "USER" 라는 권한이 존재하는지 체크한다. 권한이 존재한다면 true 를 리턴, 즉 인터셉터를 통과시킨다.
반면에, 인증&인가에 실패하면 필자가 별도로 정의한 CustomAuthenticationException 을 발생시킨다.
Exception 에 대한 핸들러는 @ControllerAdvice 어노테이션을 사용해서 아래와 같이 구현하였다. 인증&인가 에 실패하면 아래와 같이 CommonResponse 객체와 함께 401 에러를 리턴한다.
컨텐츠를 조회해보자. 아래와 같이 Exception 이 발생하였다.
레디스에 저장된 세션 정보를 확인해보자.
세션 데이터는 아래와 3개의 해시필드가 저장되어있다.
creationTime
lastAccessedTime
maxInactiveInterval
Redis Session 에 의해서 저장되는 데이터에 대한 상세한 설명은 생략한다. 각자 찾아보길 바란다.
로그인 기능을 구현한다. 로그인이 성공하게 되면 세션 정보에 권한을 셋팅해줄 것이다. 참고로, 스프링 세션 디펜던시를 추가하면, 1장에서 확인했던 구현체와는 다른 구현체가 주입되는데...
로그인이 성공했다고 임시로 설정한 후, 권한 및 사용자 정보를 httpSession 에 저장해보자. 레디스에 직접 접속해서 세션 정보를 확인해보자.
참고로, 해시필드 네이밍은 sessionAttr: 로 시작한다. (스프링이 알아서 설정해준다.)
로그인이 성공하였다. 사용자의 세션 ID 에 해당하는 권한 정보가 세션 저장소에 저장되었다. 컨텐츠 조회 요청을 다시 해보자. 세션 정보에 권한이 존재하기 때문에 인터셉터에서 인증&인가 단계를 통과할 수 있다.
인터셉터에서 아래와 같이 통과하는 것을 확인할 수 있다.
로그를 찍어보면, 세션데이터에 정상적으로 값을 조회할 수 있다.
참고로, 세션 만료 시간이 지나면 어떻게 될까?
세션 유지 시간이 만료되면,
인증&인가를 통과하지 못하게 된다. 다시 로그인을 해서, 세션 데이터를 새로 갱신해야 한다.
Spring Session Core 의 소스를 열어보면, 레디스에 저장하는 로직을 찾을 수 있다.
HttpSessionAdapter 클래스의 setAttribete 메서드이다.
해시 필드의 네이밍은 spring data redis 모듈에서 확인할 수 있는데, "sessionAttr:" 로 저장한다.
세션 저장소를 구축하는 방법은, 일반적으로 자주 사용되는 방법이다. 하지만, 한가지 단점이 존재하는데, 세션 저장소에 매번 조회를 해야한다는 사실이다. 사용자의 리소스 조회 요청이 있을때마다 권한이 있는지 검증하기 위해서, 매번 세션 저장소에 세션 데이터를 조회해야 한다.
세션 저장소를 매번 조회하지 않고,
인증&인가 기능을 구현할 수 있는 방법이 있을까?
2장에서는 스프링 부트 환경에서 JWT 를 연동한 샘플 코드를 공유한다.
JWT 및 스프링부트에 익숙한 개발자는, 2장을 패스하고 3장을 바로 읽어도 된다.
https://github.com/sieunkr/spring-jwt/tree/master/spring-jwt
JWT 에 대한 상세한 설명은 생략하겠다. 공식 레퍼런스를 참고하길 바라며...
구글에서 검색하면 수많은 글이 나올 것이다.
JWT 를 사용하기 위해서 아래와 같이 의존성을 추가한다.
인터셉터 구현은 1장(Spring Session) 에서와 동일하다.
api/v1/login 요청은 인증 없이 접근이 가능하다.
api/v1/coffees 는 반드시 인증&인가 를 통과한 후 접근할 수 있다.
인증 및 인가를 위한 인터셉터는 아래와 같다.
JWT 토큰은, 두가지 조건을 만족해야 한다.
1. 유효한 사용자인지(유효한 토큰인지) 검증(인증)
2. 리소스에 대한 권한이 있는지 검증(인가)
유효한 토큰인지 여부는 아래와 같이 구현하였는데,
JWT 토큰이 유효하지 않다면 Exception 이 발생할 것이다.
Exception 에 대한 자세한 설명은 생략한다.
- SecurityException
- MalformedJwtException
- ExpiredJwtException
- UnsupportedJwtException
- IllegalArgumentException
사실... 인증이라는 과정이 사용자가 누구인지, 클라이언트가 주장하는 사용자가 같은 사용자인지를 검증하는 과정이다. 하지만, 필자의 샘플 코드에서는 JWT 토큰의 유효성만 검증한다. 즉, 로그인시 토큰을 생성할 때만 사용자가 맞는지 검증하며, 그 이후 리소스 접근할 때는 별도로 사용자의 상세한 검증을 하지는 않는다.
JWT 토큰의 유효성 검증만으로 인증을 통과시키는게 과연 맞는 방법일까??? 의견 부탁한다...
어쨋든, 토큰이 유효하다면(즉, 인증을 통과했다면)
해당 토큰에 설정된 권한이 페이지에 접근할 수 있는지에 대한 인가 과정을 수행해야 한다.
위와 같이 두가지 검증(인증,인가)를 통과하면, 인터셉터의 preHandle 메서드에서 true 를 리턴한다.
http://localhost:8080/api/v1/coffees 를 호출해보자. 이때 HttpHeader 의 "x-auth-token" 에는 유효하지 않은 토큰정보를 포함하였다. 당연히 인증에서 실패하였고, 아래와 같이 401 에러가 발생하였다.
참고로 응답 포맷은, @ControllerAdvice 어노테이션, @ExceptionHandler 어노테이션을 사용하여, 필자가 정의한 응답 포맷으로 응답하도록 작성하였다.
지금까지 인증 및 인가를 위한 인터셉터를 구현하였다.
자, 그렇다면 인증 및 인가에 통과하기 위해서는 어떻게 하면 될까? 로그인을 시도해서 인증 토큰을 만들면 된다.
사용자가 로그인을 성공하면 백엔드 API 서버에서는 JWT 토큰을 생성한다. 그리고, 생성된 토큰을 프론트엔드에 전달한다. 아래 그림과 같다.
프론트엔드에서는, 로그인이 성공하면 JWT 토큰을 잘 저장하고 있어야 한다. 그리고, 필요한 리소스를 요청할 때 백엔드 API 를 호출하면서 JWT 토큰을 Http Header 에 함께 전송해야 한다. 아래 그림과 같이, JWT 토큰에 대한 인증,인가 체크를 인터셉터에서 수행하며, 통과하면 컨트롤러에 접근할 수 있다.
정리하면,
프론트엔드에서 백엔드 API 에 요청시에 JWT 토큰을 같이 넘겨주며, 인증 토큰이 유효한지, 리소스에 대한 권한을 검증한 후 리소스에 대한 응답을 해준다.
필자의 글이 이해가 잘 되나요? 너무 글이 길고 지겹나요?
글 쓰는게 너무 귀찮아서, 대충 쓰고 있다. 글은 빠르게 작성하고 마무리하고자 한다.
나중에는 동영상으로 공유를 해보는 것도 좋겠다는 생각이다... 근데 그것마저도 귀찮아서..
로그인 요청 및 JWT 토큰을 생성하는 로직을 구현해보자. Jwt 라이브러리를 사용해서 토큰을 생성하는 구문이다.
setExpiration 에 토큰 만료시간을 반드시 지정해야 한다. 그리고, Secret Sign Key 를 반드시 설정해야 한다. Secret Sign Key 는 외부에 절대 노출되면 안되는 값이다. 해당 Key 를 누군가(제3자가) 알게되면, 외부 누군가가 JWT 토큰을 임시로 만들어서 리소스를 요청할 수 있게 된다.
jwt.io 에 접속해서, 토큰을 만들어보자. 우측 하단에, Sign Key 를 입력하는 부분이 있다.
하텅, 로그인 컨트롤러는..
로그인이 성공하게 되면, JWT 토큰을 응답한다.
프론트엔드에서는 전달받은 토큰을 잘 저장하고 있어야 한다. 웹 브라우저의 경우에는 쿠키에 저장해도 된다. 웹 브라우저가 아니라면(예:모바일앱 등) 해당 OS 에 맞게 토큰 정보를 잘 저장하자.
토큰을 갖고 있는 클라이언트에서, Http Header 에 토큰을 포함해서 리소스에 대한 요청을 한다. 아래와 같이 토큰 정보를 Http Header 에 필자의 샘플 소스에서 정의한 "x-auth-token" 이라는 키 값에 저장해서 전달하게 되면
요청에 대한 인증 및 인가를 통과하게 될 것이다!!!
백엔드 API 서버가 여러대인 상황이다. 프론트엔드에서는 어떤 백엔드 서버에서 JWT 를 받았는지 알 수 없다. 프론트엔드에서는 L4, L7, API GateWay, AWS ELB 등에 의해서 백엔드 서버 중 1대에 요청하게 된다.
각 백엔드 서버에서 해당 토큰이 유효한지 검증하기 위해서, 모두 동일한 시크릿 키를 갖고 있어야 한다. 만약 서로 다른 시크릿 키를 가지고 있다면, 인증에 실패할 것이다. JWT 토큰에 대한 인증 검증을 백엔드 서버에서 직접 하기 때문에, 사용자의 식별정보(세션ID)를 세션 저장소에 저장하지 않아도 된다.
요청이 있을 때마다 매번 세션 저장소에 조회를 하지 않아도 된다는 사실은.
백엔드 시스템의 부하를 줄이며, 매우 심플하면서도 안정적인 인증 시스템을 구축할수 있다.
하지만, Secret Key 가 노출된다면 어떻게 될까? 시크릿 사인 키를 누군가 알게 된다면 JWT 토큰을 임시로 생성할수 있고, 복호화할 수 있다. 시크릿 키의 관리가 매우 중요하다.
또한, 시크릿 키를 모른는 상황에서도 문제가 발생할 수있다. 토큰 자체를 누군가가 가로챈다면, 역시 해당 토큰으로 인증(인가)를 성공시킬 수 있게 된다.
그래서 필자가 생각한 방법은
JWT 토큰에 대한 만료시간은 짧게 설정하는 방법이다..
하지만, 토큰 시간이 너무 짧으면 너무 자주 로그인 시도를 해야 하며, 프론트에서 작업중인 화면이 갑자기 사라지는 경우가 발생할 수 있다. 개선 방법으로는, 이미 인증된 사용자의 API 리소스 요청 시에 리프레시 토큰을 재발급하여 인증 만료 시간을 연장하는 방법도 가능하다.
고민해야할 사항이 매우 많다.
세션을 사용하던, 토큰 방식의 인증을 사용하던 완벽한 기술은 역시 없다.
어떤 기술을 사용하든,,, 많은 고민이 필요한 주제이다.
2 장에서는, JWT 토큰을 스프링의 인터셉터에서 인증, 인가를 구현하는 간단한 샘플 사례를 소개하였다.
3장에서는 드디어, Spring Security 를 연동해보겠다.
글쓰는게 힘드니 3장은 (핵심 주제이지만) 진짜 짧게 마무리할 예정이다...
3장에서는 스프링 시큐리티를 사용해서 인증,인가 를 구현하겠다.
이번 3장에서 추가된 내용은 아래와 같다.
- 스프링 시큐리티 를 연동한다.
- JPA 를 사용해서 회원 DB 구성한다.
- 테스트 코드를 작성한다.
(필자가 JPA 는 3년째 계속 초보 수준이고, 스프링 시큐리티는 5년만에 사용해본다.)
스프링 시큐리티 및 JPA 를 사용하면서 글의 난이도가 급상승하였다. 그리고, 스프링 시큐리티를 상세하게 설명할려면 책 한권 써야한다. 내용이 매우 방대하기 때문에 이 글에서 모두 설명할 수 없다.
아마도 앞으로 스프링 시큐리티를 할 기회는 당분간 없을 것 같지만...
나중에 필자의 시간이 허락 된다면, 상세하게 공유할 기회가 있지 않을까??
일단
Spring Security, Spring Data JPA 디펜던시를 추가한다. 그리고, 로컬 개발환경에서는 h2 를 사용하겠다.
회원 정보를 저장할 허접한(?) 엔티티를 아래와 같이 작성하였다. 아주 심플하다.
참고로, Spring Security 를 사용하기 위해서, 반드시 RDBMS 저장소를 구축할 필요는 없다. 회원 정보에 대한 상세한 구현이 필요한 경우에만 회원 DB 를 구현하면 된다. 아무튼, 스프링 부트 실행 시 h2 가 같이 실행이 되는데, 초기ㅣ 테스트 데이터를 저장하도록 sql 쿼리를 작성하였다.
스프링 부트가 실행이 되면, H2 테이블에 import.sql 에 작성한 쿼리가 실행할 것이다.
이때 중요한 점은,
password 에는 "password" 문자열을 인코딩해서 저장하며, ROLE 컬럼에는 "ROLE_USER" 값을 저장해준다.
eddy 라는 사용자는,
비밀번호가 "password"이며, ROLE_USER 권한을 갖는다.
DB 에 저장된 비밀번호는, 사용자가 로그인 요청 시 전달하는 비밀번호와 비교를 할 것이다.
패스워드가 일치하면 인증이 성공하며, 패스워드가 틀리다면 인증에 실패할 것이다.
1,2 장에서는 인터셉터를 사용하였지만, 3장에서는 인터셉터를 사용하지 않는다. JWT 인증 필터를 직접 구현하며, 해당 인증 필터를 스프링 시큐리티 컨피그 설정에 추가할 것이다.
참고로,
지금부터 필자가 설명하는 샘플 코드는 이해하기 매우 빡씨다.
전체 코드를 찬찬히 보면서 이해하길 바란다. 안되면 할 수 없고...
https://github.com/sieunkr/spring-jwt/tree/master/spring-security-jwt
GenericFilterBean 를 구현한 JWTFilter 클래스를 작성한다.
JWT 토큰의 유효성을 검증하는 로직은 2장과 유사하다. 차이점은, 인증에 성공하면 Spring이 관리하는 SecurityContext 에 인증 객체를 설정해준다. 매우 중요하다.
SecurityContextHolder.getContext().setAuthentication(authentication);
인증 객체는 반드시, Authentication 의 구현체만 가능하다. 필자는, UsernamePasswordAuthenticationToken 를 사용하였다.
Authentication 의 구현체는 어떤게 있을까? 스프링에서는 UsernamePasswordAuthenticationToken 를 제공하지만, Authentication 를 구현해서 직접 구현체를 만들어도 된다. 자세한 내용은 생략한다.
SecurityConfigureAdapter 를 상속하는 JWTConfigurer 클래스에 필터를 설정한 후
WebSecurityConfig 설정에서 필터를 최종 설정해야 한다.
사용자의 모든 요청은, JWT 필터를 통과하게 될 것이다.
스프링 시큐리티 컨피그 전체 설정은 아래와 같다.
1. 인증 또는 인가에 실패한 경우 Exception 처리
2. 세션 기능을 사용하지 않는다
3. /api/v1/login 으로 시작하는 요청은 인증 성공 여부와 상관없이 모두 접근 가능
4. /api/v1/coffees 으로 시작하는 요청은 인증을 반드시 통과해야 하며, 인가(USER 권한)이 있는 사용자만 접근 가능
리스스에 대한 요청을 하면 인증에서 실패할 것이다.
필자가 정의한, AUTHENTICATION_FAILED 라는 메시지를 리턴한다.
자, 이제 인증&인가 를 성공할 수 있도록... 해보자
3.3 장은 이 글에서 가장 어려운 내용이다.
필자가 잘못 알고 있다면 꼭 제보해주길 바란다.
LoginService 에 구현한 로그인 요청 메서드이다.
사용자의 이메일, 패스워드를 사용해서 UsernamePasswordAuthenticationToken 인증 객체를 만든다. 그리고, 해당 객체에 저장된 패스워드가 실제 DB 에 저장된 비밀번호가 맞는지 검증이 필요한데, 해당 과정을 수행하는 로직은 바로 authenicate 메서드이다!!!
authenticationManagerBuilder.getObject().authenticate(authenticationToken);
아.. 도대체 이게 어떻게 동작하는거야...
로그인 요청으로 생성한 인증 객체를, 실제 DB 회원 정보 를 비교하는 구문은 어디에 있는걸까?
UserDetailService 인터페이스를 구현해야한다.
해당 인터페이스는 loadUserByUsername 이라는 메서드를 재정의해야 한다.
loadUserByUsername 메서드에서 실제 DB 의 회원 정보를 가져오는 로직을 구현해야 한다. 필자는, 회원 정보를 이메일로 찾을 것이다. Repository 에 findByEmail 이라는 메서드를 구현하였다.
그리고, loadUserByUsername 메서드는 userDetails 라는 인터페이스를 리턴해야 한다. 필자의 경우에는 UserDetails 를 구현하는 (스프링에서 제공하는) User 라는 구현체를 사용하였다.
반드시 User 를 사용해야 하는지? 그렇지 않다. UserDetails 를 직접 구현해서 CustomUser 를 정의할 수 있다. 이렇게 직접 구현하면, 필요한 데이터를 커스텀하게 추가할 수 있다. CustomUser 를 정의하면, 이메일 필드를 직접 추가할 수도 있고, 필요한 데이터를 직접 설정할 수 있다. 상세한 설명은 역시 어려우니깐 생략..
그래서...
DB 회원 정보 를 비교하는 핵심 인증 로직은 과연 어디에 있는걸까?
AbstractUserDetailsAuthenticationProvider 추상클래스에서 authenticate 메서드를 실행한다.
해당 메서드에서, 아래와 같이 DaoAuthenticationProvider 클래스의, additionalAuthenticationChecks 메서드를 실행하는데.
비밀번호를 비교하는 구문을 찾을 수 있다.
매개변수를 알아보자.
userDetails 는 필자가 정의한 CustomUserDetailsService 클래스의 loadUserByUsername 에서 리턴하는 UserDetails 의 구현체는 User 객체이다.
authentication 는 필자가 loginService 에서 실행했던 구문에 주입되는 UsernamePasswordAuthenticationToken 객체이다.
해당 UsernamePasswordAuthenticationToken 인증객체에는 필자가 컨트롤러에 함께 전달한 패스워드가 설정되어있다.
userDetails 객체에는 DB 에서 찾은 회원정보를 갖고 있기 때문에 패스워드는 인코딩되어서 저장되어 있다. 자, 어쨋든 아래 구문에 의해서 비밀번호를 비교하는데...
만약, 비밀번호가 다르다면... BadCredentialsException 을 발생시킨다.
하아.. 글로 설명할려니 너무 어렵다.
[전문가를 위한 내용]위 샘플 사례에서는, 스프링에서 제공하는 DaoAuthenticationProvider 를 사용하였다. 필자는 별도로 DaoAuthenticationProvider 를 정의하지 않았다. 스프링에서 자동으로 설정해준 것이다. 만약, DaoAuthenticationProvider 를 사용하지 않겠다면, AuthenticationProvider 를 Custom 하게 정의할 수 있다. Custom 하게 정의한 AuthenticationProvider 에서는 authenticate 메서드를 직접 구현해야 한다. 해당 authenticate 메서드에서는, 사용자가 요청한 비밀번호가 회원 DB 에 정의한 비밀번호와 맞는지 인증을 검증하는 로직을 직접 구현해야 한다. 자세한 내용은 생략한다.
이 부분은 좀 중요한 내용인데.. 설명하기 힘드니, 샘플 코드가 필요하다면 따로 댓글로 남겨주면 추가 구현해서 공유하겠다..
로그인을 해보자. 인증 토큰을 발급받는다.
발급받은 토큰을 Header 포함해서 리소스 조회를 해보자.
잘 된다!
예외 처리에 대해서 알아보자.
3.3에서 설명한듯이,
로그인 실패 시 BadCredetialsException 예외가 발생한다.
필자는, GlobalExceptionHandler 에서 핸들러를 구현하였고
패스워드가 틀리면, BadCredentialsException 이 발생한다.
인증에 실패한 경우에는,
아래와 같이 정의한다.
AuthenticationEntryPoint 를 구현해서 작성한 JwtAuthenticationEntryPoint 에서 클래스의 commence 메서드에서 직접 throw new xxxException 를 발생시키면 원하는대로(GlobalExceptionHandler로 리턴) 동작하지 않는다. 이유를 아는 개발자는 의견을 남겨주길 바란다. HandlerExceptionResolver 를 사용하면 GlobalExceptionHandler 로 넘길 수 있기는 하다.
만료시간이 지난 오래된 인증 토큰으로 요청해보자.
인증에 실패하면, 아래와 같이 AuthenticationEntryPoint 의 구현체로 이동한다.
그리고, 아래와 같이..
자세한 설명은 생략한다.ㅏ
시큐리티 설정을 살짝 수정해보자.
/api/v1/coffees 의 요청에 대한 권한을 관리자 권한으로 변경하였다.
참고로, DB에 저장된 eddy 사용자의 권한은 ROLE_USER 이다.
인증을 통과하겠지만, 즉, 사용자가 맞는지에 대한 비밀번호 검증은 통과하지만,
해당 리소스에 대한 조회 권한이 없기 때문에, 권한 오류가 발생할 것이다. 즉, 인가는 실패한 것이다.
인증 통과했지만, 인가에서 실패!!
ACCESS_DENIED 오류가 발생하는데,
AccessDeniedException 를 잡아주면 된다.
생략....
테스트 코드를 작성해보자.
단위 테스트 작성하기가 어려웠다. 통합테스트로 작성하였다.
로그인 성공시에는, 인증 객체에 정상적으로 데이터가 있는지 검증한다.
로그인 실패시에는, BadCredentialsException 예외 처리가 발생하는지 검증하였다.
인증 토큰이 만료된 사용자의 경우에는 아래와 같이, 401 에러가 발생한다.
인증 토큰은 유효하지만, 리소스 접근 권한이 없다면,
Spring Security 와 JWT 를 연동해서 인증, 인가 를 구현하였다. 샘플 코드에 대한 소개가 상세하지 않아서 이해하기 쉽지 않을 것이다. 이글을 정독해서 읽은 개발자는 없겠지만, 조금이라도 도움이 되었다면 다행으로 생각하겠다. 기술블로그에 작성하기에는 너무 어려운 주제였던 것 같다. (기술이 어렵다는 의미가 아니라, 글로 설명하기 너무 어려운...) 아마도 스프링 시큐리티가 너무 방대하고 글 하나로 설명하기에는 무리가 있었던 것 같다. 좋은 강의가 있다면 알려주길 바란다.
(하지만, 스프링 시큐리티 관련 주제는 당분간 다루지 않을 예정이다.)
세달만에 작성한 기술블로그 글인데, 글 쓰는건 항상 어려운 것 같다.
기술블로그가 본인에게 크게 도움이 되지 않고, 귀찮아서인지 기술블로그 운영이 쉽지 않다.
기술블로그 운영을 포기했다가 3달만에 다시 시작했지만... 다음 글은 아마도...
바쁜 연말이 지나고 내년 초가 되어야 가능하지 않을까? 아래 주제 중 하나로 글을 쓰게 될 것 같다.
- 자바 객체지향 프로그래밍
- 주니어 개발자를 위한 스프링부트 A-Z
- 웹서비스 애플리케이션 아키텍처 3)캐싱
- AWS 를 처음 사용하는 시니어 개발자의 후기(IDC 와 비교)
- Kafka VS RabbitMQ