본문 바로가기

공부

JWT 인가 처리, 이거 모르면 제발 Spring security 쓰지 마세요.

728x90

라고 굉장히 도발적인 제목을 작성해봤다. 사실 몰라도 사용해도 괜찮다. 내가 뭐라고 이러쿵저렁쿵하겠나. 하지만 대부분의 블로그가 획일화된 방법을 제안해 나는 조금 다른 방법을 제안해보고자 한다.

 

DALL E가 그려준 썸네일

 

JWT 인가 구현

처음은 인가에 대해서 생각해보자. 다른 글처럼 JWT를 설명하는 것은 너무 지루하고 재미없기에 이 글을 읽자. 내가 본 글 중 JWT에 대해서 가장 간단하고 명확하게 정리해서 초보자도 바로 이해할 수 있을 정도로 잘 정리해두었다.

 

그림1. Spring security architecture

 

인가 구현에 있어서 가장 첫 번째는 Filter를 추가하는 것이다. Filter는 우리가 Http 요청 시, 해당 요청에 대한 인가 및 인증 처리를 해주고 만약 권한 이상의 요청 시 걸러주는 역할을 하게 된다.

 

// 아래에서 필요한 클래스는 알아보고 마지막에 완성된 코드를 제공한다.

public class JwtAuthenticationFilter extends OncePerRequestFilter {

  @Override
  protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                  FilterChain filterChain) throws ServletException, IOException {
    try {
      ... 토근 정보 추출 및 인증 처리
    } catch (AuthenticationException ex) {
      ... 토근 정보 처리 시 예외 발생 처리
      return;
    }

    filterChain.doFilter(request, response);
  }
}

 

처음 작성할 JwtAuthenticationFilter는 servlet request에서 jwt를 가져와 처리한다. 이 필터는 Http 요청 당 1번 실행하기 위해서 OncePerRequestFilter를 상속해서 구현한다.

 

AuthenticationConverter

AuthenticationConverter는 servlet request에서 인증 정보로 변환하기 위한 역할을 담당한다.

 

public class JwtAuthenticationConverter implements AuthenticationConverter {

  private static final String JWT_AUTHORIZATION_HEADER_NAME = "Authorization";

  private static final String JWT_PREFIX = "Bearer ";

  private static final int JWT_PREFIX_LENGTH = JWT_PREFIX.length();

  @Override
  public JwtAuthenticationToken convert(HttpServletRequest request) {
    final String authenzation = request.getHeader(JWT_AUTHORIZATION_HEADER_NAME);
    if (authenzation != null && authenzation.startsWith(JWT_PREFIX)) {
      final String token = authenzation.substring(JWT_PREFIX_LENGTH);
      return new JwtAuthenticationToken(token);
    }

    return null;
  }
}

 

JwtAuthenticationConverter는 servlet request에서 jwt 정보를 가져와 JwtAuthenticationToken을 만들어 반환한다. JwtAuthenticationToken는 이후 적절한 Authentication provider에 의해 처리될 수 있도록 우리가 만든 객체이다.

 

Authentication

Authentication 객체는 인증 처리에 필요한 데이터를 가지고 있는 객체다. 이런 인증 객체를 만드는 것으로 servlet과 인증 도메인의 경계가 생기고, 스프링 라이프 사이클 전반에 일관성 있는 인증 처리를 도와준다.

 

package com.example.homework.auth;

import java.util.Collection;
import java.util.Collections;
import org.springframework.security.core.AuthenticatedPrincipal;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;

public class JwtAuthenticationToken implements Authentication {

  private final Collection<GrantedAuthority> authorities;

  private boolean authenticated;

  private final Object credentials;

  private Object details;

  private final Object principle;

  public JwtAuthenticationToken(Object credentials) {
    this.principle = null;
    this.credentials = credentials;
    this.authorities = Collections.emptyList();
    this.authenticated = false;
  }

  public JwtAuthenticationToken(Object principle, Collection<GrantedAuthority> authorities) {
    this.authorities = authorities;
    this.principle = principle;
    this.credentials = null;
    this.authenticated = true;
  }

  @Override
  public Collection<? extends GrantedAuthority> getAuthorities() {
    return Collections.unmodifiableCollection(this.authorities);
  }

  @Override
  public Object getCredentials() {
    return this.credentials;
  }

  @Override
  public Object getDetails() {
    return this.details;
  }

  @Override
  public Object getPrincipal() {
    return this.principle;
  }

  @Override
  public boolean isAuthenticated() {
    return this.authenticated;
  }

  @Override
  public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
    this.authenticated = isAuthenticated;
  }

  @Override
  public String getName() {
    if (this.getPrincipal() instanceof AuthenticatedPrincipal authenticatedPrincipal) {
      return authenticatedPrincipal.getName();
    }
    return (this.getPrincipal() == null) ? "" : this.getPrincipal().toString();
  }
}

 

나는 오버로딩을 통해 credential으로 객체 생성 시 비인가 처리된 토큰 정보고, principle과 authorities를 가지고 생성된 경우 인가 처리된 토큰 정보로 처리할 예정이다. 그렇다면 왜 인가/비인가된 객체를 하나로 관리하는 것일까? 그것은 spring security는 비인가 사용자에 대한 요청 처리도 지원해야하기 때문이다.

 

AuthenticationProvider

AuthenticationProvider는 인증 처리를 담당하는데, 이 곳에서 우리는 Jwt에 대한 유효성 검사 및 사용자 정보를 다루게 된다.

 

