스프링 시큐리티 JWT
스프링 시큐리티 JWT - 7. 로그인 필터 구현
https://youtu.be/3Ff7UHGG3t8?si=Gi38uWMopxPykRPm
스프링 시큐리티 JWT - 7. 로그인 필터 구현
- 스프링 시큐리티 JWT - 7. 로그인 필터 구현
- Spring Security JWT 로그인 구현 1: LoginFilter, AuthenticationManager와 SecurityFilterChain 동작 원리
- 개념
- Servlet Filter란?
- DelegatingFilterProxy란?
- SecurityFilterChain이란?
- 왜 커스텀 LoginFilter가 필요할까?
- 주요 특징
- attemptAuthentication()이란?
- UsernamePasswordAuthenticationToken이란?
- AuthenticationManager란?
- AuthenticationProvider란?
- 예제
- LoginFilter 생성
- attemptAuthentication() 구현
- 인증 요청 객체 생성
- AuthenticationManager에 인증 요청 전달
- AuthenticationManager 주입하기
- successfulAuthentication()이란?
- unsuccessfulAuthentication()이란?
- LoginFilter 전체 코드
- SecurityConfig에 LoginFilter 등록하기
- AuthenticationManager Bean 등록하기
- SecurityConfig 전체 코드
- 현재까지 구현한 기능
- 로그인 요청 테스트하기
- curl로 로그인 요청 보내기
- LoginFilter가 실제로 실행되는지 확인하기
- 테스트 코드로 LoginFilter 검증하기
- 구조
- Stateless 환경에서는 로그인 성공 후 어떻게 될까?
- 실무에서의 활용
- 1. LoginFilter가 모든 요청을 인증하는 것은 아니다
- 2. 로그인 필터와 JWT 검증 필터를 구분한다
- 3. AuthenticationManager는 비밀번호 검증 로직 그 자체가 아니다
- 4. 인증 전 Authentication에 권한을 임의로 부여하지 않는다
- 5. 로그인 요청 데이터를 로그에 남길 때 주의한다
- 6. 로그인 API에는 Rate Limiting을 고려한다
- 7. 인증 실패 메시지는 일관되게 관리한다
- 8. 로그인 성공과 JWT 발급 성공을 구분한다
- 9. JWT 생성에는 검증된 Authentication을 사용한다
- 10. 로그인 요청에는 HTTPS를 사용한다
- 11. 커스텀 필터의 등록 위치를 검증한다
- 12. Spring Boot Filter Bean의 중복 등록을 주의한다
- 13. AuthenticationManager가 하나만 존재한다고 가정하지 않는다
- 14. JWT를 사용한다고 모든 요청에서 사용자 정보를 조회해야 하는 것은 아니다
- 15. 로그인 실패와 서버 내부 오류를 구분한다
- 16. 인증 필터 테스트와 실제 로그인 통합 테스트를 구분한다
- 정리
Spring Security JWT 로그인 구현 1: LoginFilter, AuthenticationManager와 SecurityFilterChain 동작 원리
Spring Security를 이용한 JWT 인증 시스템에서 회원가입 기능을 구현했다면, 다음으로 필요한 것은 로그인 기능이다.
회원가입에서는 사용자의 아이디와 비밀번호를 입력받아 MySQL에 저장했다. 이때 비밀번호는 BCrypt를 이용해 해시한 뒤 저장하고, 사용자에게 기본 권한을 부여했다.
이제 사용자가 로그인을 요청하면 데이터베이스에 저장된 회원 정보와 전달받은 아이디·비밀번호를 비교해야 한다.
인증에 성공하면 JWT를 발급하고, 클라이언트는 이후 API 요청마다 해당 JWT를 전달하여 인증을 수행할 수 있다.
전체적인 흐름은 다음과 같다.
flowchart TD
A["Client"] -->|"POST /login"| B["LoginFilter"]
B --> C["UsernamePasswordAuthenticationToken 생성"]
C --> D["AuthenticationManager"]
D --> E["AuthenticationProvider"]
E --> F["UserDetailsService"]
F --> G["UserRepository"]
G --> H[("MySQL")]
H --> G
G --> F
F --> E
E --> I{"인증 성공?"}
I -->|"Yes"| J["successfulAuthentication"]
I -->|"No"| K["unsuccessfulAuthentication"]
J --> L["JWT 발급"]
K --> M["HTTP 401 Unauthorized"]
L --> N["Client에 JWT 반환"]
로그인 요청은 단순히 Controller가 아이디와 비밀번호를 전달받는 구조로도 구현할 수 있다.
하지만 Spring Security에서는 로그인 요청을 처리하기 위한 인증 필터와 인증 관리 체계를 제공한다.
대표적인 구성요소는 다음과 같다.
| 구성요소 | 역할 |
|---|---|
UsernamePasswordAuthenticationFilter | 아이디와 비밀번호를 이용한 인증 요청 처리 |
UsernamePasswordAuthenticationToken | 인증에 필요한 정보를 담는 객체 |
AuthenticationManager | 인증 요청을 처리하는 인터페이스 |
AuthenticationProvider | 실제 인증 방식 구현 |
UserDetailsService | 사용자 정보 조회 |
PasswordEncoder | 입력한 비밀번호와 저장된 해시 비교 |
successfulAuthentication() | 인증 성공 이후 처리 |
unsuccessfulAuthentication() | 인증 실패 이후 처리 |
이 중 이번에 집중할 부분은 LoginFilter와 AuthenticationManager를 연결하는 과정이다.
사용자가 전달한 아이디와 비밀번호를 Spring Security의 인증 체계로 전달하고, 인증 성공과 실패에 따라 서로 다른 처리를 수행할 수 있도록 구성한다.
개념
Spring Security에서 로그인은 어떻게 처리될까?
Spring Security는 Servlet Filter를 기반으로 동작한다.
사용자가 HTTP 요청을 보내면 요청은 곧바로 Spring MVC의 Controller에 전달되는 것이 아니다.
먼저 Servlet Container에 등록된 여러 Filter를 통과한다.
Spring Security는 이 과정에 보안 필터 체인을 추가하여 인증과 인가를 수행한다.
예를 들어 사용자가 다음 요청을 전송한다고 생각해보자.
POST /login HTTP/1.1
Host: localhost:8080
Content-Type: application/x-www-form-urlencoded
username=user01&password=password123%21
Spring Security에서 로그인 필터가 해당 요청을 처리하도록 구성되어 있다면 다음 과정이 실행된다.
POST /login
↓
Spring Security Filter Chain
↓
UsernamePasswordAuthenticationFilter
↓
username 추출
↓
password 추출
↓
UsernamePasswordAuthenticationToken 생성
↓
AuthenticationManager.authenticate()
↓
인증 성공 또는 실패
여기서 중요한 점은 로그인 요청을 처리하는 주체가 반드시 Controller일 필요는 없다는 사실이다.
Spring Security의 인증 필터 자체가 특정 로그인 URL을 처리할 수 있다.
따라서 /login에 대응하는 별도의 @PostMapping 메서드가 없어도 로그인 필터가 요청을 가로채 인증을 수행하도록 구성할 수 있다.
Servlet Filter란?
Spring Security의 내부 구조를 이해하려면 먼저 Servlet Filter를 알아야 한다.
Servlet Filter는 HTTP 요청이 Servlet에 도달하기 전이나 응답이 클라이언트로 전달되기 전에 특정한 작업을 수행할 수 있는 구성요소다.
예를 들어 다음과 같은 작업에 활용할 수 있다.
- 요청 및 응답 로깅
- 인증과 인가
- 요청 헤더 검사
- 인코딩 처리
- 공통 보안 정책 적용
- 요청 데이터 검증
Spring Boot에서 Spring MVC를 사용한다면 일반적으로 내장 Tomcat과 같은 Servlet Container가 HTTP 요청을 처리한다.
요청은 여러 Servlet Filter를 통과한 뒤 DispatcherServlet으로 전달된다.
flowchart TD
A["Client"] --> B["Tomcat"]
B --> C["Servlet Filter 1"]
C --> D["Servlet Filter 2"]
D --> E["Servlet Filter 3"]
E --> F["DispatcherServlet"]
F --> G["Controller"]
G --> H["Service"]
H --> I["Repository"]
Filter는 요청을 다음 단계로 전달할 수도 있고, 특정 조건에서 요청 처리를 중단할 수도 있다.
예를 들어 인증되지 않은 사용자가 관리자 API에 접근하면 Filter 단계에서 접근을 거부할 수 있다.
이 경우 Controller의 메서드가 실행되지 않는다.
Servlet Filter와 Spring MVC Interceptor의 차이
Filter와 Interceptor는 모두 요청 처리 과정에 관여하지만 위치와 역할이 다르다.
| 구분 | Servlet Filter | Spring MVC Interceptor |
|---|---|---|
| 동작 위치 | Servlet 앞단 | DispatcherServlet 내부의 Handler 처리 과정 |
| 기반 기술 | Servlet API | Spring MVC |
| 주요 인터페이스 | Filter | HandlerInterceptor |
| 적용 대상 | Servlet 요청 처리 | Spring MVC Handler |
| 대표적인 사용 | 보안, 인코딩, 로깅 | Controller 전후 공통 처리 |
Spring Security가 Servlet Filter 기반으로 동작하는 이유 중 하나는 Controller에 요청이 도달하기 전에 인증과 인가를 수행할 수 있기 때문이다.
DelegatingFilterProxy란?
Spring Security는 Servlet Filter를 사용하지만, 인증과 인가에 필요한 구성요소는 Spring 컨테이너에서 관리한다.
여기서 두 실행 환경을 연결해야 한다.
첫 번째는 Servlet Container다.
두 번째는 Spring ApplicationContext다.
Servlet Container는 Servlet Filter의 실행을 관리한다.
반면 Spring Security의 보안 필터 체인은 Spring Bean을 통해 구성된다.
이 두 환경을 연결하기 위해 사용하는 대표적인 구성요소가 DelegatingFilterProxy다.
flowchart TD
A["Client"] --> B["Servlet Container"]
subgraph ServletFilterChain["Servlet Filter Chain"]
C["기타 Servlet Filter"]
D["DelegatingFilterProxy"]
end
B --> C
C --> D
subgraph SpringContext["Spring ApplicationContext"]
E["springSecurityFilterChain"]
F["FilterChainProxy"]
G["SecurityFilterChain"]
end
D --> E
E --> F
F --> G
G --> H["DispatcherServlet"]
H --> I["Controller"]
DelegatingFilterProxy는 Servlet Filter로 등록되어 있으면서 실제 처리를 Spring Bean에 위임하는 역할을 한다.
Spring Security에서는 일반적으로 springSecurityFilterChain이라는 이름의 Bean을 통해 보안 필터 체인에 접근한다.
이 Bean의 대표적인 구현체가 FilterChainProxy다.
이 구조는 Spring Security 공식 아키텍처에서도 설명하는 핵심 구성요소다.
FilterChainProxy란?
FilterChainProxy는 Spring Security의 여러 SecurityFilterChain을 관리하는 구성요소다.
예를 들어 애플리케이션에 다음과 같은 요청 경로가 있다고 생각해보자.
/api/**
/admin/**
/login
서비스 요구사항에 따라 서로 다른 SecurityFilterChain을 구성할 수 있다.
FilterChainProxy
├── SecurityFilterChain A
├── SecurityFilterChain B
└── SecurityFilterChain C
일반적인 단일 보안 설정에서는 하나의 SecurityFilterChain만 사용하기도 한다.
반면 관리 API와 일반 API의 인증 방식이 다르다면 여러 필터 체인을 사용하는 구조도 가능하다.
중요한 것은 Servlet Filter Chain과 Spring Security의 SecurityFilterChain이 서로 다른 계층에 존재한다는 사실이다.
Servlet Filter Chain은 Servlet Container가 관리한다.
SecurityFilterChain은 Spring Security가 관리하는 보안 필터의 집합이다.
SecurityFilterChain이란?
SecurityFilterChain은 인증, 인가, 보안 정책 등을 처리하기 위한 Spring Security Filter의 집합이다.
Spring Security에는 다양한 보안 필터가 존재한다.
대표적으로 다음과 같은 필터가 있다.
| Filter | 역할 |
|---|---|
SecurityContextHolderFilter | 현재 요청의 보안 컨텍스트 관리 |
CsrfFilter | CSRF 보호 |
LogoutFilter | 로그아웃 처리 |
UsernamePasswordAuthenticationFilter | 사용자 이름·비밀번호 기반 로그인 |
BasicAuthenticationFilter | HTTP Basic 인증 |
AnonymousAuthenticationFilter | 익명 인증 객체 제공 |
ExceptionTranslationFilter | 인증·인가 예외 처리 연결 |
AuthorizationFilter | 요청에 대한 접근 권한 판단 |
실제 활성화되는 필터의 종류와 순서는 보안 설정에 따라 달라진다.
예를 들어 CSRF를 비활성화하면 관련 필터가 제거될 수 있다.
Form Login을 비활성화하면 기본 Form Login에 필요한 인증 필터가 등록되지 않는다.
따라서 모든 Spring Security 애플리케이션이 동일한 필터 목록을 가진다고 가정해서는 안 된다.
Filter 순서가 중요한 이유
보안 필터는 순서에 따라 동작한다.
예를 들어 다음 상황을 생각해보자.
인가 검사
↓
JWT 인증
이 순서는 일반적인 JWT 인증 시스템에 적절하지 않다.
사용자를 인증하기 전에 접근 권한부터 검사하기 때문이다.
일반적으로는 다음 흐름이 필요하다.
JWT 인증
↓
Authentication 구성
↓
SecurityContext 설정
↓
인가 검사
즉 인증 정보를 구성하는 필터가 인가 판단보다 먼저 실행되어야 한다.
Spring Security는 이러한 순서 관계를 고려해 보안 필터를 배치한다.
공식 문서에서도 인증 필터와 인가 필터의 실행 순서가 보안 처리에서 중요하다고 설명한다.
왜 커스텀 LoginFilter가 필요할까?
기존 프로젝트에서는 다음 설정을 적용했다.
.formLogin(AbstractHttpConfigurer::disable)
이 설정은 Spring Security가 제공하는 기본 Form Login 기능을 비활성화한다.
일반적인 Form Login에서는 UsernamePasswordAuthenticationFilter가 로그인 요청을 처리한다.
하지만 기본 Form Login을 비활성화했기 때문에 해당 필터를 통한 기본 로그인 기능도 사용하지 않는다.
이번 프로젝트에서는 로그인 성공 시 JWT를 발급하는 인증 흐름을 직접 구성하려고 한다.
따라서 로그인 요청을 처리할 필터를 별도로 등록한다.
기본 Form Login
↓
비활성화
↓
기본 로그인 필터 사용 안 함
↓
커스텀 LoginFilter 생성
↓
SecurityFilterChain에 등록
다만 JWT 로그인을 구현할 때 반드시 UsernamePasswordAuthenticationFilter를 상속해야 하는 것은 아니다.
로그인 Controller에서 AuthenticationManager를 직접 호출하는 방식도 가능하다.
또한 별도의 AuthenticationFilter와 AuthenticationConverter를 이용하는 구성도 가능하다.
이번에는 Spring Security의 기존 아이디·비밀번호 인증 구조를 활용하기 위해 UsernamePasswordAuthenticationFilter를 상속하는 방식을 사용한다.
주요 특징
UsernamePasswordAuthenticationFilter란?
UsernamePasswordAuthenticationFilter는 사용자 이름과 비밀번호를 이용한 인증 요청을 처리하는 필터다.
Spring Security에서 Form Login을 사용할 때 대표적으로 활용되는 클래스다.
기본적으로 다음 요청을 처리하도록 구성되어 있다.
POST /login
그리고 다음 이름의 요청 파라미터를 사용한다.
username
password
이 필터는 요청에서 두 값을 추출하여 인증 요청 객체를 생성하고 AuthenticationManager에 전달한다.
대표적인 메서드는 다음과 같다.
| 메서드 | 역할 |
|---|---|
attemptAuthentication() | 인증 시도 |
obtainUsername() | username 추출 |
obtainPassword() | password 추출 |
getAuthenticationManager() | 연결된 AuthenticationManager 조회 |
successfulAuthentication() | 인증 성공 이후 처리 |
unsuccessfulAuthentication() | 인증 실패 이후 처리 |
setFilterProcessesUrl() | 로그인 요청 경로 지정 |
이번 구현에서는 해당 필터를 상속하여 로그인 요청을 직접 처리한다.
attemptAuthentication()이란?
attemptAuthentication()은 인증을 시도할 때 실행되는 메서드다.
메서드의 기본 형태는 다음과 같다.
@Override
public Authentication attemptAuthentication(
HttpServletRequest request,
HttpServletResponse response
) throws AuthenticationException {
// 인증 요청 처리
}
각 구성요소의 의미를 살펴보자.
| 구성요소 | 역할 |
|---|---|
| HttpServletRequest | 클라이언트가 전달한 HTTP 요청 |
| HttpServletResponse | HTTP 응답 객체 |
| Authentication | 인증 요청 또는 인증 결과를 표현하는 인터페이스 |
| AuthenticationException | Spring Security의 인증 관련 예외 |
이 메서드에서는 일반적으로 다음 작업을 수행한다.
HTTP 요청
↓
username 추출
↓
password 추출
↓
Authentication 객체 생성
↓
AuthenticationManager.authenticate()
↓
인증 결과 반환
여기서 중요한 점은 attemptAuthentication() 자체가 MySQL에 직접 접근할 필요는 없다는 사실이다.
실제 사용자 조회와 비밀번호 검증은 AuthenticationManager가 위임하는 인증 구성요소에서 수행할 수 있다.
UsernamePasswordAuthenticationToken이란?
UsernamePasswordAuthenticationToken은 사용자 이름과 비밀번호 기반의 인증 요청 및 인증 결과를 표현할 수 있는 객체다.
이름에 Token이 포함되어 있지만 JWT와는 다른 개념이다.
두 가지를 구분해야 한다.
| 구분 | UsernamePasswordAuthenticationToken | JWT |
|---|---|---|
| 소속 | Spring Security | JSON Web Token 표준 |
| 주된 역할 | 인증 요청 및 인증 결과 표현 | 인증 정보 등을 전달하는 토큰 형식 |
| 포함 정보 | Principal, Credentials, Authorities 등 | Header, Payload, Signature |
| 사용 위치 | Spring Security 내부 인증 처리 | 클라이언트와 서버 사이의 토큰 전달 |
예를 들어 다음과 같은 로그인 요청이 들어왔다고 생각해보자.
username = user01
password = password123!
이 데이터를 UsernamePasswordAuthenticationToken에 담을 수 있다.
UsernamePasswordAuthenticationToken authenticationToken =
UsernamePasswordAuthenticationToken.unauthenticated(
username,
password
);
이 객체는 아직 인증되지 않은 상태다.
따라서 다음 값이 false다.
authenticationToken.isAuthenticated();
이는 정상적인 동작이다.
사용자가 직접 전달한 아이디와 비밀번호만으로 인증에 성공했다고 판단해서는 안 되기 때문이다.
인증 전후의 Authentication 차이
| 구분 | 인증 전 | 인증 후 |
|---|---|---|
| Principal | 일반적으로 username | UserDetails 등 인증된 사용자 |
| Credentials | 입력한 비밀번호 | 보안 정책에 따라 제거 가능 |
| Authorities | 아직 신뢰할 수 있는 권한 없음 | 검증된 사용자 권한 |
| isAuthenticated | false | true |
인증 요청 객체를 생성할 때는 일반적으로 unauthenticated()를 사용한다.
UsernamePasswordAuthenticationToken.unauthenticated(
username,
password
);
반대로 인증이 완료되지 않았는데 임의로 ROLE_ADMIN 권한을 넣고 인증된 객체로 만들어서는 안 된다.
Spring Security 6.2.1에서도 인증되지 않은 요청과 인증된 결과를 구분하기 위한 생성 방식이 제공된다.
AuthenticationManager란?
AuthenticationManager는 Spring Security에서 인증 처리를 위한 핵심 인터페이스다.
대표적인 메서드는 다음과 같다.
Authentication authenticate(
Authentication authentication
) throws AuthenticationException;
이 메서드는 인증 요청 객체를 전달받는다.
인증에 성공하면 인증 결과를 반환한다.
인증에 실패하면 AuthenticationException 계열 예외가 발생한다.
하지만 AuthenticationManager 자체가 항상 직접 데이터베이스를 조회하는 것은 아니다.
일반적으로 구현체인 ProviderManager가 실제 인증 처리를 담당하는 AuthenticationProvider에 요청을 위임한다.
flowchart TD
A["LoginFilter"] --> B["AuthenticationManager"]
B --> C["ProviderManager"]
C --> D["AuthenticationProvider"]
D --> E["DaoAuthenticationProvider"]
E --> F["UserDetailsService"]
F --> G["UserRepository"]
G --> H[("MySQL")]
E --> I["PasswordEncoder"]
대표적인 인증 처리 흐름은 다음과 같다.
LoginFilter
↓
AuthenticationManager.authenticate()
↓
ProviderManager
↓
DaoAuthenticationProvider
↓
UserDetailsService
↓
UserRepository
↓
MySQL
↓
PasswordEncoder 검증
↓
인증 결과 반환
ProviderManager는 여러 AuthenticationProvider를 관리할 수 있다.
따라서 시스템에 따라 사용자 이름·비밀번호 인증, LDAP 인증, 기타 인증 방식 등을 조합할 수 있다.
이러한 역할 분리는 Spring Security 인증 아키텍처의 중요한 특징이다.
AuthenticationProvider란?
AuthenticationProvider는 실제 인증 방식을 구현하는 인터페이스다.
예를 들어 아이디와 비밀번호 기반 인증에서는 DaoAuthenticationProvider를 사용할 수 있다.
DaoAuthenticationProvider는 다음 구성요소를 이용한다.
UserDetailsService
↓
사용자 정보 조회
PasswordEncoder
↓
비밀번호 검증
사용자가 입력한 비밀번호가 저장된 해시와 일치하고 계정 상태가 인증을 허용한다면 인증 성공 결과를 반환할 수 있다.
반대로 사용자 정보가 없거나 비밀번호가 일치하지 않으면 인증에 실패한다.
AuthenticationManager는 인증 요청을 전달하고 관리하며, AuthenticationProvider가 해당 인증 방식에 맞는 실제 검증을 수행한다.
이번 구현에서는 LoginFilter에서 AuthenticationManager를 호출하는 부분까지 구성한다.
사용자 데이터베이스 조회를 담당할 UserDetailsService와 실제 비밀번호 검증 연결은 이후 단계에서 추가한다.
예제
1. 프로젝트 구조
기존에 구현한 회원가입 프로젝트를 확장한다.
현재 프로젝트에는 다음 구성요소가 존재한다.
src/main/java/com/example/jwt
│
├── JwtApplication.java
│
├── config
│ └── SecurityConfig.java
│
├── controller
│ ├── MainController.java
│ ├── AdminController.java
│ └── JoinController.java
│
├── dto
│ └── JoinDTO.java
│
├── service
│ └── JoinService.java
│
├── entity
│ └── UserEntity.java
│
└── repository
└── UserRepository.java
이번에는 JWT 관련 인증 처리 클래스를 모아둘 패키지를 추가한다.
com.example.jwt.jwt
그리고 해당 패키지 안에 LoginFilter를 생성한다.
src/main/java/com/example/jwt/jwt/LoginFilter.java
변경된 구조는 다음과 같다.
src/main/java/com/example/jwt
│
├── JwtApplication.java
│
├── config
│ └── SecurityConfig.java
│
├── controller
│ ├── MainController.java
│ ├── AdminController.java
│ └── JoinController.java
│
├── dto
│ └── JoinDTO.java
│
├── service
│ └── JoinService.java
│
├── entity
│ └── UserEntity.java
│
├── repository
│ └── UserRepository.java
│
└── jwt
└── LoginFilter.java
이제 LoginFilter를 작성한다.
LoginFilter 생성
UsernamePasswordAuthenticationFilter 상속
먼저 다음과 같이 클래스를 작성한다.
package com.example.jwt.jwt;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
public class LoginFilter
extends UsernamePasswordAuthenticationFilter {
}
UsernamePasswordAuthenticationFilter를 상속하면 기존 Spring Security의 로그인 처리 기능을 활용할 수 있다.
대표적으로 다음 기능을 사용할 수 있다.
- 로그인 URL에 해당하는 요청 판별
- 사용자 이름과 비밀번호 파라미터 추출
- AuthenticationManager 연결
- 인증 성공·실패 처리
- 인증 요청 객체 생성에 필요한 공통 기능
이제 실제 인증 시도를 담당하는 메서드를 재정의한다.
attemptAuthentication() 구현
다음 코드를 작성한다.
@Override
public Authentication attemptAuthentication(
HttpServletRequest request,
HttpServletResponse response
) throws AuthenticationException {
String username = obtainUsername(request);
String password = obtainPassword(request);
UsernamePasswordAuthenticationToken authenticationToken =
UsernamePasswordAuthenticationToken.unauthenticated(
username,
password
);
return getAuthenticationManager().authenticate(
authenticationToken
);
}
이 코드를 실행하면 다음 과정이 수행된다.
POST /login
↓
obtainUsername(request)
↓
username 획득
↓
obtainPassword(request)
↓
password 획득
↓
UsernamePasswordAuthenticationToken 생성
↓
AuthenticationManager.authenticate()
각 메서드를 자세히 살펴보자.
obtainUsername()
String username = obtainUsername(request);
obtainUsername()은 요청에서 사용자 이름을 추출한다.
기본적으로 다음 파라미터를 사용한다.
username
이는 다음 코드와 비슷한 역할을 한다.
String username = request.getParameter("username");
다만 UsernamePasswordAuthenticationFilter가 제공하는 메서드를 사용하면 파라미터 이름을 변경하는 설정도 활용할 수 있다.
obtainPassword()
String password = obtainPassword(request);
obtainPassword()는 요청에서 비밀번호를 추출한다.
기본 파라미터 이름은 다음과 같다.
password
개념적으로 다음 코드와 유사하다.
String password = request.getParameter("password");
여기서 주의해야 할 점은 이 메서드가 일반적인 JSON 요청 본문을 자동으로 파싱하는 것은 아니라는 사실이다.
이번 구성은 application/x-www-form-urlencoded 기반의 로그인 요청을 전제로 한다.
JSON 로그인을 구현하려면 별도의 요청 본문 파싱 로직이나 AuthenticationConverter 등이 필요할 수 있다.
인증 요청 객체 생성
사용자 이름과 비밀번호를 추출했다면 인증 요청 객체를 생성한다.
UsernamePasswordAuthenticationToken authenticationToken =
UsernamePasswordAuthenticationToken.unauthenticated(
username,
password
);
이때 객체에 들어가는 정보는 다음과 같다.
Principal
↓
username
Credentials
↓
password
Authorities
↓
아직 검증된 권한 없음
여기서 Authorities가 비어 있다는 사실을 잘 이해해야 한다.
로그인 요청을 처리하는 단계에서는 사용자가 일반 사용자인지 관리자인지 아직 검증하지 않았다.
따라서 신뢰할 수 있는 권한을 직접 설정해서는 안 된다.
권한 정보는 사용자 인증에 성공한 이후 신뢰할 수 있는 사용자 저장소 등을 통해 확인해야 한다.
AuthenticationManager에 인증 요청 전달
이제 생성한 객체를 AuthenticationManager에 전달한다.
return getAuthenticationManager().authenticate(
authenticationToken
);
여기서 getAuthenticationManager()는 로그인 필터에 설정된 AuthenticationManager를 반환한다.
이후 authenticate()가 실제 인증 처리를 시작한다.
인증에 성공하면 Authentication 객체가 반환된다.
인증에 실패하면 AuthenticationException 계열 예외가 발생한다.
이때 attemptAuthentication()에서 예외를 모두 직접 처리해야 하는 것은 아니다.
UsernamePasswordAuthenticationFilter의 상위 클래스인 AbstractAuthenticationProcessingFilter가 인증 결과에 따라 성공 및 실패 처리 흐름을 실행한다.
flowchart TD
A["attemptAuthentication 실행"] --> B["AuthenticationManager.authenticate"]
B --> C{"인증 결과"}
C -->|"Success"| D["Authentication 반환"]
C -->|"Failure"| E["AuthenticationException"]
D --> F["successfulAuthentication"]
E --> G["unsuccessfulAuthentication"]
따라서 인증 시도와 인증 결과 처리는 서로 다른 단계로 구분할 수 있다.
AuthenticationManager 주입하기
LoginFilter가 AuthenticationManager를 사용하려면 먼저 해당 객체를 전달받아야 한다.
Spring Security 6.2.1에서는 다음과 같이 생성자에서 AuthenticationManager를 설정할 수 있다.
public LoginFilter(
AuthenticationManager authenticationManager
) {
super();
setAuthenticationManager(authenticationManager);
}
상속받은 필터에 AuthenticationManager를 설정하는 것이다.
이렇게 하면 이후 다음 코드에서 해당 객체를 사용할 수 있다.
getAuthenticationManager().authenticate(authenticationToken);
여기서 다음 구분이 중요하다.
AuthenticationManager는 인증을 처리하기 위한 인터페이스다.
AuthenticationConfiguration은 Spring Security의 인증 구성을 관리하는 설정 객체다.
둘은 동일한 클래스가 아니다.
successfulAuthentication()이란?
successfulAuthentication()은 인증에 성공했을 때 호출되는 메서드다.
메서드의 형태는 다음과 같다.
@Override
protected void successfulAuthentication(
HttpServletRequest request,
HttpServletResponse response,
FilterChain chain,
Authentication authentication
) throws IOException, ServletException {
super.successfulAuthentication(
request,
response,
chain,
authentication
);
}
이 메서드는 인증이 완료된 사용자 정보를 전달받는다.
대표적으로 다음 데이터를 확인할 수 있다.
authentication.getName();
authentication.getAuthorities();
authentication.isAuthenticated();
JWT 기반 로그인에서는 인증에 성공한 사용자의 정보를 이용해 JWT를 발급할 수 있다.
Authentication 성공
↓
successfulAuthentication()
↓
사용자 식별자 확인
↓
사용자 권한 확인
↓
JWT 생성
↓
클라이언트 응답
다만 현재는 JWT 생성 기능을 아직 구현하지 않은 상태다.
따라서 임시 성공 처리를 통해 인증 성공과 JWT 발급 완료를 혼동하지 않도록 구성한다.
unsuccessfulAuthentication()이란?
unsuccessfulAuthentication()은 인증에 실패했을 때 호출되는 메서드다.
메서드의 형태는 다음과 같다.
@Override
protected void unsuccessfulAuthentication(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException failed
) throws IOException, ServletException {
super.unsuccessfulAuthentication(
request,
response,
failed
);
}
인증에 실패하는 상황은 다양하다.
예를 들어 다음과 같다.
| 상황 | 설명 |
|---|---|
| 존재하지 않는 사용자 | 사용자 조회 실패 |
| 비밀번호 불일치 | PasswordEncoder 검증 실패 |
| 비활성화된 계정 | 계정 상태 검증 실패 |
| 잠긴 계정 | 계정 잠금 정책 적용 |
| 인증 처리 구성 오류 | 적절한 AuthenticationProvider 없음 |
REST API에서는 일반적으로 사용자 이름이나 비밀번호가 올바르지 않은 경우 HTTP 401 Unauthorized를 반환한다.
HTTP/1.1 401 Unauthorized
여기서 중요한 점은 인증 실패 원인을 그대로 클라이언트에게 상세하게 노출하지 않는 것이다.
예를 들어 다음과 같이 구체적으로 구분해 반환하면 계정 존재 여부가 드러날 수 있다.
사용자가 존재하지 않습니다.
비밀번호가 잘못되었습니다.
서비스 정책에 따라 외부 응답은 공통 메시지로 처리하고, 내부에서는 필요한 수준의 장애 정보를 관리할 수 있다.
LoginFilter 전체 코드
지금까지 작성한 내용을 하나의 클래스로 정리해보자.
이번 단계에서는 다음 기능을 구현한다.
- POST
/login요청 처리 - 사용자 이름과 비밀번호 추출
- 인증되지 않은 Authentication 객체 생성
- AuthenticationManager 호출
- 인증 성공과 실패 처리
- JWT 발급 기능이 아직 구현되지 않았음을 명시적으로 표현
다음은 LoginFilter.java 전체 코드다.
package com.example.jwt.jwt;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationServiceException;
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 java.io.IOException;
public class LoginFilter
extends UsernamePasswordAuthenticationFilter {
public LoginFilter(
AuthenticationManager authenticationManager
) {
super();
// 인증을 수행할 AuthenticationManager 설정
setAuthenticationManager(authenticationManager);
// 로그인 요청 경로 지정
setFilterProcessesUrl("/login");
// 로그인 성공 시 임시 처리
setAuthenticationSuccessHandler(
(request, response, authentication) -> {
// 아직 JWT 발급 기능이 구현되지 않았음을 알림
response.sendError(
HttpServletResponse.SC_NOT_IMPLEMENTED,
"JWT_ISSUANCE_NOT_IMPLEMENTED"
);
}
);
// 로그인 실패 시 HTTP 401 반환
setAuthenticationFailureHandler(
(request, response, exception) -> {
response.sendError(
HttpServletResponse.SC_UNAUTHORIZED,
"LOGIN_FAILED"
);
}
);
}
@Override
public Authentication attemptAuthentication(
HttpServletRequest request,
HttpServletResponse response
) throws AuthenticationException {
// POST 요청만 처리
if (!"POST".equals(request.getMethod())) {
throw new AuthenticationServiceException(
"Unsupported authentication method"
);
}
// 1. username 추출
String username = obtainUsername(request);
// 2. password 추출
String password = obtainPassword(request);
// 3. null 값 처리
username = username == null ? "" : username.trim();
password = password == null ? "" : password;
// 4. 인증되지 않은 요청 객체 생성
UsernamePasswordAuthenticationToken authenticationToken =
UsernamePasswordAuthenticationToken.unauthenticated(
username,
password
);
// 5. HTTP 요청 관련 부가 정보 설정
setDetails(request, authenticationToken);
// 6. AuthenticationManager로 인증 요청 전달
return getAuthenticationManager().authenticate(
authenticationToken
);
}
@Override
protected void successfulAuthentication(
HttpServletRequest request,
HttpServletResponse response,
FilterChain chain,
Authentication authentication
) throws IOException, ServletException {
super.successfulAuthentication(
request,
response,
chain,
authentication
);
}
@Override
protected void unsuccessfulAuthentication(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException failed
) throws IOException, ServletException {
super.unsuccessfulAuthentication(
request,
response,
failed
);
}
}
현재 코드에서는 인증 성공 시 HTTP 501을 반환하도록 설정했다.
JWT_ISSUANCE_NOT_IMPLEMENTED
이는 인증 성공과 JWT 발급 완료를 혼동하지 않기 위한 임시 응답이다.
실제 JWT 발급 기능을 추가하면 성공 처리 부분에서 토큰을 생성하고 반환하도록 변경해야 한다.
반면 인증 실패 시에는 HTTP 401을 반환한다.
LOGIN_FAILED
여기서 super.successfulAuthentication()과 super.unsuccessfulAuthentication()을 호출하는 이유는 Spring Security의 기본적인 인증 성공·실패 처리 흐름을 유지하면서 등록된 Handler를 사용하기 위해서다.
실제 JWT 발급 기능을 구현할 때는 이러한 Handler 또는 메서드 재정의를 통해 필요한 처리를 연결할 수 있다.
SecurityConfig에 LoginFilter 등록하기
LoginFilter 클래스를 만들었다고 자동으로 모든 HTTP 요청에 적용되는 것은 아니다.
Spring Security의 SecurityFilterChain에 해당 필터를 등록해야 한다.
Spring Security에서는 다음 메서드를 제공한다.
addFilterBefore()
addFilterAfter()
addFilterAt()
각 메서드의 역할은 다음과 같다.
| 메서드 | 역할 |
|---|---|
addFilterBefore() | 지정한 필터보다 앞에 추가 |
addFilterAfter() | 지정한 필터보다 뒤에 추가 |
addFilterAt() | 지정한 필터의 순서 위치에 추가 |
이번에는 기존 UsernamePasswordAuthenticationFilter가 사용하던 위치에 LoginFilter를 배치한다.
http.addFilterAt(
loginFilter,
UsernamePasswordAuthenticationFilter.class
);
이렇게 하면 LoginFilter가 사용자 이름·비밀번호 인증 필터에 해당하는 순서에서 실행되도록 구성할 수 있다.
다만 addFilterAt()을 기존 필터 객체를 반드시 찾아 삭제하는 메서드로 이해해서는 안 된다.
지정한 필터의 순서 위치에 새로운 필터를 추가하는 방식이므로 같은 위치에 중복 필터가 등록되지 않도록 주의해야 한다.
현재는 기본 Form Login을 비활성화했으므로 기본 로그인 필터와 충돌하지 않도록 구성할 수 있다.
AuthenticationManager Bean 등록하기
LoginFilter 생성자에서는 AuthenticationManager를 전달받는다.
public LoginFilter(
AuthenticationManager authenticationManager
)
따라서 Spring Security 설정에서 AuthenticationManager를 사용할 수 있도록 구성해야 한다.
Spring Security 6에서는 AuthenticationConfiguration을 통해 AuthenticationManager를 가져오는 방식을 사용할 수 있다.
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration authenticationConfiguration
) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
코드를 살펴보자.
AuthenticationConfiguration
AuthenticationConfiguration authenticationConfiguration
Spring Security의 인증 관련 구성을 관리하는 객체다.
다음 메서드를 제공한다.
authenticationConfiguration.getAuthenticationManager();
이를 통해 구성된 AuthenticationManager를 가져올 수 있다.
이후 LoginFilter에 전달한다.
LoginFilter loginFilter =
new LoginFilter(authenticationManager);
이렇게 하면 LoginFilter가 AuthenticationManager를 이용해 인증을 시도할 수 있다.
단, 매우 중요한 사항이 있다.
AuthenticationManager Bean을 등록했다고 해서 MySQL 기반 사용자 인증이 자동으로 완성되는 것은 아니다.
실제 아이디와 비밀번호를 검증하려면 적절한 AuthenticationProvider와 UserDetailsService 등의 구성이 필요하다.
이 구성요소가 없다면 인증이 실패하거나, 인증 설정 상태에 따라 애플리케이션 초기화 과정에서 문제가 발생할 수 있다.
따라서 현재 단계에서는 AuthenticationManager를 연결하는 구조를 이해하는 것이 목적이다.
SecurityConfig 전체 코드
이제 기존 SecurityConfig에 LoginFilter와 AuthenticationManager를 추가한다.
다음은 Spring Security 6.2.1 기준의 전체 코드다.
package com.example.jwt.config;
import com.example.jwt.jwt.LoginFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
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.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration authenticationConfiguration
) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public SecurityFilterChain filterChain(
HttpSecurity http,
AuthenticationManager authenticationManager
) throws Exception {
// 커스텀 로그인 필터 생성
LoginFilter loginFilter =
new LoginFilter(authenticationManager);
http
// CSRF 비활성화
.csrf(AbstractHttpConfigurer::disable)
// 기본 Form Login 비활성화
.formLogin(AbstractHttpConfigurer::disable)
// HTTP Basic 비활성화
.httpBasic(AbstractHttpConfigurer::disable)
// Stateless 인증 정책
.sessionManagement(session -> session
.sessionCreationPolicy(
SessionCreationPolicy.STATELESS
)
)
// URL별 접근 권한
.authorizeHttpRequests(auth -> auth
// 로그인 및 회원가입 허용
.requestMatchers(
"/login",
"/join"
).permitAll()
// 관리자 API
.requestMatchers(
"/admin",
"/admin/**"
).hasRole("ADMIN")
// 나머지 API는 인증 필요
.anyRequest().authenticated()
)
// 커스텀 로그인 필터 등록
.addFilterAt(
loginFilter,
UsernamePasswordAuthenticationFilter.class
);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
이 코드에서 새롭게 추가한 부분은 크게 두 가지다.
첫 번째는 AuthenticationManager Bean이다.
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration authenticationConfiguration
) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
두 번째는 LoginFilter 등록이다.
.addFilterAt(
loginFilter,
UsernamePasswordAuthenticationFilter.class
)
이제 Spring Security는 /login 요청이 들어왔을 때 LoginFilter를 통해 인증을 시도할 수 있다.
다만 실제 데이터베이스 인증을 완료하려면 아직 구현해야 할 부분이 남아 있다.
현재까지 구현한 기능
여기서 현재 구현한 기능과 아직 구현하지 않은 기능을 명확하게 구분해야 한다.
| 기능 | 현재 상태 |
|---|---|
| 회원가입 | 구현 |
| 사용자 정보 MySQL 저장 | 구현 |
| 비밀번호 BCrypt 해시 | 구현 |
| SecurityFilterChain | 구현 |
| LoginFilter 생성 | 구현 |
| POST /login 요청 처리 | 구현 |
| username 추출 | 구현 |
| password 추출 | 구현 |
| UsernamePasswordAuthenticationToken 생성 | 구현 |
| AuthenticationManager 연결 | 구성 |
| UserDetailsService | 미구현 |
| MySQL 사용자 조회를 통한 로그인 인증 | 미구현 |
| 로그인 성공 시 JWT 발급 | 미구현 |
| 요청별 JWT 검증 | 미구현 |
즉 현재 단계의 핵심은 다음과 같다.
POST /login
↓
LoginFilter
↓
username / password 추출
↓
UsernamePasswordAuthenticationToken
↓
AuthenticationManager
이후에는 다음 구조가 연결되어야 한다.
AuthenticationManager
↓
AuthenticationProvider
↓
UserDetailsService
↓
UserRepository
↓
MySQL
↓
비밀번호 검증
그리고 인증에 성공하면 JWT를 발급하는 기능을 구현해야 한다.
로그인 요청 테스트하기
현재는 데이터베이스 인증 구현이 완성되지 않았으므로 로그인 성공까지 확인하는 단계는 아니다.
먼저 로그인 요청이 LoginFilter에 정상적으로 전달되는지 확인해보자.
1. 애플리케이션 실행
다음 명령을 실행한다.
./gradlew bootRun
이 명령은 Spring Boot 애플리케이션을 실행한다.
정상적으로 실행되었다면 다음과 유사한 로그를 확인할 수 있다.
Tomcat started on port 8080 (http)
Started JwtApplication
다만 아직 AuthenticationProvider나 UserDetailsService가 구성되지 않았다면 인증 구성 상태에 따라 초기화 단계에서 문제가 발생할 수 있다.
이 경우 단순히 LoginFilter가 잘못되었다고 판단하기보다 AuthenticationManager에 실제 인증 처리 구성이 존재하는지 확인해야 한다.
2. Postman 요청 구성
Postman에서 다음 요청을 생성한다.
POST http://localhost:8080/login
Body는 x-www-form-urlencoded로 설정한다.
다음 데이터를 입력한다.
| Key | Value |
|---|---|
| username | user01 |
| password | password123! |
요청을 전송하면 LoginFilter에서 다음 코드가 실행되어야 한다.
String username = obtainUsername(request);
String password = obtainPassword(request);
이후 인증 요청 객체가 생성된다.
UsernamePasswordAuthenticationToken authenticationToken =
UsernamePasswordAuthenticationToken.unauthenticated(
username,
password
);
그리고 AuthenticationManager에 전달된다.
return getAuthenticationManager().authenticate(
authenticationToken
);
현재는 실제 회원 정보를 조회하고 검증하는 구성요소가 완성되지 않았으므로 인증이 실패할 수 있다.
하지만 중요한 것은 로그인 요청이 LoginFilter까지 도달하고, 인증 요청 객체가 생성되어 AuthenticationManager로 전달되는지 확인하는 것이다.
curl로 로그인 요청 보내기
Postman을 사용하지 않더라도 curl을 이용해 확인할 수 있다.
다음 명령을 실행한다.
curl -i -X POST \
http://localhost:8080/login \
--data-urlencode "username=user01" \
--data-urlencode "password=password123!"
각 옵션의 의미는 다음과 같다.
| 옵션 | 역할 |
|---|---|
-i | 응답 헤더 출력 |
-X POST | POST 방식 요청 |
--data-urlencode | 요청 파라미터 URL 인코딩 |
--data-urlencode를 사용하면 특수문자가 포함된 비밀번호도 안전하게 요청 형식에 맞춰 전달할 수 있다.
단, 이 방법이 비밀번호를 암호화하는 것은 아니다.
HTTP 요청 구간에서 비밀번호를 안전하게 보호하려면 HTTPS가 필요하다.
예상되는 인증 실패 응답
AuthenticationManager가 인증 실패를 AuthenticationException으로 전달한다면 현재 설정한 Failure Handler가 실행된다.
이때 다음과 같은 응답을 확인할 수 있다.
HTTP/1.1 401 Unauthorized
LOGIN_FAILED
다만 실제 응답 본문의 표현 방식은 Servlet Container와 오류 처리 설정에 따라 달라질 수 있다.
또한 인증 구성 자체가 올바르지 않아 초기화에 실패했다면 HTTP 요청을 처리하기 전에 애플리케이션이 종료될 수 있다.
따라서 현재 단계에서는 HTTP 상태 코드만으로 구현 성공 여부를 판단해서는 안 된다.
LoginFilter가 실제로 실행되는지 확인하기
가장 간단한 방법은 디버거를 이용하는 것이다.
다음 메서드에 Breakpoint를 설정한다.
@Override
public Authentication attemptAuthentication(
HttpServletRequest request,
HttpServletResponse response
) throws AuthenticationException {
그다음 POST /login 요청을 전송한다.
Breakpoint에서 다음 값을 확인한다.
request.getMethod();
예상 결과:
POST
다음 값을 확인한다.
obtainUsername(request);
예상 결과:
user01
다음 값을 확인한다.
obtainPassword(request);
예상 결과:
password123!
마지막으로 Authentication 객체의 상태를 확인한다.
authenticationToken.isAuthenticated();
예상 결과:
false
이 값이 false인 이유는 아직 사용자 인증이 완료되지 않았기 때문이다.
여기서 AuthenticationManager.authenticate() 호출까지 도달했다면 로그인 요청을 인증 관리 체계로 전달하는 구조가 동작하고 있음을 확인할 수 있다.
비밀번호는 디버깅 과정에서도 불필요하게 출력하거나 공유하지 않는 것이 좋다.
테스트 코드로 LoginFilter 검증하기
실제 데이터베이스 인증 로직을 구현하기 전에도 LoginFilter의 핵심 동작을 검증할 수 있다.
이번에는 Mockito를 이용해 AuthenticationManager를 대체한다.
검증할 내용은 다음과 같다.
- LoginFilter가 username을 추출하는가?
- LoginFilter가 password를 추출하는가?
- Authentication 객체가 인증되지 않은 상태로 생성되는가?
- AuthenticationManager의
authenticate()가 호출되는가?
LoginFilterTest 전체 코드
package com.example.jwt.jwt;
import jakarta.servlet.http.HttpServletRequest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.Authentication;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class LoginFilterTest {
@Mock
private AuthenticationManager authenticationManager;
@Test
void 로그인_요청의_아이디와_비밀번호를_인증매니저에_전달한다() {
// given
LoginFilter loginFilter =
new LoginFilter(authenticationManager);
MockHttpServletRequest request =
new MockHttpServletRequest(
"POST",
"/login"
);
request.addParameter(
"username",
"user01"
);
request.addParameter(
"password",
"password123!"
);
MockHttpServletResponse response =
new MockHttpServletResponse();
when(
authenticationManager.authenticate(
any(Authentication.class)
)
).thenThrow(
new BadCredentialsException(
"Invalid credentials"
)
);
// when
assertThrows(
BadCredentialsException.class,
() -> loginFilter.attemptAuthentication(
request,
response
)
);
// then
ArgumentCaptor<Authentication> captor =
ArgumentCaptor.forClass(
Authentication.class
);
verify(authenticationManager).authenticate(
captor.capture()
);
Authentication authentication =
captor.getValue();
assertEquals(
"user01",
authentication.getPrincipal()
);
assertEquals(
"password123!",
authentication.getCredentials()
);
assertFalse(
authentication.isAuthenticated()
);
}
}
이 테스트는 실제 MySQL을 사용하지 않는다.
또한 JWT를 생성하지 않는다.
LoginFilter가 요청 데이터를 올바르게 추출하고 AuthenticationManager에 전달하는지만 확인한다.
테스트 실행
다음 명령을 실행한다.
./gradlew test
테스트가 통과하면 다음과 유사한 결과를 확인할 수 있다.
BUILD SUCCESSFUL
이 결과가 의미하는 것은 다음과 같다.
Mock HTTP Request
↓
LoginFilter.attemptAuthentication()
↓
UsernamePasswordAuthenticationToken 생성
↓
AuthenticationManager.authenticate() 호출
↓
Mockito 검증 성공
즉 아직 데이터베이스 인증이 구현되지 않았더라도 로그인 필터의 입력 처리와 인증 요청 전달 기능은 독립적으로 검증할 수 있다.
구조
로그인 요청이 처리되는 전체 흐름
지금까지 구현한 내용을 하나의 구조로 정리해보자.
sequenceDiagram
participant Client
participant Servlet as Servlet Container
participant Proxy as DelegatingFilterProxy
participant Chain as FilterChainProxy
participant Filter as LoginFilter
participant Manager as AuthenticationManager
participant Provider as AuthenticationProvider
Client->>Servlet: POST /login
Servlet->>Proxy: 요청 전달
Proxy->>Chain: Spring Security 처리 위임
Chain->>Filter: 로그인 요청 전달
Filter->>Filter: username 추출
Filter->>Filter: password 추출
Filter->>Filter: UsernamePasswordAuthenticationToken 생성
Filter->>Manager: authenticate(authenticationToken)
Manager->>Provider: 인증 요청 위임
alt 인증 성공
Provider-->>Manager: 인증 결과
Manager-->>Filter: Authentication
Filter->>Filter: successfulAuthentication()
Filter-->>Client: 임시 성공 후처리 응답
else 인증 실패
Provider-->>Manager: AuthenticationException
Manager-->>Filter: AuthenticationException
Filter->>Filter: unsuccessfulAuthentication()
Filter-->>Client: HTTP 401
end
이번 단계에서는 필터와 AuthenticationManager를 연결한다.
이후 데이터베이스 인증을 구성하면 다음 부분이 연결된다.
AuthenticationProvider
↓
UserDetailsService
↓
UserRepository
↓
MySQL
그리고 JWT 생성 기능을 구현하면 인증 성공 후 다음 흐름까지 완성할 수 있다.
successfulAuthentication()
↓
인증된 사용자 정보 확인
↓
JWT 생성
↓
HTTP 응답에 JWT 포함
Stateless 환경에서는 로그인 성공 후 어떻게 될까?
기존 프로젝트의 SecurityConfig에는 다음 설정이 존재한다.
.sessionManagement(session -> session
.sessionCreationPolicy(
SessionCreationPolicy.STATELESS
)
)
이 설정은 Spring Security가 인증 상태를 유지하기 위해 HttpSession을 생성하거나 사용하지 않도록 구성한다.
여기서 중요한 점은 LoginFilter를 통해 인증이 성공하더라도 서버가 해당 사용자의 로그인 상태를 이후 요청까지 자동으로 보존하지 않는다는 것이다.
예를 들어 다음과 같은 구조다.
POST /login
↓
아이디와 비밀번호 검증
↓
Authentication 성공
↓
JWT 발급
↓
응답 종료
이후 클라이언트가 다른 API를 호출한다.
GET /admin
Authorization: Bearer <JWT>
이 요청을 처리하려면 JWT를 검증하는 별도의 인증 구성이 필요하다.
GET /admin
↓
JWT 추출
↓
JWT 검증
↓
Authentication 구성
↓
SecurityContext 설정
↓
ROLE_ADMIN 확인
↓
AdminController
즉 다음 두 필터는 서로 다른 역할을 수행할 수 있다.
| 구성요소 | 역할 |
|---|---|
| LoginFilter | 아이디와 비밀번호를 검증하여 로그인 처리 |
| JWT 인증 필터 | 이미 발급된 JWT를 검증하여 요청 인증 |
LoginFilter는 로그인 API를 처리한다.
JWT 인증 필터는 로그인 이후 보호된 API에 접근할 때 인증 정보를 구성한다.
로그인 필터에서 Authentication이 생성되었다고 해서 다른 HTTP 요청에서도 자동으로 인증된 상태가 유지되는 것은 아니다.
Stateless 환경에서는 이러한 차이를 반드시 이해해야 한다.
실무에서의 활용
1. LoginFilter가 모든 요청을 인증하는 것은 아니다
LoginFilter가 SecurityFilterChain에 등록되어 있더라도 모든 API 요청에서 사용자 이름과 비밀번호를 추출하는 것은 아니다.
현재 LoginFilter는 다음 URL을 처리하도록 구성했다.
setFilterProcessesUrl("/login");
따라서 일반적으로 다음 요청이 인증 처리 대상이다.
POST /login
반면 다음 요청은 LoginFilter의 로그인 인증 처리 대상이 아니다.
GET /admin
GET /
이러한 요청은 별도의 인증 방식과 인가 정책에 따라 처리되어야 한다.
2. 로그인 필터와 JWT 검증 필터를 구분한다
로그인 필터는 사용자 이름과 비밀번호를 처리한다.
JWT 검증 필터는 클라이언트가 전달한 JWT를 처리한다.
두 기능을 하나의 필터에 모두 구현할 수도 있지만 책임이 복잡해질 수 있다.
따라서 다음처럼 구분하는 구조를 고려할 수 있다.
LoginFilter
↓
로그인 요청
↓
아이디·비밀번호 인증
↓
JWT 발급
JwtAuthenticationFilter
↓
일반 API 요청
↓
JWT 검증
↓
SecurityContext 구성
이렇게 분리하면 로그인 인증 방식과 토큰 검증 정책을 독립적으로 관리하기 쉽다.
3. AuthenticationManager는 비밀번호 검증 로직 그 자체가 아니다
AuthenticationManager는 인증 요청을 처리하는 인터페이스다.
일반적으로 ProviderManager가 AuthenticationProvider에 검증을 위임한다.
따라서 AuthenticationManager를 주입받았다는 사실만으로 데이터베이스 인증이 완성되었다고 판단하면 안 된다.
실제 사용자 조회와 PasswordEncoder 설정이 올바르게 연결되어야 한다.
4. 인증 전 Authentication에 권한을 임의로 부여하지 않는다
다음과 같은 값은 사용자가 입력한 데이터다.
username
password
이 정보만으로 사용자가 관리자인지 판단할 수 없다.
따라서 인증 요청 단계에서는 다음과 같이 인증되지 않은 Token을 생성해야 한다.
UsernamePasswordAuthenticationToken.unauthenticated(
username,
password
);
실제 권한은 인증 성공 이후 신뢰할 수 있는 사용자 정보에서 가져와야 한다.
5. 로그인 요청 데이터를 로그에 남길 때 주의한다
로그인 구현 중에는 다음과 같이 비밀번호를 출력하고 싶을 수 있다.
System.out.println(password);
하지만 실제 서비스에서는 피해야 한다.
비밀번호가 애플리케이션 로그나 로그 수집 시스템에 저장될 수 있기 때문이다.
필터가 정상적으로 실행되는지 확인하려면 사용자 이름 자체를 출력하기보다 요청 처리 여부를 기록하는 방법을 사용할 수 있다.
log.debug("Login authentication attempted");
사용자 식별 정보가 필요한 경우에도 최소한의 정보만 기록하고 보안 정책을 따라야 한다.
6. 로그인 API에는 Rate Limiting을 고려한다
로그인 API는 인증되지 않은 사용자가 접근할 수 있다.
따라서 공격자가 반복적으로 아이디와 비밀번호를 대입할 수 있다.
POST /login
↓
반복적인 비밀번호 추측
↓
AuthenticationManager
↓
인증 실패 반복
이를 방지하기 위해 다음 정책을 고려할 수 있다.
- IP별 요청 빈도 제한
- 계정별 로그인 실패 횟수 관리
- 비정상 로그인 탐지
- 반복 공격에 대한 지연 또는 차단
- 계정 잠금 정책
다만 과도한 계정 잠금 정책은 공격자가 정상 사용자의 계정을 의도적으로 잠그는 데 악용할 수도 있으므로 신중하게 설계해야 한다.
7. 인증 실패 메시지는 일관되게 관리한다
사용자가 존재하지 않는 경우와 비밀번호가 잘못된 경우를 서로 다른 메시지로 반환하면 계정 존재 여부가 노출될 수 있다.
따라서 일반적인 로그인 실패에서는 동일한 응답을 사용하는 방식을 고려할 수 있다.
HTTP/1.1 401 Unauthorized
LOGIN_FAILED
서버 내부에서는 보안상 필요한 수준의 원인을 구분하되, 클라이언트에 불필요한 내부 정보를 노출하지 않아야 한다.
8. 로그인 성공과 JWT 발급 성공을 구분한다
아이디와 비밀번호 검증에 성공했다고 해서 JWT 발급까지 완료된 것은 아니다.
예를 들어 다음과 같은 상황이 발생할 수 있다.
사용자 인증 성공
↓
JWT 생성 시도
↓
서명 키 설정 오류
↓
JWT 발급 실패
따라서 로그인 API의 최종 성공 응답은 인증 검증뿐 아니라 JWT 발급까지 정상적으로 끝난 이후 반환해야 한다.
현재 구현에서 성공 후처리를 임시 응답으로 구성한 이유도 이 차이를 명확하게 표현하기 위해서다.
9. JWT 생성에는 검증된 Authentication을 사용한다
JWT에 사용자 권한을 포함하려면 해당 정보가 신뢰할 수 있는 출처에서 검증되어야 한다.
예를 들어 다음과 같은 요청을 받았다고 생각해보자.
username = user01
password = password123!
role = ROLE_ADMIN
클라이언트가 전달한 role 값을 그대로 JWT에 포함하면 안 된다.
사용자 인증에 성공한 이후 서버가 확인한 권한을 이용해야 한다.
AuthenticationManager
↓
인증 성공
↓
검증된 Authentication
↓
Authorities 확인
↓
JWT Claim 생성
이것은 사용자 권한 상승을 방지하기 위한 중요한 설계 원칙이다.
10. 로그인 요청에는 HTTPS를 사용한다
아이디와 비밀번호는 민감한 인증 정보다.
따라서 반드시 HTTPS를 통해 전달해야 한다.
Form Data 방식이라고 해서 비밀번호가 자동으로 암호화되는 것은 아니다.
또한 Base64나 URL 인코딩 역시 암호화가 아니다.
실제 서비스에서는 네트워크 구간의 기밀성을 위해 HTTPS를 사용해야 한다.
11. 커스텀 필터의 등록 위치를 검증한다
Spring Security는 필터 순서에 따라 인증과 인가를 처리한다.
따라서 커스텀 필터를 추가했다면 실제로 어떤 위치에 등록되었는지 확인하는 것이 좋다.
예를 들어 다음 설정을 사용할 수 있다.
logging.level.org.springframework.security.web.DefaultSecurityFilterChain=DEBUG
이후 애플리케이션을 실행하면 활성화된 SecurityFilterChain의 구성 정보를 확인할 수 있다.
출력 내용에는 다음과 같은 필터가 포함될 수 있다.
LoginFilter
이는 커스텀 필터가 보안 필터 체인에 등록되었는지 확인하는 데 도움이 된다.
실제 출력되는 필터 목록은 Spring Security 버전과 보안 설정에 따라 달라진다.
12. Spring Boot Filter Bean의 중복 등록을 주의한다
Servlet Filter를 Spring Bean으로 등록하면 Spring Boot가 Servlet Container에 자동 등록할 수 있다.
그런데 동일한 Filter를 SecurityFilterChain에도 등록하면 예상하지 못한 중복 실행이 발생할 수 있다.
따라서 이번처럼 SecurityFilterChain 내부에서 직접 LoginFilter를 생성하는 방식에서는 별도로 @Component를 선언하지 않는다.
LoginFilter loginFilter =
new LoginFilter(authenticationManager);
필터를 Spring Bean으로 관리해야 한다면 Servlet Filter 자동 등록 여부까지 확인해야 한다.
13. AuthenticationManager가 하나만 존재한다고 가정하지 않는다
단순한 애플리케이션에서는 하나의 AuthenticationManager를 사용하는 경우가 많다.
하지만 복잡한 서비스에서는 여러 인증 방식이나 여러 SecurityFilterChain을 구성할 수 있다.
예를 들어 다음과 같은 상황이다.
일반 사용자 로그인
↓
UsernamePasswordAuthenticationProvider
관리자 로그인
↓
추가 인증 정책
외부 시스템 인증
↓
별도 AuthenticationProvider
이러한 환경에서는 AuthenticationManager와 AuthenticationProvider의 관계를 명확하게 관리해야 한다.
14. JWT를 사용한다고 모든 요청에서 사용자 정보를 조회해야 하는 것은 아니다
로그인 요청에서는 일반적으로 데이터베이스의 사용자 정보를 조회하고 비밀번호를 검증한다.
하지만 이후 JWT 기반 인증에서는 토큰을 검증하는 방식에 따라 데이터베이스 조회 없이 사용자 식별과 권한 판단을 수행할 수 있다.
다만 계정 정지나 관리자 권한 회수 등 최신 상태가 중요한 요청에서는 별도 조회 정책이 필요할 수 있다.
따라서 로그인 인증과 JWT 인증의 데이터베이스 접근 전략을 동일하게 가정할 필요는 없다.
15. 로그인 실패와 서버 내부 오류를 구분한다
사용자가 비밀번호를 잘못 입력한 상황과 데이터베이스 연결이 실패한 상황은 다르다.
비밀번호 불일치
↓
정상적인 인증 실패
MySQL 연결 장애
↓
인증 시스템 내부 오류
두 상황을 모두 동일한 원인으로 기록하면 장애 분석이 어려워진다.
클라이언트에 노출하는 메시지는 보안상 제한할 수 있지만, 서버 내부에서는 인증 실패와 인프라 장애를 구분해 모니터링하는 것이 좋다.
16. 인증 필터 테스트와 실제 로그인 통합 테스트를 구분한다
LoginFilter의 단위 테스트가 통과했다고 해서 전체 로그인이 정상적으로 구현되었다는 의미는 아니다.
단위 테스트에서는 AuthenticationManager를 Mock으로 대체할 수 있다.
하지만 실제 로그인에서는 다음 구성요소가 모두 연결되어야 한다.
LoginFilter
↓
AuthenticationManager
↓
AuthenticationProvider
↓
UserDetailsService
↓
UserRepository
↓
MySQL
이후 JWT 발급까지 구현했다면 실제 요청을 이용한 통합 테스트로 다음 항목을 검증해야 한다.
| 테스트 상황 | 기대 결과 |
|---|---|
| 정상 아이디·비밀번호 | JWT 발급 |
| 존재하지 않는 사용자 | 인증 실패 |
| 잘못된 비밀번호 | 인증 실패 |
| 계정 비활성화 | 정책에 따른 인증 거부 |
| 로그인 성공 후 보호 API 호출 | JWT 기반 인증 |
| 일반 사용자로 관리자 API 호출 | 403 Forbidden |
이처럼 계층별 테스트와 전체 인증 흐름 테스트를 함께 수행하는 것이 중요하다.
정리
Spring Security에서 JWT 기반 로그인 시스템을 구현하려면 사용자 이름과 비밀번호를 이용한 인증 흐름을 이해해야 한다.
이번에 구현한 핵심 구성요소는 LoginFilter와 AuthenticationManager다.
먼저 UsernamePasswordAuthenticationFilter를 상속하여 커스텀 로그인 필터를 생성한다.
public class LoginFilter
extends UsernamePasswordAuthenticationFilter {
}
그다음 attemptAuthentication()을 재정의한다.
@Override
public Authentication attemptAuthentication(
HttpServletRequest request,
HttpServletResponse response
) throws AuthenticationException {
String username = obtainUsername(request);
String password = obtainPassword(request);
username = username == null ? "" : username.trim();
password = password == null ? "" : password;
UsernamePasswordAuthenticationToken authenticationToken =
UsernamePasswordAuthenticationToken.unauthenticated(
username,
password
);
setDetails(request, authenticationToken);
return getAuthenticationManager().authenticate(
authenticationToken
);
}
이 메서드는 로그인 요청에서 사용자 이름과 비밀번호를 추출하고 AuthenticationManager에 인증을 요청한다.
다음으로 SecurityConfig에서 AuthenticationManager를 구성한다.
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration authenticationConfiguration
) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
그리고 LoginFilter를 SecurityFilterChain에 등록한다.
.addFilterAt(
loginFilter,
UsernamePasswordAuthenticationFilter.class
)
이를 통해 로그인 요청은 다음과 같은 흐름으로 처리될 수 있다.
Client
↓
POST /login
↓
Servlet Container
↓
DelegatingFilterProxy
↓
FilterChainProxy
↓
SecurityFilterChain
↓
LoginFilter
↓
UsernamePasswordAuthenticationToken
↓
AuthenticationManager
인증이 성공하거나 실패하면 각각의 후처리 메서드가 실행된다.
Authentication 성공
↓
successfulAuthentication()
↓
JWT 발급 및 응답
Authentication 실패
↓
unsuccessfulAuthentication()
↓
HTTP 401
현재 단계에서는 인증 요청을 전달하는 구조를 구성했으며, 실제 사용자 조회와 비밀번호 검증 및 JWT 발급은 아직 연결되지 않았다.
다음으로 필요한 기능은 MySQL에 저장된 사용자 정보를 Spring Security의 UserDetails로 변환하고, 이를 UserDetailsService와 AuthenticationProvider에 연결하는 것이다.
이후 인증에 성공한 사용자의 정보로 JWT를 발급하면 실제 로그인 기능을 완성할 수 있다.
가장 중요한 점은 LoginFilter가 비밀번호를 직접 검증하는 것이 아니라, AuthenticationManager에 인증을 요청한다는 사실이다.
또한 UsernamePasswordAuthenticationToken은 Spring Security 내부에서 사용하는 인증 객체이며, 로그인 성공 후 발급하는 JWT와는 전혀 다른 역할을 가진다.
이 두 개념을 구분하면 Spring Security의 인증 구조를 훨씬 명확하게 이해할 수 있다.
한 줄 요약
Spring Security JWT 로그인은 UsernamePasswordAuthenticationFilter를 상속한 LoginFilter에서 아이디와 비밀번호를 추출하고, UsernamePasswordAuthenticationToken을 생성하여 AuthenticationManager에 전달하는 구조로 구현할 수 있다. 인증 성공과 실패는 각각 successfulAuthentication()과 unsuccessfulAuthentication()에서 처리하며, 실제 MySQL 사용자 검증과 JWT 발급은 별도의 인증 구성요소를 연결해야 완성된다.