[패스트캠퍼스X야놀자 : 미니 프로젝트] Spring Security를 이용한 JWT Access Token 인증 구현하기

꼬마요리사레미·2023년 12월 11일

인증이 필요한 요청이 들어왔을 때 JWT Access Token을 검증하는 과정을 설명한다.

public class JwtConstants {

    // ...
    
    public static final String AUTHORIZATION_HEADER = "Authorization";
    public static final String BEARER_PREFIX = "Bearer ";
    
    // ... 
    
}
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
            
        // 1. 요청 헤더에서 토큰을 꺼낸다.
        String accessToken = extractToken(request);

        // 2. 토큰이 존재하지 않는 경우 다음 필터로 넘어간다.
        if (accessToken == null) {
            request.setAttribute("exception", SecurityExceptionCode.NOT_TOKEN);
            filterChain.doFilter(request, response);
            return;
        }

        // 3. 토큰이 유효하지 않은 경우 다음 필터로 넘어간다.
        // 4. 정상 토큰이면 해당 토큰으로 Authentication을 가져와서 SecurityContext 에 저장한다.
        try {

            Authentication authentication = jwtTokenProvider.getAuthentication(accessToken);
            SecurityContextHolder.getContext().setAuthentication(authentication);

        } catch (JwtException e) {

            request.setAttribute("exception", SecurityExceptionCode.INVALID_TOKEN);
            filterChain.doFilter(request, response);
            return;

        } catch (UsernameNotFoundException e) {

            request.setAttribute("exception", SecurityExceptionCode.USER_NOT_FOUND);
            filterChain.doFilter(request, response);
            return;

        }
        // 5. 다음 필터로 넘어간다.
        filterChain.doFilter(request, response);
    }

    private String extractToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(JwtConstants.AUTHORIZATION_HEADER);

        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(JwtConstants.BEARER_PREFIX)) {
            return bearerToken.substring(JwtConstants.BEARER_PREFIX.length());
        }

        return null;
    }
}

💡 JwtFilter

  • 인증이 필요한 요청이 들어왔을 경우 HTTP Request Header 에 담긴 JWT를 검증하기 위한 JwtFilter 클래스를 구현한다.
  • JwtFilter 클래스는 OncePerRequestFilter 클래스를 확장한 클래스로, 모든 HTTP 요청에 대해 한 번씩만 실행되도록 보장한다.
  • doFilterInternal 메서드에서 JWT를 검증한다.
  1. 유효한 경우 해당 토큰을 사용하여 사용자를 인증하고 SecurityContext에 저장한다.
  2. 유효하지 않은 경우 예외를 설정하고 필터 체인을 계속 진행한다.
  • resolveToken 메서드에서는 HTTP Request Header에서 JWT를 추출한다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtTokenProvider jwtTokenProvider;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(request -> request
                .requestMatchers("/").permitAll()
                .requestMatchers("/auth/**").permitAll()
                .requestMatchers("/products/**").permitAll()
                .anyRequest().authenticated()
            )
            
        return http.build();
    }
}

💡 AuthorizationFilter

  • Spring Security에서 인증된 사용자의 권한을 확인하고, 특정 리소스에 대한 접근 권한을 결정하는 역할을 수행하는 필터이다.
  • Spring Security 구성에서는 authorizeHttpRequests 메서드를 사용하여 이러한 권한을 정의하고, AuthorizationFilter가 이를 기반으로 요청을 처리한다.
  1. requestMatchers 메서드는 특정 URL 패턴에 대한 권한 설정을 지정하는 데 사용되며, 각 패턴에 대한 접근 규칙을 지정할 수 있다.
  2. permitAll()은 해당 URL 패턴에 대한 모든 사용자의 접근을 허용하는 권한 설정이다.
  3. 그외의 모든 요청은 인증된 사용자에게 허용한다.
@Component
public class JwtTokenProvider {

    // ... (생략)
    
