<JWT 도입>
토큰 기반 로그인 방식 에서는 사용자가 로그인을 하면 서버가 사용자를 식별할 수 있는 최소한의 정보와 만료 시간 등을 포함한 토큰을 발급해준다. 사용자는 이후 요청을 보낼 때 이 토큰을 서버에 함께 보내고, 서버는 해당 토큰을 검증해 인증과 인가를 처리한다.
해당 방식은 어떤 서버에서 토큰을 발급했든 클라이언트가 보내온 토큰만 확인하면 인증과 인가가 가능하기 때문에, 여러 서버를 운영하는 환경에서 특히 편리하게 사용할 수 있다. 서버 측에서는 해당 토큰이 내가 발급해 준게 맞는지 확인할 수 있는 비밀키를 제외하고 아무것도 서버측에 저장하지 않는다.
이런점 덕분에 서버 인스턴스를 수평 확장할 때 세션 방식보다 유리하고, 별도의 세션 저장소를 두지 않아도 되기 때문에 서버 저장 공간도 아낄 수 있다.
JWT(JSON Web Token)는 토큰 기반 인증에서 가장 널리 사용되는 방식 중 하나이고 우리 프로젝트도 이를 사용할 생각이다. 이를 위해서 우선 JOSE (JSON Object Signing and Encryption)에 대해서 먼저 알 필요가 있었다. JOSE는 JSON 데이터를 안전하게 서명(Signing)하고 암호화(Encryption)하는 데 사용되는 표준 기술 스택이다.
JOSE의 대표적인 구성 요소로는 JWS, JWE, JWA, JWK가 있다. JWS는 데이터를 서명해서 변조되지 않았다는 걸 보장해주고,
JWE는 데이터 전체를 암호화해서 아예 내용을 볼 수 없게 만든다.
JWA는 어떤 암호화 알고리즘을 사용할지를, JWK는 공개키나 비밀키를 JSON 형식으로 표현한 것을 말한다. 이런 용어들을 미리 알고 있으면 JWT 구조를 이해하는 데 도움이 된다.
JWT는 이러한 JOSE 스펙을 활용해 만들어진 구현체다. JWS 기반의 JWT를 만들 수도 있고, JWE 기반의 JWT를 만들 수도 있다. 이번 프로젝트에서는 JWT 안에 사용자의 중요정보를 담지 않을 예정이고, JWS 방식이 가장 널리 사용되고 레퍼런스도 많기 때문에 JWS 기반의 JWT를 사용하기로 했다.

JWS 기반으로 구현된 JWT의 구조는 위와 같다. 토큰의 타입, 서명 알고리즘, 토큰 롤링을 위한 ID 값 등의 메타 정보가 담긴 Header와 실제 전달하고자 하는 데이터가 담긴 Payload가 존재한다.
그리고 가장 중요한 서명(Signature) 값이 붙는다. 이 서명 값은 Header와 Payload를 각각 Base64로 인코딩한 후, "Header.Payload" 형태로 이어붙인 문자열을 JWK에 정의된 키와, 토큰 생성 시 지정한 JWA 알고리즘을 이용해 서명하여 생성된다. 이 서명을 통해 토큰이 위변조되지 않았는지 검증할 수 있다.
마지막으로 Header + Payload + 생성된 서명값을 Base64로 다시 인코딩 하여 왼쪽의 eyjhbGc... 같은 형태의 문자열로 주고 받게 된다.

예를 들어, 위의 JWT 예시에서는 HS256이라는 알고리즘을 통해 서명과 검증을 진행한다. HS256은 대칭키 방식의 서명 알고리즘으로 서명과 검증에 동일한 키를 사용한다.

어떤 사용자가 로그인된 회원만 할 수 있는 요청과 함께 JWT를 보내오면, 서버는 먼저 해당 토큰에서 Header와 Payload를 추출한 뒤, 서버측의 SecretKey를 이용해 토큰을 발급할 때와 같은 방식으로 Signature를 생성해 본다.
이때, 사용자가 보내온 JWT에 포함된 Signature와 서버가 생성한 Signature가 일치한다면, 해당 토큰은 서버에서 발급된 것이며 변조되지 않았음을 확인할 수 있고 해당 토큰의 Payload에 담겨져 있는 내용을 신뢰 하게 된다.

