spring
spring security 에서 form말고 json body를 받는 REST API login 구현하기 (1) - Filter

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 를 생성하자

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() 함수의 일부를 보면 알 수 있다.

AbstractAuthenticationProcessingFilter.class
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;
    }
}
AbstractAuthenticationProcessingFilter.class
protected boolean requiresAuthentication(HttpServletRequest request,
        HttpServletResponse response) {
    return requiresAuthenticationRequestMatcher.matches(request);
}

우리가 만드는 Filter 동작 조건은 아래와 같다

  • http method : POST
  • 기본은 /loign Path
  • 사용자 지정으로 url path 변경 가능

이를 위해서 RequestMatcher 구현체중에 AntPathRequestMatcher 를 사용하겠다.

JsonUsernamePasswordAuthenticationFilter.java
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() 를 구현해보자

JsonUsernamePasswordAuthenticationFilter.java
@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);
}

순서를 보면

  1. Request 객체에서 body 값을 읽어와서 AuthDto 객체로 맵핑한다, 이 AuthDto는 ID, PW를 포함하고 있다 (AutoDto는 직접 작성한 DTO 클래스다)
  2. UsernamePasswordAuthenticationToken 을 생성한다, 첫번째 인자는 Object principal = ID, 두번째 인자는 Object credentials = PW 이다
  3. ID/PW가 입력된 UsernamePasswordAuthenticationToken 을 이용하여 AuthenticationManager.authenticate(usernamePasswordAuthenticationToken)를 호출하고 반환되는 인증 완료된 Authentication 객체를 return 한다
❕

인증 완료된 Authentication 라는 키워드가 나오는데, 여기서 알고 가야할 부분이 Authentication 은 두가지 상태를 갖을 수 있다 라는 것이다

boolean isAuthenticated(); 를 구현해야하니 구현체들은 보통 상태 Field를 하나 생성하는 것으로 보인다

  • 인증이 완료된 상태
  • 인증이 완료되지 않은 상태

사용자의 인증 요청 즉 Login 요청 이 들어왔을 때

인증이 완료되지 않은 상태의 인증 객체가 초기에 바로 생성되고

이 인증 객체가 비즈니스 로직을 거쳐서 인증이 완료된 상태로 변경되는 것이 인증 과정 이다

❔

"생성자 파라미터의 순서가 ID, PW 인거는 어떻게 아니?"

이걸 확실히 알려면 코드를 타고 들어가야 알 수 있어서 주관적인 생각으로는 설계가 좀 불친절하다고 느꼈다..

일단 Object principal = ID 인 것은 이미 구현된 AbstractUserDetailsAuthenticationProvider.authenticate() 보면 알 수 있다.

AbstractUserDetailsAuthenticationProvider.class
public Authentication authenticate(Authentication authentication)
        throws AuthenticationException {
    // ~~~
    String username = (authentication.getPrincipal() == null) ? "NONE_PROVIDED"
            : authentication.getName();
    // ~~~
}

그 다음 Object credentials = PW 인 것은 DaoAuthenticationProvider.additionalAuthenticationChecks() 를 보면 된다

DaoAuthenticationProvider.class
 
@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 라고 지정한다, 클래스는 뒤에서 생성해보겠다

JsonUsernamePasswordAuthenticationFilter.java
private AuthenticationSuccessHandler successHandler = new JsonUsernamePasswordAuthenticationSuccessHandler();
JsonUsernamePasswordAuthenticationFilter.java
 
@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 로 하겠다

JsonUsernamePasswordAuthenticationSuccessHandler.java
 
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 와 생성자

JsonUsernamePasswordAuthenticationSuccessHandler.java
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()

함수의 동작 순서대로 봐보자

  1. 인증이 완료된 authentication 로 부터 UserDetails 를 꺼내온다
  2. jwtTokenProvider 을 사용하여 UserDetails 의 내부 정보를 기반으로 String accessToken 을 생성하고, accessToken 연장용 String refreshToken 을 생성한다
  3. json 형식의 body 생성을 위한 Map 객체 responseBody를 생성한다
  4. 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 인지 확인할 수 있다

AbstractUserDetailsAuthenticationProvider.class
 
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 이다

JwtAuthenticationFilter.java
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를 이어서 진행한다