   public boolean validateToken(String token) {
        try {
            parseClaims(token);
            return true;
        } catch (JwtException e) {
            return false;
        }
    }

    public Authentication getAuthentication(String accessToken) {
        // 1. Access Token에서 claims을 파싱한다.
        Claims claims = parseClaims(accessToken);

        // 2. 파싱된 claims에 대한 검증을 수행한다.
        if (claims.get(JwtConstants.AUTHORITIES_KEY) == null) {
            throw new InvalidTokenException(SecurityExceptionCode.INVALID_TOKEN);
        }

        // 3. claims에서 authorities를 추출한다.
        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get(JwtConstants.AUTHORITIES_KEY).toString().split(","))
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());

        // 4. claims에서 사용자 아이디를 추출한다,
        Long userId = Long.parseLong(claims.getSubject());

        // 5. claims에서 추출된 사용자 아이디를 사용하여 UserDetails를 생성한다.
        // 6. 생성된 Authentication 객체를 반환한다.
        UserDetails userDetails = customUserDetailsService.loadUserById(userId);

        return new UsernamePasswordAuthenticationToken(userDetails, "", authorities);
    }

    private Claims parseClaims(String accessToken) {

        return Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(accessToken)
                .getBody();
    }
    
    // ... (생략)
    
 }

⭐ validateToken Method

주어진 토큰이 유효한지 판단한다.
토큰이 유효하다는 것은 토큰이 변조되지 않았으며, 만료되지 않았으며, 올바른 서명을 가지고 있다는 것을 의미한다.

  • Spring Security의 JWT 라이브러리인 io.jsonwebtoken에서 제공하는 메서드 체인 방식의 API를 사용하여 JWT를 검증하고 파싱하는 부분이다.
  • 토큰이 유효하면 true 그렇지 않으면 false를 반환한다.

⭐ getAuthentication Method

주어진 토큰을 사용하여 Authentication 객체를 생성하여 반환한다.

  • 토큰으로부터 클레임 정보를 추출하고, 클레임으로부터 sub 및 auth 정보를 추출한다.
  • UsernamePasswordAuthenticationToken 클래스를 활용한다.
    Spring Security 에서 Authentication 정보를 나타내는 클래스 중 하나이다.
* Principal (주체) : UserDetails 인터페이스를 구현한 객체가 주체가 된다. 사용자의 식별 정보와 권한을 제공한다.
* Credentials (자격 증명) : 주로 비밀번호를 나타낸다. 보안상의 이유로 주로 null로 설정된다.
* Authorities (권한) : 사용자에게 부여된 역할이나 권한 목록을 나타낸다.

💡 JWT 검증 과정

  1. 디코딩
  • 토큰은 BASE64 인코딩된 헤더, 페이로드, 서명의 세 부분으로 구성된다.
  • 헤더와 페이로드를 BASE64 디코딩하여 원래 JSON 형식의 내용을 얻는다.
  1. 헤더 및 페이로드 확인
  • 헤더에는 알고리즘 정보가 포함되어 있으며, 페이로드에는 클레임 정보가 들어 있다. 이 정보를 사용자가 기대하는 값과 비교하여 검증한다.
  1. 서명 검증
  • 헤더에 지정된 알고리즘 정보를 확인한다.
  • 해당 알고리즘을 사용하여 헤더와 페이로드를 합친 후, 서버의 비밀 키를 사용하여 서명을 생성한다.
  • 생성된 서명과 제공된 서명을 비교하여 일치 여부를 확인한다.
@RequiredArgsConstructor
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public void configure(HttpSecurity httpSecurity) {
        JwtFilter jwtFilter = new JwtFilter(jwtTokenProvider);
        httpSecurity.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
    }
}
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtTokenProvider jwtTokenProvider;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
            
        http 
            .apply(new JwtSecurityConfig(jwtTokenProvider));

        return http.build();
    }
}