JWT를 사용한 로그인 과정에서, 가장 먼저 해야 할 일은 Github OAuth를 통해 로그인에 성공한 사용자에게 JWT를 발급하는 것이었다. OAuth2LoginAuthenticationFilter에서 인증을 마치면, 해당 필터에서 인증 성공 후 실행되는 메서드가 호출된다.
해당 메서드의 마지막 부분에서 AuthenticationSuccessHandler의 인증 성공 메서드를 실행하는 것을 확인할 수 있다. 즉, 이 핸들러를 커스터마이징하여 OAuth 로그인에 성공한 사용자 요청을 제어할 수 있다. 이를 통해, 로그인 성공 후 JWT를 발급하거나 추가적인 후속 작업을 자유롭게 처리할 수 있다.
JWT 발급 및 검증에는 nimbus-jose-jwt 라이브러리를 선택했고 서명 및 검증 방식으로는 간단하면서도 성능이 좋은 대칭키 기반 알고리즘을 선택했다.
스프링 시큐리티 OAuth2| 정수원 - 인프런 강의
현재 평점 4.9점 수강생 2,391명인 강의를 만나보세요. 스프링 시큐리티 OAuth2의 기본 개념부터 API 사용법과 내부 아키텍처를 학습합니다. 아울러 OAuth2 Client, OAuth2 Resource Server, Authorization Server를 통
www.inflearn.com
위의 강의를 참고하여 JWT 발급과 검증 로직을 구현했다. (강사님께 블로그 기록 허가 받았습니다.)

위와 같이 나의 SecurityFilterChain에 JWT 관련 필터들을 등록해 줬다. JWT를 이용한 로그인을 구현할 때 보안상 신경 써줘야 할 부분들이 몇개 있었다.
우선 JWT 헤더의 "alg" 값을 조작하여 인증 절차를 우회하는 공격을 방어해야 했다. 예를 들어, 공격자가 JWT 헤더의 alg 값을 none 또는 서버에서 의도하지 않은 알고리즘으로 변경할 경우, 위조된 토큰이 정상적으로 검증될 수 있다. 이를 방지하기 위해서, 헤더의 alg 값을 신뢰하지 않고, 사전에 고정된 알고리즘(HS256)만을 사용하여 JWT를 검증하고 발급하도록 설정했다.

토큰 기반 로그인 방식에는 악성유저가 토큰을 탈취한다면 위와 같이 서버에서 정상 사용자인지 악성 사용자인지 구별할 방법이 없다는 문제가 있다.

이런 토큰 탈취로 인한 피해를 일부 방어하기 위해서 토큰의 유효기간을 매우 짧게 설정하는 방법이 있다. 그러나 이런 방식은 토큰 탈취 시 피해를 최소화하는 데에는 일정 부분 효과가 있을 수 있지만,.사용자가 몇 분, 몇 초 단위로 반복적으로 ID와 비밀번호를 입력하여 로그인을 수행해야 하므로, 사용자 경험 측면에서는 부정적인 영향을 초래한다.

그래서 주로 토큰 탈취로 인한 피해를 방어하기 위해, 실제 인증 및 인가에 사용되는 Access Token과, 사용자의 재 로그인 없이 만료된 Access Token을 재발급 하기 위해 사용되는 Refresh Token을 분리하여 운영하는 방법이 있다. Access Token은 유효기간을 짧게 설정하고, Refresh Token은 상대적으로 긴 유효기간을 가지도록 설정한다.
Access Token은 일반적으로 사용자의 웹 브라우저의 Local Storage 또는 Session Storage에 저장되며, Refresh Token은 웹 브라우저의 쿠키 저장소에 보관된다. 쿠키는 기본적으로 모든 요청에 자동으로 포함되어 서버로 전송되나, 최근과 같이 프론트엔드와 백엔드가 분리된 환경에서는 브라우저에서 백엔드 API 서버로의 요청 시 쿠키의 포함 여부를 프론트엔드에서 제어할 수 있다.
이러한 구조에서 Access Token은 인증 및 인가가 필요한 모든 요청마다 서버로 전송되는 반면, Refresh Token은 Access Token재발급이 필요한 경우에만 서버로 전송된다.
따라서 Access Token은 Refresh Token보다 네트워크 상으로 많이 돌아다녀, 탈취 위험이 높아진다. 하지만 생명 주기가 짧기 때문에 일정 수준의 피해 예방이 가능하다. 만약 Access Token의 탈취상황을 정밀하게 통제하고 싶다면 서버 측에서 Access Token에 대한 화이트리스트 또는 블랙리스트를 관리하는 방식도 고려할 수 있다.
Refresh Token은 Access Token에 비해 네트워크상 돌아다닐 일이 적어서 상대적으로 안전하다고 평가되지만, 토큰 탈취 문제에서 자유로운 것은 아니다. 따라서 Refresh Token의 탈취 가능성에 대한 대응 방안도 필요하며, 대표적인 방식으로 Refresh Token Rotate 기법이 있다.

