기존 keycloak을 사용하며 인증 및 인가 처리를 맡기고 있었으나, 이번에 시큐리티와 JWT를 통해 스프링 내에서 자체적으로 인증 및 인가를 적용하는 작업을 하게 되었다.
인증이란?
증명. 어느 자원에 대한 요청이 들어왔을때, 요청 대상이 누구인지 확인하는 것.
회원가입 및 로그인된 사용자인지를 확인하는거라 생각하면 된다.
인가란?
허락. 어느 자원에 대한 요청이 들어왔을때, 요청 대상이 해당 자원에 대한 접근 권한을 가졌는지 확인하 일이다.
예를들어 우리 서비스에서는 비회원(임시 회원) / 회원 / 사업자 회원 / 관리자 등 다양한 Role타입이 있는데, 각 권한별로 요청이 가능한 api 목록이 다르다. 일반 회원인 사용자가 관리자 api를 호출하면 안되듯이, 이러한 인가 작업이 필수적이다.
인증 방식
JWT 기반 인증의 핵심은, HTTP 요청의 Authorization 헤더에 담긴 JWT 토큰을 디코딩해 사용자 정보를 추출하고, 이를 기반으로 Authentication 객체를 생성하는 것이다.
현재 사용되고 있는 필터 목록을 찍어보면 다음과 같다.
서비스에서 OAuth2 라이브러리를 사용하고 있는데, 위 목록에서 BearerTokenAuthenticationFilter라는 부분은 OAuth2ResourceServer 설정에서 내부적으로 등록되는 필터이다.
해당 필터가 동작되면서 요청 Authorization 헤더로부터 Jwt 토큰으로 디코딩하고, 등록한 JwtAuthConverter를 호출해 Authentication 객체로 변환하는 과정이 이루어지게 된다.
생성된 Authentication 객체는 SecurityContextHolder에 저장되어, 이후 컨트롤러나 서비스에서 인증된 사용자 정보를 활용할 수 있게 되는 것이다.
Oauth2ResourceServer 설정 부분이다. 시크릿 키 기반의 decoder, Converter 인터페이스를 구현한 JwtAuthConverter를 등록해주고 있다.
인증이 완료된 요청에 대해서는 앞서 말했듯이 SecurityContextHolder에서 context 내의 Authentication 객체를 꺼내 사용할 수 있다. 인증된 유저 정보가 필요할때 controller에서 꺼내와 service층으로 넘기기도 하는데, 나는 UserContext라는 클래스를 만들어 sevice 클래스 내에서 바로 User 객체로 꺼내와 사용할 수 있도록 구현해놓았다.
SecurityContextHoler 내의 Context로부터 Authentication 객체를 꺼내고 토큰의 subject 정보를 가져오기 위해 JwtAuthenticationToken으로 캐스팅한다.
* Principal: 유저에 해당하는 정보. 대부분의 경우 Principal로 UserDetails를 반환함.
* GrantAuthority: 유저의 권한 목록
인가 방식
인가 방식에 대해서도 고민했었는데, 필터 기반의 인가 방식을 선택하게 되었다.
controller단 메서드에 @PreAuthorize를 이용하여 AOP 기반으로 인가를 처리하는 방식은 개발 과정에서는 더 편리할것 같지만, 필터 기반으로 인가를 하게 되면 요청 엔드포인트별로 어떤 권한이 필요한지 한눈에 보이고 문서화하기에 더 용이할것이라는 생각에 해당 방식을 선택하게 되었다.
SecurityConstants 클래스에 필요한 권한에 따라 각 엔드포인트들을 묶어서 관리해주고 있다.
공부를 하다 든 의문이 있다. 인가 과정을 거치며 생긴 예외는 JwtAuthenticationEntryPoint 내에서 예외에 대한 처리를 해주도록 처리해놨다. 근데 생각해 보니 permitAll()는 이후 AuthorizationFilter에서의 설정인데, permitAll()로 설정된 엔드포인트 요청 헤더에 토큰 정보가 없는데도 어떻게 예외처리가 되지 않고 끝까지 넘어갈수 있는지에 대한 새삼스러운 의문이 생겼다.
1. BearerTokenAuthenticationFilter에서 요청 Authorization 헤더에 토큰이 없는경우에는 인증 시도를 하지 않는다는 점이다. 토큰이 없으면 예외를 던지지 않고, 인증 정보가 설정되지 않은 채로 다음 필터로 넘어가게 된다.
2. AnonymousAuthenticationFilter의 역할에 대해 알아야 한다.
AnonymousAuthenticationFilter에서 정상적으로 인증이 처리되어 context가 있다면 기존 context를 반환하고, 아니라면 AnonymousAuthenticationToken라는 익명 인증정보를 넣어준다고 한다.
그렇기에 앞서 인증을 건너뛰게 되더라도 여기서 익명 객체가 담기게 되고 AuthorizationFilter로 넘어가게 된다.
3. 인가 과정에서는 인증된 유저이면서도 익명 유저가 아님을 기대하기 때문에 permitAll()로 등록된 요청 엔드포인트가 아니라면 익명 유저일 경우 실패하게 된다. AuthorizationFilter에서 이 익명객체는 isGranted = false로 되어있기 때문에 인가 로직을 거치는 과정에서 조건을 통과하게 되지 못하게 되는 것이다.
인가 과정에서 발생하는 예외에 대해서는 JwtAccessDeniedHandler에서 처리되도록 해주었다.
permitAll()로 등록된 엔드포인트 요청이라면 인증정보 상관없이 통과하게 된다.
궁금했던 "헤더에 토큰 정보가 없는, permitAll로 설정된 요청"에 대한 흐름을 정리해보자면
BearerTokenAuthenticationFilter 인증과정 건너뜀 -> AnonymousAuthenticationFilter에서 익명 인증정보가 authentication에 담김 -> AuthorizationFilter에서 permitAll로 통과
이러한 흐름인 것이다!
그렇다면 확인겸 permitAll()로 설정한 요청에 대해 이후 authentication에 뭐가 들어가는지 로그를 찍어 눈으로 확인해보자.
역시 AnonymousAuthenticationToken이 들어가있다.
그런데, 왜 이런 익명 유저 설정을 넣어주는걸까? 그리고 왜 Authenticated=true로 설정되는걸까?
1. SecurityContext에 인증 객체가 전혀 없으면, 이후의 보안 체크나 인가 로직에서 null로 인식되어 예외가 발생하는 상황이 있을 수 있는데 이를 막기 위함도 있고, 시큐리티 로직 내에서 익명사용자와 인증된 사용자에 대해 구분할 이유가 있기 때문이라고 한다 (자세한, 한번에 와닿는 이유는 아직도 잘 모르겠다..)
2. Authenticated=true는 로그인이 완료되었음을 나타낸다기 보다는 보안 필터 체인을 거쳤음을 의미하는 것으로 본다고 한다.
애플리케이션이 항상 인증 객체를 가지고 일관된 보안 처리를 할 수 있도록 고안된 기능이라는 것이다.
시큐리티..잘 알고 싶다 😂
'SpringBoot' 카테고리의 다른 글
JWT 토큰 관리 방식에 대한 고민 / RTR 방식 도입 (0) | 2025.03.03 |
---|