spring security 에서 form말고 json body를 받는 REST API login 구현하기 (1) - Filter
spring security 를 사용하면 보통 form 방식의 login 을 많이 구현하는데, json body 를 받는 형식으로 구현해보자
환경
- spring boot 2.4.1
- spring security core 5.3.6
spring security 6.x
버전부터 많은 변화가 있다고 알고있고, 해당 포스팅은 5.x
기준이다
JsonUsernamePasswordAuthenticationFilter
JsonBody에서 id
, pw
를 획득하여 인증을 시작하는 JsonUsernamePasswordAuthenticationFilter.java
를 생성하자
public class JsonUsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException, IOException, ServletException {
//~~~
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
Authentication authResult) throws IOException, ServletException {
//~~~
}
}
AbstractAuthenticationProcessingFilter
를 상속받도록 하는데
해당 클래스를 상속받은 이유는 해당 클래스 주석의 첫번째 줄에 있다
Abstract processor of browser-based HTTP-based authentication requests. "브라우저 기반 HTTP 기반 인증 요청을 처리하는 추상 프로세서."
우리는 REST API
형식의 Login이니 HTTP 기반 인증
이 맞고
또한 formLogin 의 기본 필터
인 UsernamePasswordAuthenticationFilter
가 AbstractAuthenticationProcessingFilter
를 상속받고 있으니 Spring Security 기본 환경과 비슷한 느낌을 줄 수 있다.
함수별로 살펴보자
생성자
AbstractAuthenticationProcessingFilter
의 생성자를 보면 RequestMatcher
를 파라미터로 받는다
RequestMatcher
는 Filter가 동작해야하는 Request인지 검증하는 용도이다.
이에 대한 동작은 AbstractAuthenticationProcessingFilter.doFilter()
함수의 일부를 보면 알 수 있다.
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
if (!requiresAuthentication(request, response)) {
chain.doFilter(request, response);
return;
}
}
protected boolean requiresAuthentication(HttpServletRequest request,
HttpServletResponse response) {
return requiresAuthenticationRequestMatcher.matches(request);
}
우리가 만드는 Filter
동작 조건은 아래와 같다
- http method : POST
- 기본은
/loign
Path - 사용자 지정으로
url path
변경 가능
이를 위해서 RequestMatcher
구현체중에 AntPathRequestMatcher
를 사용하겠다.
public JsonUsernamePasswordAuthenticationFilter() {
super(new AntPathRequestMatcher("/login", "POST"));
}
public JsonUsernamePasswordAuthenticationFilter(String pathPattern) {
super(new AntPathRequestMatcher(pathPattern, "POST"));
}
AntPathRequestMatcher
는 Apache Ant의 파일 경로 패턴 스타일을 의미한다
*
: 현재 디렉토리 내의 모든 파일 또는 디렉토리와 일치한다
**
: 모든 디렉토리 수준에서 일치한다. 예를 들어, /a/**/b
는 /a/b
, /a/x/b
, /a/x/y/b
등과 일치한다
?
: 하나의 문자와 일치한다
등의 패턴이 가능하다
attemptAuthentication()
Filter 가 호출되면 기본적으로 doFilter()
가 실행되며 우리가 상속받은 AbstractAuthenticationProcessingFilter.doFilter()
에서 attemptAuthentication()
를 호출한다.
doFilter()
함수에서 바로 인증하기 보다는 인증 전용 함수
에게 역할을 위임한 것으로 보인다
javadoc 을 살펴보면 (javadoc 전문은 생략)
- 목적 : 실제 인증 수행
- 반환 : 인증된 사용자 토큰 또는 인증이 완료되지 않았을 경우 null
위 두 내용이 핵심
spring security 에서 인증을 수행해주는 객체가 별도로 있는데 AuthenticationManager
라는 녀석이다
추상클래스인 AbstractAuthenticationProcessingFilter
의 필드이며 생성자로 초기화는 불가능하고 setter
로 주입해야한다.
일단 주입 받았다고 치고 attemptAuthentication()
를 구현해보자
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException, IOException, ServletException {
// 1
AuthDto authDto;
try {
authDto = new ObjectMapper().readValue(request.getInputStream(), AuthDto.class);
} catch (IOException e) {
throw new IllegalArgumentException(
"The request body format for authentication appears to be invalid. Please verify the validity of the body",
e);
}
// 2
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(
authDto.getId(), authDto.getPassword());
// 3
return this.getAuthenticationManager().authenticate(usernamePasswordAuthenticationToken);
}
순서를 보면
Request
객체에서body
값을 읽어와서AuthDto
객체로 맵핑한다, 이AuthDto
는 ID, PW를 포함하고 있다 (AutoDto는 직접 작성한 DTO 클래스다)UsernamePasswordAuthenticationToken
을 생성한다, 첫번째 인자는Object principal = ID
, 두번째 인자는Object credentials = PW
이다- ID/PW가 입력된 UsernamePasswordAuthenticationToken 을 이용하여
AuthenticationManager.authenticate(usernamePasswordAuthenticationToken)
를 호출하고 반환되는인증 완료된 Authentication
객체를return
한다
인증 완료된 Authentication
라는 키워드가 나오는데, 여기서 알고 가야할 부분이
Authentication 은 두가지 상태를 갖을 수 있다
라는 것이다
boolean isAuthenticated();
를 구현해야하니 구현체들은 보통 상태 Field
를 하나 생성하는 것으로 보인다
- 인증이 완료된 상태
- 인증이 완료되지 않은 상태
사용자의 인증 요청
즉 Login 요청
이 들어왔을 때
인증이 완료되지 않은 상태의 인증 객체
가 초기에 바로 생성되고
이 인증 객체
가 비즈니스 로직을 거쳐서 인증이 완료된 상태
로 변경되는 것이 인증 과정
이다
"생성자 파라미터의 순서가 ID, PW 인거는 어떻게 아니?"
이걸 확실히 알려면 코드를 타고 들어가야 알 수 있어서 주관적인 생각으로는 설계가 좀 불친절하다고 느꼈다..
일단 Object principal = ID
인 것은 이미 구현된 AbstractUserDetailsAuthenticationProvider.authenticate()
보면 알 수 있다.
public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
// ~~~
String username = (authentication.getPrincipal() == null) ? "NONE_PROVIDED"
: authentication.getName();
// ~~~
}
그 다음 Object credentials = PW
인 것은 DaoAuthenticationProvider.additionalAuthenticationChecks()
를 보면 된다
@SuppressWarnings("deprecation")
protected void additionalAuthenticationChecks(UserDetails userDetails,
UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
// ~~~
String presentedPassword = authentication.getCredentials().toString();
// ~~~
}
successfulAuthentication()
다음은 successfulAuthentication()
함수다
AbstractAuthenticationProcessingFilter.doFilter()
에서 인증 과정에 문제가 발생하지 않으면 마지막으로 successfulAuthentication()
를 호출한다
함수의 javadoc을 보면
- 인증 성공한 Authentication 을
SecurityContextHolder
에 설정 - 인증이 성공했을음을
RememberMeServices
에 알림 ApplicationEventPublisher
을 통해 인증 성공 이벤트 발생- 기타 추가 동작을
AuthenticationSuccessHandler
에 위임
라는 내용이 적혀있다.
각 항목들을 간단하게 살펴보면
- SecurityContextHolder : 스레드 로컬에 인증 정보 저장
- RememberMeServices : 쿠키를 통한 재로그인 생략
- ApplicationEventPublisher : 로그인 완료 이벤트를 발행해서 로깅, 알림 등이 동작하도록 함
- AuthenticationSuccessHandler : 로그인 성공에대한 리다이렉트, 프록시 등을 진행
우리는 JWT
니 쿠키는 사용하지 않겠다, 또한 여기는 인증에 대한 내용이고 로깅, 알림등은 범위를 벗어나기 때문에 이것 또한 생략하겠다
그러면 남은건 SecurityContextHolder
와 AuthenticationSuccessHandler
부분만 넣어주면 된다.
AuthenticationSuccessHandler
는 field로 지니고 있겠다
아직 존재하지 않는 클래스지만 이름을 미리 JsonUsernamePasswordAuthenticationSuccessHandler
라고 지정한다, 클래스는 뒤에서 생성해보겠다
private AuthenticationSuccessHandler successHandler = new JsonUsernamePasswordAuthenticationSuccessHandler();
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
Authentication authResult) throws IOException, ServletException {
SecurityContextHolder.getContext().setAuthentication(authResult);
this..authenticationSuccessHandler.onAuthenticationSuccess(request, response, authResult);
}
AuthenticationSuccessHandler
successfulAuthentication()
에서 SecurityContextHolder
에다가 인증 정보를 저장하고 넘어왔다, 이제 동일한 스레드내에서는 어디서든 인증 정보를 가져다 쓸 수 있게 되었다.
이제 남은건 사용자에게 인증 성공을 알리면서 JWT를 발행
하는 것이다
일단 AuthenticationSuccessHandler
인터페이스를 구현하는 Handler 클래스
를 하나 작성해보자
이름은 JsonUsernamePasswordAuthenticationFilter.java
에서 필드를 지정할 때 사용했던 JsonUsernamePasswordAuthenticationSuccessHandler
로 하겠다
public class JsonUsernamePasswordAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
private JwtTokenProvider jwtTokenProvider;
public JsonUsernamePasswordAuthenticationSuccessHandler(JwtTokenProvider jwtTokenProvider) {
Objects.requireNonNull(jwtTokenProvider,
"jwtTokenProvider does not allow initialization to NULL");
this.jwtTokenProvider = jwtTokenProvider;
}
/**
* 인증이 성공하였으니 Jwt를 발급합니다.
*/
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
Objects.requireNonNull(jwtTokenProvider, "jwtTokenProvider does not allow NULL");
// 1
UserDetails user = (UserDetails) authentication.getPrincipal();
// 2
String accessToken = this.jwtTokenProvider.createAccessToken(user.getUsername(), user.getAuthorities());
String refreshToken = this.jwtTokenProvider.createRefreshToken();
// 3
Map<String, String> responseBody = Map.of("accessToken", accessToken, "refreshToken", refreshToken);
// 4
response.getWriter().write(new ObjectMapper().writeValueAsString(responseBody));
}
}
하나씩 알아보자
jwtTokenProvider Field 와 생성자
private JwtTokenProvider jwtTokenProvider;
public JsonUsernamePasswordAuthenticationSuccessHandler(JwtTokenProvider jwtTokenProvider) {
Objects.requireNonNull(jwtTokenProvider,
"jwtTokenProvider does not allow initialization to NULL");
this.jwtTokenProvider = jwtTokenProvider;
}
jwtTokenProvider
는 JWT
생성, 검증 등을 담당하는 클래스다 Bean
으로 등록되어있어야한다.
jwtTokenProvider의 구현 자체에 대해서는 생략하도록 하겠다
JsonUsernamePasswordAuthenticationSuccessHandler
의 경우 Bean
이 아니기에 jwtTokenProvider 를 주입받을 수 없다.
그렇기 때문에 생성자를 통해서 주입받도록 하였다
onAuthenticationSuccess()
함수의 동작 순서대로 봐보자
- 인증이 완료된
authentication
로 부터UserDetails
를 꺼내온다 jwtTokenProvider
을 사용하여 UserDetails 의 내부 정보를 기반으로String accessToken
을 생성하고, accessToken 연장용String refreshToken
을 생성한다- json 형식의 body 생성을 위한
Map 객체 responseBody
를 생성한다 responseBody
객체를json형식으로 정규화
하고response
객체의writer
를 획득하여 직접write
한다
"인증 완료되지 않음 단계에서는 Authentication.principal
이 id
였는데 여기서는 왜 UserDetails
객체니?"
AuthenticationManager.authenticate()
인증이 완료된 후 반환하는 Authentication
과 인증전 Authentication 은 다른 객체다.
AuthenticationManager.authenticate()
내부에서 사용되는 AuthenticationProvider
가 어떤 구현체인지에 따라 이 Authentication
의 구조도 변경된다
우리가 사용한 UsernamePasswordAuthenticationToken
의 경우 DaoAuthenticationProvider
를 사용하는데 DaoAuthenticationProvider
는 AbstractUserDetailsAuthenticationProvider
를 상속 받았다.
AbstractUserDetailsAuthenticationProvider.authenticate()
함수의 일부를 보면 Authentication.principal
이 왜 UserDetails
인지 확인할 수 있다
UserDetails user = this.userCache.getUserFromCache(username);
// ~~~
Object principalToReturn = user;
if (forcePrincipalAsString) {
principalToReturn = user.getUsername();
}
return createSuccessAuthentication(principalToReturn, authentication, user);
JwtAuthenticationFilter
이제 알아볼 필터는 이미 Login을 하여 JWT
를 발급받은 사용자가 보내는 요청에서 JWT
가 사용할 수 있는 JWT인지 검증
하는 Filter 이다
public class JwtAuthenticationFilter extends GenericFilterBean {
private JwtTokenProvider jwtTokenProvider;
public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
Objects.requireNonNull(jwtTokenProvider,
"jwtTokenProvider does not allow initialization to NULL");
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
// 1
String token = jwtTokenProvider.resolveToken((HttpServletRequest) request);
// 2
if (token != null && jwtTokenProvider.validateToken(token)) {
// 3
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
// 4
chain.doFilter(request, response);
}
}
생성자
jwtTokenProvider
는 Bean
객체기 때문에 Bean
객체간에만 자동 주입
이 가능하다
JwtAuthenticationFilter
의 경우 Bean
이 아니기 때문에 생성자를 통한 주입이 필요하다
doFilter()
JWT
의 유효성을 검증하는 필터의 실행부이다
여기서 JWT
의 유효성을 판단하고 유효하다면 Authentication
객체를 생성해서 SpringSecurityContext
에 저장해야한다
순서대로 알아보자
1. request
에서 jwtTokenProvider
를 이용하여 JWT
를 얻어온다
2. jwtTokenProvider
를 이용하여 JWT
가 유효한지 검증한다
3. 유효한 JWT
일 경우 jwtTokenProvider
를 이용하여 JWT
로 부터 Authentication 인증객체
를 생성하고
SpringSecurityContext
에 저장하여 동일한 스레드내에서 언제든 사용할 수 있도록 한다
4. 다음 Filter를 이어서 진행한다