Refresh Token이 Access Token 재발급을 위해서 서버로 전달되면 서버측에서는 새로운 Access Token을 발급해 준다. 이 때 보안 강화를 위해서 기존에 사용자가 전달해 온 Refresh Token도 함께 재발급하고 Set Cookie 헤더를 이용해 사용자 웹브라우저의 쿠키 저장소에 덮어쓴다.
이 과정에서 사용자가 보내온 기존 Refresh Token은 사용자의 웹 브라우저에서는 지워지겠지만, 여전히 유효한 상태이며, 만약 탈취 당했다면 공격자는 이를 통해 Access Token 재발급이 가능해진다. 따라서 서버는 토큰 재발급에 사용된 기존 Refresh Token을 무효화 해야 한다.
이를 위해서 크게 두 가지 방식을 사용할 수 있다. 첫째로 서버에서 기존 Refresh Token을 블랙리스트로 관리하는 방식, 둘째는 새로 발급된 Refresh Token만을 화이트리스트로 등록하고 유효한 것으로 간주하는 방식이 있다.
화이트리스트 방식을 사용하면 특정 사용자가 계정 탈취 정황이 의심되어 신고하거나 서버 측에서 이상 행위를 탐지한 경우, 해당 사용자의 Refresh Token 화이트리스트를 일괄 삭제함으로써 토큰 탈취에 의한 피해를 빠르게 차단할 수 있다.
반면 블랙리스트 방식은 같은 문제가 발생 했을 때, 사용자의 토큰 발급 내역 전체를 추적해, 유효기간이 만료되지 않은 모든 기존 토큰을 일일이 블랙리스트에 등록해야 하기 때문에 관리가 비효율적이라고 판단했다.
또한, 토큰 화이트 리스트를 DB에 저장할 때 이를 평문으로 그대로 저장하면 DB가 털릴 경우 모든 보안 체계가 망가지는 문제가 생길수 있기 때문에 비밀번호를 저장할 때처럼 적절한 단방향 해시 함수를 이용해서 저장했다.
마지막으로 JWT 방식의 로그인을 구현하면서 신경 써야 했던 부분은 JWK 롤링이었다. 우리 프로젝트에서는 JWK로 대칭키를 사용하고 있으며, 별도의 인증 서버 없이 하나의 백엔드 서버에서 토큰 발급과 검증을 모두 담당하고 있다. 따라서 서버 내부에 SecretKey가 저장되어 있고, 이 키는 외부로 절대 공유되지 않는다.
어떤 암호화 알고리즘을 사용하든, 어떤 종류의 키를 사용하든 간에 모든 암호에는 유통기한이 존재한다. 그래서 주기적으로 JWK를 교체해주는 건 필수다. 서버는 여러 개의 Secret Key를 보관할 수 있어야 하고, 사용자의 요청에 포함된 토큰이 발급됐을 당시의 키로 검증을 수행해야 한다. 이 부분은 아직 구현 까지는 하지 않았다.

추가로 JWT를 통한 인증 방식을 구현하면서 왜 일반적으로 Refresh Token의 경우 쿠키에 Access Token의 경우 Local Storage나 Session Storage에 저장되어 Authorization 헤더를 통해 전달 되는지가 궁금했다. 이 부분에 대해서 생각해 보기 전에 몇가지 사전 지식을 정리할 필요가 있었다.
과거에는 쿠키가 클라이언트 측에 정보를 저장할 수 있는 유일한 방법이었다고 한다. 하지만 시간이 지나면서 Local Storage와 Session Storage라는 Web Storage가 등장하면서 쿠키가 아닌 다른 선택지가 생기게 되었다.
쿠키는 저장할 수 있는 데이터의 용량이 매우 작고 매 요청마다 서버로 항상 데이터가 전송되는 반면 Web Storage는 더 많은양의데이터를 저장 가능하고 매 요청마다 서버로 전송되지 않으며 JavaScript로 더 세밀한 조정이 가능한 특징이 있긴한데 지금 로그인 구현 환경에서는 눈에 띄는 차이점은 아닌 것 같다.
나의 경우에는 대용량의 데이터 저장이 필요 하지도 않고 프론트와 백엔드가 분리된 환경에서 API 서버로 가는 요청에 쿠키를 같이 전송할지 말지는 React(또는 JS)에서 서로 다른 도메인간 쿠키를 포함할지 말지를 결정하는 withCredentials 옵션 등을 통해 제어가 가능하니 크게 의미있지는 않아 보인다.