💡 JwtSecurityConfig

  • Spring Security의 SecurityConfigurerAdapter를 확장하여 특정한 설정을 제공하는 클래스이다. 이 클래스는 JWT를 사용한 인증을 구성하는 데 사용된다.
  • 여기서 configure 메서드에서는 JwtFilter를 생성하고 이를 UsernamePasswordAuthenticationFilter 앞에 추가한다.
  • JwtFilter는 주어진 JwtTokenProvider를 사용하여 JWT를 검증하고, 필요에 따라 사용자를 인증하는 역할을 수행한다.
  • 이후 SecurityFilterChain에 JwtSecurityConfig를 적용하면 configure 메서드가 호출되어 추가적인 보안 구성이나 필터를 적용할 수 있다.
@Component
@RequiredArgsConstructor
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    private final ObjectMapper objectMapper;

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException authException) throws IOException {
        ExceptionCode exception = (ExceptionCode) request.getAttribute("exception");

        if (exception != null && exception.equals(SecurityExceptionCode.NOT_TOKEN)) {
            exceptionHandler(response, SecurityExceptionCode.NOT_TOKEN);
            return;
        }

        if (exception != null && exception.equals(SecurityExceptionCode.INVALID_TOKEN)) {
            exceptionHandler(response, SecurityExceptionCode.INVALID_TOKEN);
            return;
        }

        if (exception != null && exception.equals(SecurityExceptionCode.USER_NOT_FOUND)) {
            exceptionHandler(response, SecurityExceptionCode.USER_NOT_FOUND);
            return;
        }
    }

    public void exceptionHandler(HttpServletResponse response, SecurityExceptionCode errorCode) throws IOException {
        Map<String, Object> responseBody = new HashMap<>();
        responseBody.put("status", errorCode.getStatus());
        responseBody.put("code", errorCode.getCode());
        responseBody.put("message", errorCode.getMsg());

        String responseBodyJson = objectMapper.writeValueAsString(responseBody);

        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setStatus(401);
        response.setCharacterEncoding("utf-8");
        response.getWriter().write(responseBodyJson);
    }
}

💡 JwtAuthenticationEntryPoint

  • 인증되지 않은 사용자가 보호된 엔드포인트에 액세스하려고 할 때 (401 Unauthorized) 호출될 JwtAuthenticationEntryPoint를 커스텀하게 작성한다.
  • 인증을 수행하는 JwtFilter에서 발생한 예외를 받아서 처리하고, JSON 형태의 API 응답으로 변환하는 역할을 수행한다.
  • commence 메서드: AuthenticationEntryPoint 인터페이스의 메서드로, 인증 예외가 발생했을 때 호출된다.
  • exceptionHandler 메서드: objectMapper를 사용하여 status, code, message를 담은 JSON을 생성하고, HttpServletResponse를 통해 클라이언트에게 전달한다.
  • Spring Security는 최종적인 인증/인가 요청을 AuthorizationFilter에서 판단한다.
  • 여기서 인증이 필요한 요청인데 인증이 되어 있지 않다거나, 필요한 권한이 존재하지 않는 요청을 AuthorizationDecision이라는 클래스로 판단한다.
  • 이때 요건이 충족되지 않았다고 판단이 되면 예외를 던지고 이는 AuthorizationFilter의 바로 앞 필터인 ExceptionTranslationFilter에서 처리하게 된다.
  • 이 때, 인증이 필요한 요청에 인증이 되지 않은 요청이라면 ExceptionTranslactionFilter는 AuthenticationEntryPoint의 메서드를 호출한다.
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .exceptionHandling()
            .accessDeniedHandler(jwtAccessDeniedHandler)  
            .authenticationEntryPoint(jwtAuthenticationEntryPoint);
}

💡 exceptionHandling()

  • 위에서 커스텀한 JwtAuthenticationEntryPoint를 SecurityFilterChain에 등록한다.
  • 이렇게 설정하면 해당 예외가 발생했을 때 등록된 커스텀 핸들러들이 호출되어 적절한 응답을 생성하게 된다.

0개의 댓글