[Spring] Spring Security + JWT 자격 증명 및 권한 검증 구현_03

Gogh·2023년 1월 2일
0

Spring

목록 보기
23/23
post-thumbnail

🎯 목표 : JWT 개념 학습, Spring Security 어플리케이션에 JWT를 구현하는 과정 학습

📒 Spring Security + JWT

  • [Spring] Spring Security + JWT_01 블로깅에 이어,
  • [Spring] Spring Security + JWT 로그인 인증 구현_02 블로깅에서 로그인 인증 필터와 후속 처리에 대해 구현하였다.
  • 로그인 인증을 성공한 후 Request의 Header에서 토큰을 얻어 권한 검증을 하는 로직과 예외에 대한 처리 로직을 구현 해 보자.
  • 아래에서 구현한 로직에서는 Refresh Token을 가지고 Access Token을 재발행 하는 로직은 추가하지 않았다.
    • 이 부분은 추후 AOP를 사용하여 각각의 사용자가 해당 사용자에 적합한 리소스에 접근하기 위한 권한을 검증하는 로직과 함께 정리할 예정이다.

📌 JWT 검증 필터 구현


@RequiredArgsConstructor
public class JwtVerificationFilter extends OncePerRequestFilter {

    private final JwtTokenizer jwtTokenizer;

    private final JwtAuthorityUtils authorityUtils;

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain
    ) throws ServletException, IOException {
        try {
            Map<String, Object> claims = verifyJws(request);
            setAuthenticationToContext(claims);
        } catch (SignatureException se) {
          request.setAttribute("exception", se);
        } catch (ExpiredJwtException ee) {
          request.setAttribute("exception", ee);
        } catch (Exception e) {
          request.setAttribute("exception", e);
        }

        filterChain.doFilter(request, response);

    }

    @Override
    protected boolean shouldNotFilter(
            HttpServletRequest request
    ) throws ServletException {
        String authorization = request.getHeader("Authorization");

        return authorization == null || !authorization.startsWith("Bearer ");
    }

    private Map<String, Object> verifyJws(HttpServletRequest request) {
        String jws = request.getHeader("Authorization").replace("Bearer ", "");
        String base64SecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());

        return jwtTokenizer.getClaims(jws, base64SecretKey).getBody();
    }

    private void setAuthenticationToContext(Map<String ,Object> claims) {
        String username = (String) claims.get("username");
        List<GrantedAuthority> roles = authorityUtils.createAuthorities((List<String>) claims.get("roles"));
        Authentication authentication =
                new UsernamePasswordAuthenticationToken(username, null, roles);

        SecurityContextHolder.getContext().setAuthentication(authentication);
    }
}
  • OncePerRequestFilter를 확장하여 하나의 Request 당 한번만 실행되는 SecurityFilter를 구현하였다.
  • 토큰을 검증하고 Claims를 얻기 위해 JwtTokenizer를 주입받는다.
  • 토큰 검증에 성공 후 Authentication을 채울 사용자 권한을 생성하기 위해 CustomAuthorityUtils을 주입 받는다.
  • verifyJws() 메소드는 JWT를 검증하기위한 메소드다.
    • request의 Header에서 JWT를 얻어 replace()로 토큰외 부분을 제거한다.
    • JWT Signature를 검증하기 위한 Secret Key를 얻고 Claims를 파싱한다. Claims를 정상적으로 파싱할수 있다면, Signature의 검증 역시 정상적으로 성공한 것과 동일하다.
  • setAuthenticationToContext()에서는 Authentication 객체를 SecurityContext에 저장한다.

📌 권한 검증에 대한 예외 처리

  • JwtVerificationFilterdoFilterInternal() 메소드 내부에서 try-catch 문으로 예외를 잡아 request로 넘겨주고 있다.

  • 넘겨 받은 AuthenticationException 예외들을 처리하기위한 AuthenticationEntryPoint와 인증 성공후 해당 리소스에 권한이 없을때 발생하는 AccessDeniedException를 처리하기 위한 AccessDeniedHandler를 구현해 보자.

  • 에러에 대한 응답 DTO는 ErrorResponse클래스로 따로 정의 되어 있으며, 코드는 생략하겠다. 우선 ErrorResponse를 출력 스트림으로 생성해주는 ErrorResponder 클래스를 정의했다.