지금 까지의 내용을 정리해 보자면 프론트 개발자의 입장에서는 서버와 주고 받는 JWT Access Token (ATK)와 JWT Refresh Token (RTK)를 어딘가에 저장해두고 API 서버에 전달하도록 만들어야 하는데 선택지가 위의 그림처럼 크게 Web Storage, Cookie가 있는 상황이다.
한국재정정보원
재정활동 디지털 플랫폼‘디브레인(dBrain)’운영 공공기관
www.fis.kr
어떤 토큰들을 어디에 저장할지 선택하기 전에, JWT를 웹 브라우저에 저장할 때 가장 많이 마주하게 되는 XSS, CSRF 공격에 대해서 알아봤다. 우선 XSS (Cross-Site Scripting) 공격에는 크게 3가지 종류가 있다.
어떤 서비스의 게시판 같은 곳에 악성 스크립트를 심은 데이터를 올려두고 사용자가 해당 게시글을 조회했을 때 해당 스크립트가 실행되게 하는 Stored XSS, 별도의 악성 스크립트를 데이터에 올려두지 않고도 악성스크립트가 포함된 URL을 사용자가 클릭하게 한 후 해당 스크립트가 실행되게 하는 Reflected XSS, 서버와의 상호작용 없이도 브라우저 자체에서 악성스크립트를 실행시키는 DOM Based XSS 공격이 존재한다.
XSS공격에 대한 더 자세한 내용은 위의 자료에 정리되어 있다. 중요한 것은 XSS 공격은 공격자가 악성 자바스크립트 코드를 어떻게든 사용자 브라우저에 전달해 실행함으로써, 사용자가 원치 않는 요청을 강제로 실행시킨다는 점이다. 주로 <script> 태그 형태로 삽입된 이 악성 스크립트는 사용자의 웹 브라우저 저장소(localStorage, sessionStorage)나 쿠키에 저장된 JWT 또는 SessionID를 읽어 외부 서버로 전송할 수 있기 때문에, 인증 토큰 탈취에 사용된다.
또다른 보안 위협요소 중 하나인 CSRF(Cross-Site Request Forgery) 공격은 사용자가 이미 우리 서비스에 로그인된 상태에서 공격자가 만든 악성 링크나 폼을 클릭하도록 유도하여, 브라우저가 요청에 자동으로 포함하는 쿠키에 저장된 SessionId나 JWT 토큰을 이용해 공격자가 의도한 요청(예: 금전 이체, 비밀번호 변경 등)을 사용자도 모르는 사이에 수행하는 공격이다.
즉, 악성 사용자가 자신이 수행하고 싶은 요청을 포함한 링크를 피해자에게 전달하고 이 링크를 피해자가 클릭하게 되면 피해자의 웹브라우저 쿠키 저장소에 있는 인증 정보가 자동으로 포함되는 특성을 활용한 공격이다.

위에서 살펴본 Web Storage와 Cookie, 그리고 XSS와 CSRF 공격의 특성을 종합해보면, 각 토큰의 저장 위치를 어떻게 선택할지 결정할 수 있다. 우리 프로젝트에서는 ATK를 Session Storage에 저장하기로 했다. Session Storage는 Local Storage와 달리 브라우저 탭 간에 데이터가 공유되지 않으며, 탭이 종료되면 자동으로 삭제된다는 점에서 보안에 더 유리하다. 이러한 특성 덕분에 민감한 인증 정보를 저장할 때 더 적절한 선택이 될 수 있다.
또한, ATK를 Session Storage에 저장해두고, 인증이나 인가가 필요한 요청마다 HTTP Request의 Authorization 헤더에 직접 담아 전송하면, 쿠키를 사용하지 않고 인증을 처리할 수 있기 때문에 쿠키 기반 인증 정보를 악용하는 CSRF 공격으로부터 어느 정도 자유로울 수 있다.
다만 언제든지 JavaScript를 이용해서 해당 저장소에 접근 가능하기 때문에 XSS 공격에 대한 대비가 필요하다. XSS 공격으로 ATK에 대한 악성 접근을 방지하기 위해서는 사용자의 입력을 검증하고 출력 시점에 < : < 변환 같은 이스케이프 처리가 필요하다.
입력에 대한 검증은 XSS 공격뿐만 아니라 여러 가지 보안 공격을 방어하기 위해 프론트와 백엔드 양쪽에서 모두 반드시 수행해야 하고, 이스케이프 처리는 리액트가 어느 정도 지원해준다. 또, CSP(Content Security Policy) 헤더 설정을 통해 XSS 공격을 막을 수 있는데, 이 부분은 필요하다면 추후 적용해 볼 생각이다.

두 번째로, Refresh Token은 쿠키에 저장하기로 했다. 서버에서 쿠키를 발급할 때는 다양한 속성을 설정할 수 있는데, 대표적으로 HttpOnly와 SameSite 속성이 있다.
HttpOnly 속성은 해당 쿠키를 JavaScript로 접근하지 못하도록 막아준다. 그래서 만약 XSS 공격으로 악성 스크립트가 실행되더라도, HttpOnly가 설정된 RTK에는 접근할 수 없어 XSS로부터 비교적 안전하다.

