[프로젝트] Spring Security에서 JWT와 CustomUserDetails 활용하기

chaen-ing·2024년 12월 15일
0

저번 글에서 JWT 토큰을 발급 받는 과정까지 진행했었습니다.
이번 글에서는 JWT 인증 정보를 컨트롤러에서 어떻게 활용해야 할지,
필터에서 처리된 인증 정보를 어떻게 @AuthenticationPrincipal로 가져올 수 있는지에 대해 작동 방식과 사용 예시를 알아보도록 하겠습니다.

🔗 Spring Security의 Filter Chain

먼저 스프링에서는 필터를 적용하면 필터가 호출 된 다음에 디스패처 서블릿이 호출된다.

필터 흐름 : HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 컨트롤러

  • 예를 들어 인증이 되지 않은 사용자라면 필터에서 적절하지 않은 요청이라고 판단하고 서블릿 호출 없이 끝을 낼 수 있다.

필터는 체인으로 구성되어서, 중간에 필터를 여러개 자유롭게 추가할 수 있다.

  • 예를 들어서 로그를 남기는 필터를 먼저 적용하고, 그 다음에 로그인 여부를 체크하는 필터를 만들 수 있다.

JwtAuthFilter

JWT 토큰을 추출하고 검사 후 인증객체를 생성하는 과정 또한 필터로 진행할 수 있다.

@RequiredArgsConstructor
public class JwtAuthFilter extends GenericFilterBean {

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {

        // 헤더에서 JWT 토큰 추출
        String token = jwtTokenProvider.resolveToken((HttpServletRequest) request);

        // 유효성 검사 후 SecurityContext에 저장
        if (token != null && jwtTokenProvider.validateToken(token)) {
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

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

순서는 아래와 같다
1. HTTP 요청 헤더에서 JWT 토큰 추출 (Authorization 헤더)
2. 토큰 유효성 검증 및 사용자 정보 추출
3. 인증 객체 생성(UsernamePasswordAuthenticationToken)
4. SecurityContextHolder에 인증 객체 저장

1,2,3번은 저번에 작성해준 JwtTokenProvide에 이어서 작성했다.

JwtTokenProvider

 // JWT 토큰에서 인증 정보 조회
    public Authentication getAuthentication(String token) {
        UserDetails userDetails = customUserDetailService.loadUserByUsername(this.getUserPk(token));
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

    // JWT 토큰의 유효성 + 만료일자 확인
    public boolean validateToken(String jwtToken) {
        try {
            Jws<Claims> claimsJws = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken);
            return !claimsJws.getBody().getExpiration().before(new Date());
        } catch (Exception e) {
            return false;
        }
    }

    // Request의 Header에서 token 값을 가져오기
    public String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7); // "Bearer " 이후의 토큰을 반환
        }
        return null;
    }

    // 토큰에서 회원 정보 추출
    public String getUserPk(String token) {
        return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
    }

resolveToken() : Authorization 헤더에서 토큰 추출

  • HTTP 요청에서 Authorization 헤더를 가져와서
  • Bearer로 시작하는지 검증 후 이후의 토큰만 반환

나는 이 과정을 생략했어서 계속 403에러가 났었다...꼭 토큰 추출해줘야된다...

validateToken() : 토큰의 유효성 검사

  • 토큰을 파싱해서 서명 검증 후 유효하면 Claims 객체를 받고
  • 객체의 유효기간이 지났으면 false 반환

getAuthentication() : 인증 정보를 추출하여 Authentication 객체를 생성

  • 토큰을 파싱해서 Subject로 설정했던 PK를 꺼낸다
  • customUserDetailService에서 UserDetails 객체 생성
  • UsernamePasswordAuthenticationToken 객체에 유저정보인 CustomUserDetails, 자격증명, 권한정보를 담아보낸다.

여기까지 진행후에 Filter에서 SecurityContext에 Authentication 객체를 setAuthentication()으로 저장해주면 컨트롤러 같은 계층에서 요청시에 참조가 가능하다.

CustomUserDetails & CustomUserDetailService

@AllArgsConstructor
public class CustomUserDetails implements UserDetails {

    private Long id;
    private String nickname;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return null;
    }

    @Override
    public String getPassword() {
        return null;
    }

    @Override
    public String getUsername() {
        return nickname;
    }

    public Long getId() {
        return id;
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

UserDetails를 implement 해주면 되고 난 권한정보는 생략하고 필요한 것만 세팅해줬다.
프로젝트에서 필요한것들로 커스텀해주면 된다.

@Service
@RequiredArgsConstructor
public class CustomUserDetailService implements UserDetailsService {

    private final UserRepository userRepository;

    // pk 받아서 해당 유저를 찾아 CustomUserDetails로 반환
    @Override
    public UserDetails loadUserByUsername(String id) throws UsernameNotFoundException {
        User user =
                userRepository
                        .findById(Long.parseLong(id))
                        .orElseThrow(() -> new UsernameNotFoundException(USER_NOT_FOUND.getMessage()));

        return new CustomUserDetails(user.getId(), user.getNickname());
    }
}

필터 체인에 추가

 @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf(AbstractHttpConfigurer::disable) // CSRF 비활성화
                .sessionManagement(
                        session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션 비활성화
                .authorizeHttpRequests(
                        authorize ->
                                authorize
                                        .requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html")
                                        .permitAll() // Swagger 경로는 누구나 접근 가능
                                        .requestMatchers("/api/v1/auth/**")
                                        .permitAll()
                                        .anyRequest()
                                        .authenticated() // 그 외의 경로는 인증된 사용자만 접근 가능
                        )
                .addFilterBefore(
                        new JwtAuthFilter(jwtTokenProvider),
                        UsernamePasswordAuthenticationFilter.class); // JWT 필터 추가

        return http.build();
    }

컨트롤러에서 사용하기

위의 과정을 완료하면 스프링 security holder에 인증 객체가 저장이 된 것으로 컨트롤러에서 사용할 수 있다.

@PostMapping("/profile")
public ResponseEntity<ApiResponse<?>> profile(
            @Valid @RequestBody AddUserProfileRequest request,
            @AuthenticationPrincipal CustomUserDetails customUserDetails) {

	userService.updateProfile(request, customUserDetails.getId());

	return ResponseEntity.status(HttpStatus.OK).body(ApiResponse.EMPTY_RESPONSE);
}

Filter가 요청을 처리하면서 Authentication 객체를 SecurityContextHolder에 저장하게되고
컨트롤러에서는 @AuthenticationPrincipal을 통해 CustomUserDetails 객체를 바로 주입받아 위와 같이 사용할 수 있다

authorization header를 포함한 상태에서(자물쇠모양) 요청을 보내면 실행이 되는 것 확인완료!

여기까지 카카오 소셜 로그인 + jwt 토큰을 사용한 인증 + spring security에서 CustomUserDetails 활용에 대해 알아보았다.

📌 앞으로...

  • Spring Security에 대해 더 공부가 필요할듯
  • 권한도 추가해보자

참고 블로그 🙇
https://velog.io/@win-luck/Springboot-카카오-소셜로그인-Jwt-토큰-발급-및-API-검증

profile
💻 개발 공부 기록장

0개의 댓글

관련 채용 정보