public class ErrorResponder {
    public static void sendErrorResponse(HttpServletResponse response, HttpStatus status) throws IOException {
        Gson gson = new Gson();
        ErrorResponse errorResponse = ErrorResponse.of(status);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setStatus(status.value());
        response.getWriter().write(gson.toJson(errorResponse, ErrorResponse.class));
    }
}
  • 다음으로, AuthenticationException 예외들을 처리하기 위해 AuthenticationEntryPoint 을 구현한 클래스다

@Slf4j
@Component
public class MemberAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        Exception exception = (Exception) request.getAttribute("exception");
        ErrorResponder.sendErrorResponse(response, HttpStatus.UNAUTHORIZED);

        logExceptionMessage(authException, exception);
    }

    private void logExceptionMessage(AuthenticationException authException, Exception exception) {
        String message = exception != null ? exception.getMessage() : authException.getMessage();
        log.warn("Unauthorized error happened: {}", message);
    }
}
  • commence()AuthenticationException이 발생할 경우 호출된다.

@Slf4j
@Component
public class MemberAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        ErrorResponder.sendErrorResponse(response, HttpStatus.FORBIDDEN);
        log.warn("Forbidden error happened: {}", accessDeniedException.getMessage());
    }
}
  • AccessDeniedHandler을 구현한 클래스다. handle()메소드는 요청한 리소스에 대한 사용자의 권한이 없을 경우 AccessDeniedException이 발생하여 호출된다.
  • 예외 처리도 구현하였으니, Config에 구현한 내용들을 모두 적용해 보자.

📌 Config 적용


@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtTokenizer jwtTokenizer;

    private final JwtAuthorityUtils authorityUtils;

  @Bean
  public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http.headers().frameOptions().sameOrigin()
      .and()
      .csrf().disable()
      .cors(Customizer.withDefaults())
      .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
      .and()
      .formLogin().disable()
      .httpBasic().disable()
      // 예외처리 handler 추가
      .exceptionHandling()
      .authenticationEntryPoint(new MemberAuthenticationEntryPoint())
      .accessDeniedHandler(new MemberAccessDeniedHandler())

      .and()
      .apply(new CustomFilterConfig())
      .and()
      .authorizeRequests(auth -> auth.anyRequest().permitAll());

    // .....
    //......

    public class CustomFilterConfig extends AbstractHttpConfigurer<CustomFilterConfig, HttpSecurity> {
      @Override
      public void configure(HttpSecurity builder) throws Exception {
        AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class);
        JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager, jwtTokenizer);

        jwtAuthenticationFilter.setFilterProcessesUrl("/auth/login");
        jwtAuthenticationFilter.setAuthenticationSuccessHandler(new MemberAuthenticationSuccessHandler());
        jwtAuthenticationFilter.setAuthenticationFailureHandler(new MemberAuthenticationFailureHandler());

        // 검증 필터 추가
        JwtVerificationFilter jwtVerificationFilter = new JwtVerificationFilter(jwtTokenizer, authorityUtils);

        builder.addFilter(jwtAuthenticationFilter)
          .addFilterAfter(jwtVerificationFilter, JwtAuthenticationFilter.class);

      }
    }
  }
}
  • 예외 처리 Handler를 추가 해주기 위해 FilterChain에 아래 코드를 추가해준다.
      .exceptionHandling()
      .authenticationEntryPoint(new MemberAuthenticationEntryPoint())
      .accessDeniedHandler(new MemberAccessDeniedHandler())
  • CustomFilterConfigconfigure()에 권한 검증 필터를 추가해 주고 아래와 같이 필터가 적용될 순서를 지정해 준다.
  builder.addFilter(jwtAuthenticationFilter)
          .addFilterAfter(jwtVerificationFilter, JwtAuthenticationFilter.class);
  • 여기까지 Spring Security + JWT 를 학습하고 구현한 내용을 정리해 보았다. 예제 프로젝트로 gitHub에서 전체 코드를 확인 할수 있다.
  • https://github.com/sussa3007/prac-project-foodservice
  • Spring Security + JWT을 적용한 이후 기존과 API 명세도 달라질 것이고, Test 코드도 권한에 따라 따로 작성해야 될 것이다. 위 연습 프로젝트는 계속 업데이트 할 예정이다.
profile
컴퓨터가 할일은 컴퓨터가

0개의 댓글