다만, 쿠키를 통해 RTK를 전송하게 되면 CSRF 공격에 대한 방어가 필요하다. 내가 사용하는 Spring 진영에서는 이를 방어하기 위한 두 가지 방법을 제공하는데, 하나는 CSRF 토큰을 활용한 동기화 토큰 패턴 방식이고, 다른 하나는 쿠키의 SameSite 속성을 활용하는 방식이다. CSRF 토큰 방식은 뒤에서 다루기로 하고, 여기서는 SameSite 속성을 통해 RTK에 대한 CSRF 공격을 이용했다.
우선 RTK의 목적은 ATK와 다르게, 사용자를 인증하거나 인가하는 것이 아니라 토큰 재발급에 있다. 만약 어떤 사용자가 RTK를 이용해 인증 / 인가를 시도하는 것을 방지하기 위해, 토큰의 타입을 검증하는 로직을 추가했다.
하지만 그럼에도 불구하고 RTK를 통한 CSRF 공격 가능성은 존재한다. 예를 들어, 누군가 사용자의 의도와 무관하게 RTK를 이용해 토큰 재발급 요청을 보내는 방식으로 악용할 수 있기 때문이다.

RTK 쿠키에 SameSite 속성을 Strict 또는 Lax로 설정하면 CSRF 공격을 방어할 수 있다. Strict는 요청이 우리 서비스에서 발생한 것이 아니라면 쿠키를 아예 전송하지 않도록 막아주고, Lax는 어느 정도의 범위까지는 허용하지만 기본적으로 GET 메서드가 아닌 요청에는 쿠키를 포함시키지 않는다.
즉, Strict를 사용하면 악성 링크 클릭 시 HTTP 요청에 쿠키가 포함되지 않도록 막을 수 있고, Lax를 사용할 경우에는 돈 송금이나 비밀번호 변경 같은 중요한 동작을 GET 메서드로 처리하지 않도록 설계하면 CSRF 공격을 방어할 수 있다. 다만, 구형 웹 브라우저는 SameSite 속성을 제대로 지원하지 않기 때문에 이를 통해서만 CSRF를 막으려면 사용자 요청에 포함된 브라우저 정보를 기반으로 차단하는 등의 추가적인 조치가 필요하다.

httpSecurity.csrf(AbstractHttpConfigurer::disable); // csrf 명시적 비활성화
Spring Security는 SecurityFilterChain을 구성할 때 명시적으로 CSRF Configurer를 비활성화하지 않으면 위와 같이 CsrfFilter를 생성해서 SecurityFilterChain에 자동으로 등록한다. 이 필터는 Spring Security가 CSRF 공격을 방어하기 위해 공식 문서에서 설명하는 '동기화 토큰 패턴 방식'을 자체적으로 지원하는 필터다.
내 경우에는 ATK는 Authorization Header에 넣어서 사용하고, RTK는 SameSite 속성을 활용해 별도로 CSRF 공격을 방어하고 있었기 때문에 해당 필터가 필요하지 않았다. CsrfFilter는 사용자에게 CSRF 토큰을 발급하고, 이 토큰을 세션이나 쿠키 등에 저장한 뒤 매 요청마다 함께 전달받아 실제 사용자의 요청인지 확인하는 방식으로 동작한다. 그런데 이 기능을 사용하지 않으면서 필터가 활성화되어 있으면 예상치 못한 문제가 발생할 수 있기 때문에 명시적으로 비활성화했다.
<인가처리>
이제 JWT를 통한 인증은 마무리할 수 있었다. 하지만 로그인에서 가장 중요한 부분 중 하나인 인가에 대해서는 아직 전혀 다루지 않았다. 인증의 목적은 단순히 사용자의 신원을 파악하는 것에서 끝나는 게 아니라, 해당 사용자에게 적절한 권한을 부여하는 것까지 포함된다.
JWT 기반 인증을 처리하게 되면, Payload에 담긴 Claim들을 이용해 현재 사용자가 누구인지 식별할 수 있고, 추가로 Payload에 인가 관련 정보(권한 등)를 포함시킴으로써, 인증 이후 적절한 권한 부여까지 가능하게 된다.

Spring Security의 인가 과정을 알기 위해서 위의 5가지의 필터를 주목해서 볼 필요가 있었다.

사용자의 요청이 발생하면, 먼저 SecurityContextHolderFilter를 통과하게 된다. 이 필터를 지나면서 SecurityContext에 접근할 수 있는 Supplier가 하나 생성된다.
Spring Security의 최신 버전에서는 성능개선을 위해서 SecurityContext를 지연 로딩하는 방식을 사용한다. 즉, 실제로 SecurityContext가 필요한 시점까지는 이를 즉시 불러오지 않고, 실제로 접근할 때 로드된다. 성능쪽에 대한 고민을 잠시 내려놓고 생각하면 그냥 SecurityContextHolderFilter를 지나가면서 SecurityContext가 최초로 생성된다고 알고 있어도 될 것 같다.

만약 사용자의 요청에 포함된 Authorization 헤더에 유효한 JWT Access Token이 있다면, JWT 인증 필터에서 해당 토큰을 검증하게 된다. 토큰이 유효하다고 판단되면, 토큰내의 정보를 바탕으로 해당 사용자의 인증 정보를 담은 객체가 생성되며, 이 인증 객체는 SecurityContext에 세팅된다. 이 때 인증 객체 내부 에는 사용자의 권한 정보도 함께 포함된다.

