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 의 기본 필터UsernamePasswordAuthenticationFilterAbstractAuthenticationProcessingFilter 를 상속받고 있으니 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"));
}

AntPathRequestMatcherApache 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니 쿠키는 사용하지 않겠다, 또한 여기는 인증에 대한 내용이고 로깅, 알림등은 범위를 벗어나기 때문에 이것 또한 생략하겠다

그러면 남은건 SecurityContextHolderAuthenticationSuccessHandler 부분만 넣어주면 된다.

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;
}
 

jwtTokenProviderJWT 생성, 검증 등을 담당하는 클래스다 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.principalid 였는데 여기서는 왜 UserDetails 객체니?"

AuthenticationManager.authenticate() 인증이 완료된 후 반환하는 Authentication 과 인증전 Authentication 은 다른 객체다.

AuthenticationManager.authenticate() 내부에서 사용되는 AuthenticationProvider 가 어떤 구현체인지에 따라 이 Authentication 의 구조도 변경된다

우리가 사용한 UsernamePasswordAuthenticationToken 의 경우 DaoAuthenticationProvider 를 사용하는데 DaoAuthenticationProviderAbstractUserDetailsAuthenticationProvider 를 상속 받았다.

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);
    }
}

생성자

jwtTokenProviderBean 객체기 때문에 Bean 객체간에만 자동 주입 이 가능하다 JwtAuthenticationFilter 의 경우 Bean이 아니기 때문에 생성자를 통한 주입이 필요하다

doFilter()

JWT 의 유효성을 검증하는 필터의 실행부이다 여기서 JWT의 유효성을 판단하고 유효하다면 Authentication 객체를 생성해서 SpringSecurityContext 에 저장해야한다

순서대로 알아보자

1. request 에서 jwtTokenProvider를 이용하여 JWT 를 얻어온다 2. jwtTokenProvider를 이용하여 JWT 가 유효한지 검증한다 3. 유효한 JWT일 경우 jwtTokenProvider를 이용하여 JWT로 부터 Authentication 인증객체 를 생성하고 SpringSecurityContext 에 저장하여 동일한 스레드내에서 언제든 사용할 수 있도록 한다 4. 다음 Filter를 이어서 진행한다