영원히 남는 기록, 재밌게 쓰자

Spring Security 와 JWT를 활용한 API 인가 로직 구현 및 적용 해보기 본문

springboot

Spring Security 와 JWT를 활용한 API 인가 로직 구현 및 적용 해보기

youngjae-kim 2024. 6. 20. 18:42
728x90
반응형

이전 글

 

Spring Security와 JWT 토큰 방식 이용한 로그인 인증

로그인 인증 관련을 적용해 보기 위해서는 spring security에서 사용자 정보를 다룰 때에 사용되는 클래스와 인터페이스에 대해 짚고 넘어가야 한다. UserUser클래스는 Spring Security에서 제공하는 디폴

happy-youngjae.tistory.com

 

이전 글에서 토큰 방식을 이용한 로그인 인증을 구현해보았다.

로그인 인증(Authentication)을 통해 발급 받은 JWT(Json Web Token)를 사용하여 API요청 시 인가(Authorization)에 사용해보면서 구현한 로직들에 대해 알게된 내용들을 정리해보았다. 

 

인가를 적용시키기 위해서, API에 접근하기 전에, Request Header에 있는 JWT 토큰을 추출하여, 토큰의 내용을 검증, 즉 인증 내용을 검증하는 필터를 작성해보겠다.

 

인가 (Authorization)

JwtAuthFilter

@Slf4j
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {

    public static final String AUTHORIZATION_HEADER = "Authorization";

    private final JwtUtil jwtUtil;
    private final CustomUserDetailsService customUserDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        log.debug("================================================================================ Security filter chain start ================================================================================");

        String token = jwtUtil.resolveToken(request);

        //JWT가 헤더에 있는 경우
        if (token != null) {

            //JWT 유효성 검증
            if (jwtUtil.validateToken(token)) {
                Authentication authentication = jwtUtil.getAuthentication(token);

                //유저와 토큰 일치 시 userDetails 생성
                UserDetails userDetails = customUserDetailsService.loadUserByUsername(authentication.getName());

                if (userDetails != null) {
                    //UserDetsils, Password, Role -> 접근권한 인증 Token 생성
                    UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
                            new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());

                    //현재 Request의 Security Context에 접근권한 설정
                    SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
                    log.debug("Security Context에 '{}' 인증 정보를 저장했습니다, URI: {}", authentication.getName(), request.getRequestURI());
                }
            } else {
                log.debug("유효성 검증에 실패했습니다.");
            }
        }

        log.debug("================================================================================ Security filter chain end ================================================================================");

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

 

  • 서버가 클라이언트의 request를 받아 컨트롤러 메서드를 호출하기전 이 request를 가로채어 Header에 로그인 시 담아준 JWT에 대한 검증을 수행하는 JwtAuthFilter가 동작

 

  • OncePerRequestFilter vs GenericFilterBean

인증/인가를 구현하기 위해 여러 레포를 참고하였는데, 대체로 filer클래스가 OncePerRequestFilter를 상속받는 경우와 GenericFilterBean을 상속받는 경우로 나뉘었다.
둘 다 기본적인 인증 필터 기능에는 문제가 없지만 어떤 차이가 있는지 찾아봤다.

스프링에서, 디스패처 서블릿이 서블릿 컨테이너 앞에서 모든 요청을 컨트롤러에 전달한다.
서블릿은 요청마다 서블릿을 생성하여 메모리에 저장한 뒤 같은 클라이언트의 요청이 들어올 경우 생성해둔 서블릿 객체를 재활용한다.
그런데 만약 서블릿이 다른 서블릿으로 dispatch하게 되면, 다른 서블릿 앞단에서 filter chain을 한번 더 거치게 된다.
이 차이때문에 OncePerRequestFilter를 사용한다.

쉽게 말해, 내부적으로 프로젝트의 다른 API에 요청할 때마다 모든 API가 동일한 보안 필터를 갖기 때문에 동일한 인증이 다시 발생하게 된다.
이를 막기 위해서 OncePerRequest를 상속받아 AuthenticationFilter를 구현하는 것이 더 나은 선택!

 

  • 유효한 토큰이라면 loadUserByUsername()으로 유저가 존재하는지 검증을 진행한다.
    • validateToken()을 통해 내가 발급한 토큰이 맞는지 유효기간이 지나지 않은 토큰인지 등을 판단 (유효성 판단)
  • UserPasswordAuthenticationToken(스프링 시큐리티 내부에서 인가에 사용되는 클래스)를 통해 시큐리티에서 서버에서 사용자가 존재하는지 찾은 뒤에 현재 요청 스레드의 Context(SecurityContextHolder)에 추가한다.
  • Context에 추가된다는 것은 해당 요청이 필터를 거쳐서 정상적으로 인가에 성공하여 승인된 Request(요청)이라는 뜻이다.

 