이 과정에서 SecurityContext는 Supplier를 통해 로드되며, 처음에는 비어 있는 상태로 생성된다. 그 후, JWT 인증 필터에서 생성한 인증 객체가 이 SecurityContext에 저장된다. 이 부분은 성능때문에 조금 복잡하게 되어있는 데, 그냥 쉽게 생각해서 SecuirtyContextHolderFilter에서 생성된 SecurityContext안에 JwtAuthenticationFilter에서 인증 객체를 세팅했구나 정도로 생각하면 될 것 같다.

그 후 AnonymousAuthenticationFilter를 지나게 되는데 이 필터는 현재 SecurityContext 안에 인증 객체가 존재하는지를 확인한다. 이미 앞선 JWT 인증 필터에서 인증 객체를 세팅해둔 상태라면, AnonymousAuthenticationFilter는 이미 인증처리가 완료되었구나 라고 판단 후, 별도의 처리를 하지 않고 그대로 다음 필터로 요청을 넘긴다.

반면, 요청에 JWT가 포함되어 있지 않은 비 회원 사용자라면 AnonymousAuthenticationFilter는 SecurityContext 내부를 확인했을 때 인증 객체가 존재하지 않기 때문에, 이를 null로 인식하게 된다.

이 경우 사용자를 인증되지 않은 익명 사용자로 간주하고, 익명 인증 객체를 생성해 SecurityContext에 저장한다.

이때 특별한 커스텀 설정을 하지 않았다면, 익명 인증 객체의 principal에는 기본값으로 "anonymousUser"라는 문자열이 들어가고, authorities에는 ROLE_ANONYMOUS라는 권한이 부여된다. 이는 Spring Security가 기본적으로 제공하는 익명 사용자 설정이다. 필요하다면 설정을 통해 principal을 "GUEST" 같은 다른 값으로 변경하거나, 권한을 별도로 지정하는 것도 가능하다.
지금까지의 내용을 정리해보면, Spring Security의 인가 과정을 담당하는 인가 필터가 동작하기에 앞서 먼저 SecurityContext가 생성된다. 이후 JWT 인증 필터에서 토큰 검증에 성공한 사용자라면, 해당 사용자의 인증 객체가 SecurityContext에 저장된다.
반면, JWT를 포함하지 않은 요청이라면, AnonymousAuthenticationFilter가 익명 사용자용 인증 객체를 생성해 SecurityContext에 세팅한다. 이렇게 해서 모든 요청은 SecurityContext에 각자의 인증 객체를 가진 상태로 인가 필터로 들어가게 된다.