public class JwtAuthenticationProvider implements AuthenticationProvider {

  private final JwtUtil jwtUtil;

  public JwtAuthenticationProvider(JwtUtil jwtUtil) {
    this.jwtUtil = jwtUtil;
  }

  @Override
  public Authentication authenticate(Authentication authentication) throws AuthenticationException {
    try {
      return doAuthenticate(authentication);
    } catch (JwtAuthenticationException e) {
      throw e;
    } catch (Exception e) {
      throw new JwtAuthenticationException("Unknown jwt authentication error", e);
    }
  }

  @Override
  public boolean supports(Class<?> authentication) {
    return JwtAuthenticationToken.class.isAssignableFrom(authentication);
  }

  private JwtAuthenticationToken doAuthenticate(Authentication authentication) {
    if (authentication instanceof JwtAuthenticationToken authenticationToken) {
      return verifyJwtAuthenticationToken(authenticationToken);
    }
    throw new JwtAuthenticationException("JWT token is incorrect");
  }

  private JwtAuthenticationToken verifyJwtAuthenticationToken(
      JwtAuthenticationToken authentication) {
    String token = (String) authentication.getCredentials();
    String username = jwtUtil.getUsername(token);
    return new JwtAuthenticationToken(username, Collections.emptyList());
  }
}

 

이 Provider는 Spring security cycle에서 AuthenticationManager에 의해 관리되며 AuthenticationManager는 등록된 Provider 중 해당 Authentication 객체를 처리 가능한 Provider는 선택해 인증 처리를 한다. 즉, 정리하자면 다음과 같다.

 

  • authentication manager는 등록된 providers를 순회하며 supports를 호출한다.
  • 만약 supports가 true인 경우 해당 provider로 authentcate를 호출한다.

즉, Provider는 인증 자체를 담당하는 역할을 담당한다.

 

AuthenticationException

그렇다면 인증에 실패하면 어떻게 처리하면 좋을까? 대부분 인증에 실패했을 때 처리는 Filter에 위임하는 것이 좋다. 왜냐하면 Provider는 인증 처리에 연관되어 있기 때문에 실패 응답에 대한 처리는 Filter가 받아 실패 처리 handler로 전달하는 것이 이상적이기 때문이다.

 

그렇다면 Spring security cycle에서 인증 실패에 대한 예외는 어떤 걸 던져야 하는지 알아야한다. Spring security는 인증 도메인에서 발생하는 예외 처리에 사용되는 AuthenticationException 추상 클래스를 제공한다.

 

public class JwtAuthenticationException extends AuthenticationException {

  public JwtAuthenticationException(String msg) {
    super(msg);
  }

  public JwtAuthenticationException(String msg, Throwable cause) {
    super(msg, cause);
  }
}

 

이렇게 Spring security cycle 내에서 정한 규칙을 지키면 이후 인증 처리에 필요한 Provider를 교체해도 Spring security cycle 내 적절한 처리를 받을 수 있다.

 

AuthenticationFailureHandler

그렇다면 AuthenticationException이 발생하면 어디서 처리하는 것이 좋을까? 이 질문에 대한 내 대답은 AuthenticationFailureHandler를 만들어 처리하는 것이 좋을 것 같다. 만약 단순한 로직이라면 Handler가 아닌, AuthenticationEntryPoint를 통해 문제를 해결해보자.

 

Avengers..! Assemble.

그림2. 영화 "Avengers assemble" 등장인물을 프로그래밍 언어로 의인화

 

여기까지 인가에 필요한 객체를 생성했다. 그러면 다시 JwtAuthenticationFilter로 돌아오면 다음과 같이 작성할 수 있다.

 

public class JwtAuthenticationFilter extends OncePerRequestFilter {

  private final AuthenticationConverter authConverter = new JwtAuthenticationConverter();

  private final AuthenticationFailureHandler failureHandler =
      new JwtAuthenticationEntryPointFailureHandler();

  private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder
      .getContextHolderStrategy();

  private AuthenticationManager authenticationManager;

  public JwtAuthenticationFilter(AuthenticationManager authenticationManager) {
    this.authenticationManager = authenticationManager;
  }

  @Override
  protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                  FilterChain filterChain) throws ServletException, IOException {
    try {
      Authentication token = this.authConverter.convert(request);
      if (token != null) {
        Authentication authResult = this.authenticationManager.authenticate(token);
        SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
        context.setAuthentication(authResult);
        this.securityContextHolderStrategy.setContext(context);
      }
    } catch (AuthenticationException ex) {
      failureHandler.onAuthenticationFailure(request, response, ex);
      return;
    }

    filterChain.doFilter(request, response);
  }
}

 

항상 기술 글을 작성하는 것은 즐겁다. 왜냐하면 내가 별 말 안하고 코드가 잔뜩 넣어두면 대부분 끝까지 내려와 "소스 코드는 깃허브에 확인해보세요!"만 확인할테니 글을 잘 안적어도 된다는 것이다. 그래서 동작하는 코드는 깃허브 코드를 확인해보세요!

출처

'공부' 카테고리의 다른 글

🍎 레디스 톺아보기 - pub/sub  (0) 2023.12.19
🍎 레디스 톺아보기 - 데이터 타입 이해하기  (0) 2023.12.17
제네릭  (0) 2023.11.10
클래스와 인터페이스  (0) 2023.11.10
모든 객체의 공통 메서드  (0) 2023.11.10