SpringSecurityContext

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
public class SecurityConfig {
    private static final String[] AUTH_WHITELIST = {"/api/user/**", "/api/teacher/**", "/api/student/**", "/api/class"};

    private final CustomAccessDeniedHandler accessDeniedHandler;
    private final CustomAuthenticationEntryPoint authenticationEntryPoint;
    private final CustomUserDetailsService customUserDetailsService;
    private final JwtUtil jwtUtil;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

        // CSRF 보호 비활성화
        http.csrf((csrf) -> csrf.disable());
        http.cors(Customizer.withDefaults());

        //세션 관리 상태 없음으로 구성, Spring Security가 세션 생성 or 사용 X
        http.sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(
                SessionCreationPolicy.STATELESS));

        //FormLogin, BasicHttp 비활성화
        http.formLogin((form) -> form.disable());
        http.httpBasic(AbstractHttpConfigurer::disable);

        //JwtAuthFilter를 UsernamePasswordAuthenticationFilter 앞에 추가
        http.addFilterBefore(new JwtAuthFilter(jwtUtil, customUserDetailsService), UsernamePasswordAuthenticationFilter.class);

        // 권한 규칙 작성
        http.authorizeHttpRequests(authorize -> authorize
                        .requestMatchers(AUTH_WHITELIST).permitAll()
                        //@PreAuthorization을 사용할 것이기 때문에 모든 경로에 대한 인증 처리는 Pass
                        .anyRequest().permitAll()
//                        .anyRequest().authenticated()
        );

        // 인증과 인가에 실패 했을 때 처리할 exception handler
        http.exceptionHandling((exceptionHandling) -> exceptionHandling
                .authenticationEntryPoint(authenticationEntryPoint)
                .accessDeniedHandler(accessDeniedHandler)
        );

        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

 

addFilterBefore(new JwtAuthFilter(jwtUtil, customUserDetailsService), UsernamePasswordAuthenticationFilter.class);

를 추가해주었다. JWT 기반 인증을 Spring Security의 기본 인증 필터보다 우선적으로 적용한다는 설정

 

크게 요청흐름을 정리하면

1. 사용자가 로그인 이후 Request Header에 JWT를 포함하여 API요청을 수행한다.

2. JwtAuthFilter에서 해당 요청을 가로채어 유효성 검증을 수행한다.

3. 해당 과정이 실패하게 되면 컨텍스트에 인증 정보를 저장하지 않고 다음 필터로 넘긴다.

 

컨트롤러(Controller)

 

이전의 컨트롤러 로직

// 요청 api 메서드
@GetMapping("/{teacherId}")
public ResponseEntity<TeacherDto> getInfo(@PathVariable Long teacherId) {
    return teacherService.getInfo(teacherId);
}

 

요청으로 받아온 PathVariable을 사용하는데 url의 id 값만 바꿔도 세션이나 따로 검증로직이 없다면 다른 사용자의 정보가 노출될 수 있는 문제가 있었다.

 

JWT 방식을 사용하는 컨트롤러 로직

@GetMapping("/{teacherId}")
@Secured("ROLE_TEACHER")
public ResponseEntity<TeacherDto> getInfo(HttpServletRequest request) {
    return teacherService.getInfo(request);
}

 

SecurityConfig에서 엔드 포인트 별 권한 규칙을 메서드 별로 권한 접근 제어한다고 설정했었다.

@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)

 

@Secured("{권한}") 어노테이션을 사용하면 해당 권한을 가진 요청만 접근할 수 있다.

 

선생님 마이페이지를 조회하는 테스트를 postman에서 진행해 보았다.

 

postman에서 토큰을 요청에 넣어줄 때는 postman에서 로그인 테스트로 받았던 토큰 정보를 테스트하고자 하는 Authorization의 Bearer Token을 선택하고 넣은 뒤 요청을 보내면 된다.

 

 

선생님 정보를 잘 가져오는 것을 확인할 수 있었다.

 

이제 스프링 시큐리티와 JWT 인증과 인가를 바탕으로 각 요청 별 권한을 주어 프로젝트를 진행할 해보자!!

 

 

참고 및 스크랩

https://sjh9708.tistory.com/170

https://velog.io/@wellsy1012/SpringSecurity-OncePerRequestFilter%EC%99%80-GenericFilterBean%EC%9D%98-%EC%B0%A8%EC%9D%B4

 

728x90
반응형