AuthorizationFilter는 Spring Security에서 인가를 담당하는 필터이다. 해당 필터의 동작 과정에 대해서 알아보기 전에 나의 API 중 어떤 요청에 어떤 인가가 필요한지 설정해 줘야 한다.
Spring Security에서는 크게 Request URI를 기반으로 한 인가 제어와 메서드 레벨에서의 AOP를 통한 인가 제어를 지원하는데, 나는 요청 기반 인가를 선택했다. 요청 기반의 권한 검사를 위해서 나의 SecurityFilterChain을 구성할 때 HttpSecurity의 authorizeHttpRequests() API를 활용할 수 있다.
httpSecurity.authorizeHttpRequests(auth -> auth
.requestMatchers("/health", "/").permitAll()
.requestMatchers("/permit/all/test").permitAll()
.requestMatchers("/auth/test/admin").hasRole("ADMIN")
.requestMatchers("/error/**","/favicon.ico" ).permitAll()
.requestMatchers("/auth/reissue/token").permitAll()
.anyRequest().authenticated())
해당 API의 자세한 내용은 필요할 때 검색해 보면 될 것 같고, 간단한 예시를 보는 게 정리가 빠를 것 같다. 위와 같이 특정 Endpoint에 대해서 원하는 권한을 부여할 수 있다.
permitAll()을 부여하게 된다면 누구든지 해당 자원에 접근할 수 있게 된다. authenticated()를 사용한다면 익명 사용자가 아닌, 인증에 성공한 모든 사용자가 자원에 접근할 수 있다.
hasRole("ADMIN") 같은 경우 사용자의 인증 객체에 저장된 권한이 ROLE_ADMIN인 경우에만 해당 자원에 접근이 가능하다는 것을 의미한다.
인가 설정을 할 때 주의해야 할 점이 몇 가지 있었다. 우선, 모든 자원을 permitAll() 설정을 주고 보호가 필요한 자원만 인증/인가를 설정해 줄 것인지, 아니면 모든 자원을 authenticated()로 잠궈두고 보호가 필요하지 않은 자원만 permitAll()로 열어줘야 할지 고민이었는데 보통 소프트웨어 보안 개발 지침 가이드 같은 것을 살펴보면 일단 모든 자원을 보호 처리하고 꼭 필요한 자원만 permitAll()로 열어두는 게 맞다고 한다. 반대로 진행할 경우 예상하지 못한 보안 취약점이 생길 수 있다.
두 번째로 주의할 점은, Spring Security의 해당 API를 사용하다 보면 hasRole()과 hasAuthority()가 있어서 사용 중 헷갈릴 수 있는데, hasRole()의 경우 내가 입력한 문자열에 ROLE_이라는 접두사를 붙여서 처리한다. hasAuthority()는 그렇지 않다.
예를 들어 인증 객체에 권한이 ROLE_ADMIN인 회원만 접근할 수 있게 하려면 hasRole("ADMIN")이나 hasAuthority("ROLE_ADMIN")을 설정하면 된다. hasRole()이 조금 더 직관적인 것 같다.
httpSecurity.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/**").permitAll()
.requestMatchers("/api/security").authenticated()
...
마지막으로, 해당 인가 설정은 위에서부터 아래로 순차적으로 읽어 내려가기 때문에 위와 같이 처리할 경우 /api/security에 대한 인가 작업은 적용되지 않는다. 이는 앞선 /api/**에 대한 permitAll()이 먼저 적용되기 때문이다. 이런 동작 방식은 뒤에서 인가 필터의 작동 과정을 살펴보면 더 자세히 알 수 있다.
@Bean
public RoleHierarchy roleHierarchy() {
return fromHierarchy("ROLE_ADMIN > ROLE_USER\n" +
"ROLE_USER > ROLE_ANONYMOUS");
}
아직 프로젝트 초창기이고 이렇다 할 인가를 적용할 만한 API가 없었다. 인가 테스트를 하기 위해서 위와 같이 권한 간 계층 구조를 적용했다.
이는 필수 적용 사항은 아니지만, 만약 내 프로젝트의 권한에 계층이 있다면 가독성을 위해서 적용시키는 게 좋다. 예를 들어 Gold, Silver, Bronze라는 권한이 있다고 했을 때 Silver 회원부터 접근 가능한 EndPoint에는 hasRole("Gold", "Silver")와 같이 처리를 해 줘야 하는데, Gold > Silver > Bronze 계층을 적용하면 hasRole("Silver")만 작성해도 그 위의 권한인 Gold 유저는 통과가 된다.
즉, 권한마다 별도로 인가를 처리하면 중복 코드가 많아질 수 있는데, 계층 구조를 정의해 두면 상위 권한이 하위 권한을 자연스럽게 포함하므로 처리하기가 훨씬 편하다.

이제 인가 처리를 위한 사전 작업은 끝났고, 실제로 Spring Security의 AuthorizationFilter에서 어떤 일이 발생하는지 살펴보기로 했다.우선 Spring Security에서는 인증 객체를 위한 인터페이스인 Authentication은 제공하는데, Authorization 같은 인가 전용 인터페이스를 제공하지 않는다.
대신 인증 객체 인터페이스인 Authentication 에는 getAuthorities()라는 메서드가 포함되어 있다. 즉, Authentication을 구현한 어떤 인증 객체라도 getAuthorities()를 통해 해당 인증 객체 내부에 포함된 사용자의 여러 권한들을 조회할 수 있게 되어 있다. 여기서 사용자의 각 권한 객체들은 어떻게 구현되든 GrantedAuthority 인터페이스를 구현해야 한다.
GrantedAuthority는 권한 객체를 표현하는 데 사용되며, 이 인터페이스를 통해 Spring Security는 사용자의 권한을 관리하고 인가를 처리한다. GrantedAuthority 인터페이스에 대한 기본 구현체인 SimpleGrantedAuthority를 사용하면 권한 객체를 쉽게 생성할 수 있다. 이는 Spring Security에서 권한을 표현할 때 가장 자주 사용되는 클래스이며, 문자열로 표현된 권한을 GrantedAuthority 타입으로 쉽게 변환해 준다.
즉, JWT를 통해서 사용자 인증에 성공한 후 JWT에 포함된 ROLE_USER 같은 문자열로 된 권한을 꺼내서 SimpleGrantedAuthority에 넣어서 정상적인 권한 객체로 변환 시킬 수 있다. 이를 인증 객체에 넣어 주고 이를 다시 Security Context에 넣어주면 된다. 조금 더 정확히는 한 사용자는 여러가지의 ROLE을 가질 수 있기 때문에 권한들의 컬렉션이 인증 객체에 저장된다.

만약 JWT 내에 ROLE_ADMIN이라는 권한 정보를 포함한 사용자가 ADMIN 사용자만 접근 가능한 엔드포인트로 요청을 보내왔다고 하면, 앞서 살펴본 것처럼 인증 객체가 null인 상태로 SecurityContext가 생성된다. 그 후 요청이 JWT 인증 필터를 지나간다.

내 경우, JWT 인증 필터에서 인증에 성공한 사용자의 인증 객체에는 ROLE_ADMIN이라는 권한 정보를 가진 SimpleGrantedAuthority가 포함된다.

이제 인가 처리를 담당하는 AuthorizationFilter에서 나의 인증 객체를 바탕으로 인가 처리를 진행한다. 이전에 봤던 인증 필터에서 인증 매니저에게 인증 작업을 위임하는 것처럼, 인가 필터에서도 인가 매니저에게 인가 작업을 위임한다.
이 인가 매니저 내부에서는 우리가 이전에 설정한 .authenticated(), .hasRole() 등의 인가 조건에 따라 이를 처리하는 여러 종류의 구체적인 인가 매니저들이 존재한다. 중요한 점은, 특정 요청에 대한 인가 처리가 성공하면 해당 필터를 무사히 지나가게 되고, 만약 인가 처리에 실패하면 예외가 던져진다는 점이다.

특정 엔드포인트에 대해 인가 처리가 무사히 통과된 경우는 더 이상 인증이나 인가에 대해 신경 쓸 필요가 없다. 하지만 위와 같이 인가에 실패하는 경우에 대한 처리는 중요하다.

위와 같은 경우 인가처리에 실패하고 인가 필터에서 AuthorizationDeniedException을 던지게 된다.

이 때, AuthorizationFilter에서 발생한 예외를 처리하는 부분이 ExceptionTranslationFilter이고

위와 같이 AuthorizationDeniedException을 해당 필터에서 잡아서 처리하게 된다. 지금 시나리오 처럼 ROLE_USER인 사용자가 ADMIN만 접근 할 수 있는 권한에 접근한다면 인가 예외가 발생한다.

이런 인가 예외의 경우 AccessDeniedHandler를 통해서 처리되는데 나의 경우 API 서버를 만들고 있기 때문에 해당 예외가 발생하면 403 에러를 클라이언트에게 반환하도록 커스터마이징 했다.

하지만, 만약 JWT가 없는, 즉 비회원 사용자가 인증이나 인가가 필요한 엔드포인트로 요청을 보내오는 경우는 다르게 처리된다. 인가는 반드시 인증이 된 이후에 처리되어야 하기 때문에 .authenticated()를 설정했든 .hasRole()을 설정했든 모두 인증이 먼저 필요한 엔드포인트라고 생각하면 된다.

이런 경우, Access DeniedHandler로 가지 못하고 다른 예외 처리 로직을 타게된다.

이를 따라가 보면 AuthenticationEntryPoint에서 처리 되는 것을 알 수 있다. 따라서 이 부분도 따로 커스터마이징을 진행했다.

위와 같이 인가 필터에서 발생할 수 있는 예외 상황들을 핸들링 했다.
<참고 자료>
스프링 시큐리티 OAuth2| 정수원 - 인프런 강의
현재 평점 4.9점 수강생 2,385명인 강의를 만나보세요. 스프링 시큐리티 OAuth2의 기본 개념부터 API 사용법과 내부 아키텍처를 학습합니다. 아울러 OAuth2 Client, OAuth2 Resource Server, Authorization Server를 통
www.inflearn.com
OAuth 2.0 Client :: Spring Security
The HttpSecurity.oauth2Client() DSL provides a number of configuration options for customizing the core components used by OAuth 2.0 Client. In addition, HttpSecurity.oauth2Client().authorizationCodeGrant() enables the customization of the Authorization Co
docs.spring.io
HTTP 쿠키 - HTTP | MDN
HTTP 요청을 수신할 때, 서버는 응답과 함께 Set-Cookie 헤더를 전송할 수 있습니다. 쿠키는 보통 브라우저에 의해 저장되며, 그 후 쿠키는 같은 서버에 의해 만들어진 요청(Request)들의 Cookie HTTP 헤더
developer.mozilla.org
Web Storage API - Web APIs | MDN
The two mechanisms within Web Storage are as follows: sessionStorage is partitioned by browser tabs and by origin. The main document, and all embedded browsing contexts (iframes), are grouped by their origin and each origin has access to its own separate s
developer.mozilla.org
Cross Site Request Forgery (CSRF) :: Spring Security
When should you use CSRF protection? Our recommendation is to use CSRF protection for any request that could be processed by a browser by normal users. If you are creating a service that is used only by non-browser clients, you likely want to disable CSRF
docs.spring.io
'토이 프로젝트 > 깃허브 연동 로그인' 카테고리의 다른 글
| 깃허브 연동 로그인 [02] - Spring Security를 활용한 로그인 구현 (0) | 2025.10.01 |
|---|---|
| 깃허브 연동 로그인 [01] - 로그인 구현 전 고민해 본 것들 (0) | 2025.09.30 |