[Java]JWT 토큰 설정 2편 (JwtTokenFilter)

정석환·2025년 5월 13일

사전 설명

JwtTokenFilter가 하는 역활

jwt 토큰 필터는 Http요청이 들어오면 토큰이 유효한지 확인한다.
유효 하지 않다면 401 error를 띄워준다.

TokenBody dto

@Data
@AllArgsConstructor
public class TokenBody {

    private Long memberId;
    private String role;

}

jwt 토큰에서 memberId와 권한을 가져오기 위한 dto

JwtTokenProvider (기존 코드 추가)

@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
//  jwt 토큰을 발급, 검증, 파싱 하는 클래스
public class JwtTokenProvider {
    // access Token과 Refresh 토큰의 재발급 정보를 담당
    private final JwtConfiguration configuration;

    // Refresh Token의 발급, 조회, 블랙리스트 등록을 담당
    private final TokenRepository refreshTokenRepositoryAdapter;

    // 시크릿 키 생성
    private SecretKey getSecretKey() {
        // JJWT 라이브러리의 유틸 클래스인 io.jsonwebtoken.security.Keys에서 제공하는 메서드로,HMAC 방식 서명을 위한 시크릿 키를 생성
        return Keys.hmacShaKeyFor(configuration.getSecret().getAppKey().getBytes());
    }

    //jwtToken 생성
    private String issue(Long memberId, String role, Long validTime) {

        // Payload = subject, claim("role"), issuedAt(iat), expiration(exp)
        // Signature  = signWith를 통해 생성
        // 아래의 코드에는 header에 해당되는 코드가 없는데 자동적으로 생성된다.  (alg: HS256, typ: JWT)
        String jwtToken = Jwts.builder()
                .setSubject(memberId.toString())                          // subject: 사용자 ID
                .claim("role", role)                                   // 사용자 역할(권한)을 추가
                .issuedAt(new Date())                                     // 발급 시간 (iat) 현재 시간
                .expiration(new Date(new Date().getTime() + validTime))   // 만료 시간 (ext) 현재 시간 + yml 설정 시간
                .signWith(getSecretKey(), Jwts.SIG.HS256)                 // 시크릿 키로 서명하여 Signature 생성
                .compact();                                               //  Header + Payload + Signature 결합 → 최종 JWT 문자열 반환

        return jwtToken;
    }

    // Accesss 토큰 생성
    public String issueAccessToken(Long memberId, String role) {
        return issue(memberId, role, configuration.getValidation().getAccess());
    }

    // Refresh 토큰 생성
    public String issueRefreshToken(Long memberId, String role) {
        return issue(memberId, role, configuration.getValidation().getRefresh());
    }

    // 토큰 두개를 묶는다.
    public KeyPair generateKeyPair(Member member) {

        String accessToken = issueAccessToken(member.getId(),member.getRole().name());

        String refreshToken = issueRefreshToken(member.getId(),member.getRole().name());

        refreshTokenRepositoryAdapter.save(member, refreshToken);

        KeyPair jwtTokens = KeyPair.builder()
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .memberId(member.getId().toString())
                .build();

        return jwtTokens;
    }

    //특정 사용자의 유효한 RefreshToken이 DB에 있는지 확인
    public RefreshToken validateRefreshToken(Long memberId) {

        Optional<RefreshToken> validRefTokenOptional = refreshTokenRepositoryAdapter.findValidRefTokenByMemberId(memberId);


        return validRefTokenOptional.orElse(null);
    }
	
    ////추가 되는 부분 ↓////
    ////추가 되는 부분 ↓////
    
    // 클라이언트가 보낸 JWT의 유효성을 서명(Signature) 기반으로 검증한다.
    // - parser(): JWT 문자열을 파싱할 준비를 한다.
    // - verifyWith(): 서명 검증에 사용할 SecretKey를 설정한다. (구버전은 setSigningKey() 사용 → 허용 타입 제한적)
    // - parseSignedClaims(): 토큰을 header.payload.signature로 분리하고, 서명이 유효한지 확인한다.
    public boolean validate(String token) {
        try {
            Jwts.parser()
                    .verifyWith(getSecretKey())
                    .build()
                    .parseSignedClaims(token);
            return true;

        // JWT 관련 최상위 예외	만료, 위조, 포맷 문제 등 대부분의 JWT 검증 실패 시 발생
        } catch (JwtException e) {
            log.info("JWT 토큰에 문제가 있습니다. = {}", e.getMessage());
            log.info("TOKEN : {}", token);

        // Java 기본 예외	null이거나 빈 토큰이 들어온 경우
        } catch (IllegalArgumentException e) {
            log.info("JWT 토큰이 Null입니다. = {}", e.getMessage());

        // 모든 나머지 예외 예상 못 한 오류 (시스템 오류 등)
        } catch (Exception e) {
            log.info("JWT 토큰 검증 중 예상치 못한 예외가 발생 했습니다. = {}", e.getMessage());
        }

        return false;
    }

    //토큰 내부 정보를 파싱해서 사용자 ID (sub)와 역할 (role)을 꺼냄
    public TokenBody parseJwt(String token) {
        Jws<Claims> parsed = Jwts.parser()
                .verifyWith(getSecretKey())
                .build()
                .parseSignedClaims(token);

        return new TokenBody(
                // 토큰을 만들때 payload의 Subject 에 setter 로 memeberId를 넣었음, memberId 반환 받기
                Long.parseLong(parsed.getPayload().getSubject()),
                // role이라는 커스텀 Claim을 가져온다.
                parsed.getPayload().get("role").toString()
        );
    }

}

기존 코드에서 추가 된 부분이 validate랑 parseJwt이가 있다.

JwtTokenFilter

@Slf4j
@Component
@RequiredArgsConstructor
//OncePerRequestFilter는 매 http요청 마다 한번만 실행되는 필터
public class JwtTokenFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;
    private final MemberRepository memberRepository;


    // jwt 인증의 핵심 메소드 이며 토큰 추출, 유효성 검사, 사용자 정보 추출, DB에서 사용자 조회,SecurityContext에 등록할 인증 객체 생성 및 인증 객체 설정을 해야한다.
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        log.info("jwt 필터 도착");
        
        // 토큰 추출
        String realToken = resolveToken(request);

        if (realToken == null || !jwtTokenProvider.validate(realToken)) {
            response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Access token invalid or expired");
            return;
        }

        // 유효성 검사
        if (realToken != null && jwtTokenProvider.validate(realToken)) {
            // 사용자 정보 추출
            TokenBody tokenBody = jwtTokenProvider.parseJwt(realToken);
            // DB에서 사용자 조회
            Member member = memberRepository.findById(tokenBody.getMemberId())
                    .orElseThrow(() -> new MemberNotFound(ExceptionMessage.MEMBER_NOT_FOUND));

            //SecurityContext에 등록할 인증 객체 생성
            //Spring Security의 인증 처리 규칙에 따라, SecurityContext에는 반드시 UserDetails 또는 OAuth2User를 구현한 인증된 사용자 객체가 들어가야한다.
            //attributes란?	OAuth2 로그인 시, 제공자로부터 받은 사용자 정보 (JSON)
            //JWT 기반 인증에서는 클라이언트(브라우저 등)에서 이미 인증이 끝난 후, JWT만 주고받기 때문에 attributes는 필요 없다.
            CustomUserPrincipal customUserPrincipal = CustomUserPrincipal.from(member,null);
            log.info("customUserPrincipal.getId() = {}", customUserPrincipal.getId());

            // Spring Security는 JWT 내부 정보를 자동으로 인식하지 못하기 때문에 파싱한 사용자 정보 및 권한을 직접 Authentication 객체에 담아서 알려줘야 한다.
            // SecurityContext에 인증 객체 설정 (유저 정보, jwt토큰, 권한)
            Authentication authentication = new UsernamePasswordAuthenticationToken(customUserPrincipal, realToken, customUserPrincipal.getAuthorities());
            log.info("authentication.getPrincipal() = {}", ((CustomUserPrincipal) authentication.getPrincipal()).getEmail());
            log.info("authentication.getPrincipal() = {}", ((CustomUserPrincipal) authentication.getPrincipal()).getId());


            // SecurityContextHolder에 인증 정보를 넣어 줌으로써 현재 요청을 보낸 사용자가 인증된 사용자임을 Spring Security에게 알려준다.
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        log.info("jwt 필터 성공");
        //	필터 체인 계속 진행
        filterChain.doFilter(request, response);
    }


    // http 요청에서 토큰만 추출 한다.
    private String resolveToken(HttpServletRequest request) {
        // 요청 헤더 중 Authorization 값을 가져온다
        // 요청 헤드의 생김새 Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0...
        String bearerToken = request.getHeader("Authorization");

        // Bearer로 시작하는지 확인
        if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
            log.info("resolvToken");
            log.info("bearerToken.substring(7) = {}", bearerToken.substring(7));
            // Bearer 이후 실제 토큰 문자열만 잘라서 반환
            return bearerToken.substring(7);
        }
        return null;
    }

}

Security Config

@Configuration
@RequiredArgsConstructor
public class SecurityConfig {

    private final CustomOAuth2UserService customOAuth2UserService;
    private final OAuth2SuccessHandler oAuth2SuccessHandler;
    private final JwtTokenFilter jwtTokenFilter;


    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http

                //cors 기본 설정 활성화
                .cors(Customizer.withDefaults())

                //csrf 비활성화
                .csrf(csrf -> csrf.disable())

                // form 로그인 비활성화
                .formLogin(formLogin -> formLogin.disable())

                //인증 권한 설정
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/", "/login/**", "/oauth2/**").permitAll()
                        .requestMatchers("/api/auth/logout", "/api/auth/reissue").permitAll()
                        .requestMatchers("/api/member").authenticated()
                        .anyRequest().authenticated()
                )

                // Oauthlogin 설정
                .oauth2Login(oauth2 -> oauth2
                        .userInfoEndpoint(userInfo -> userInfo
                                .userService(customOAuth2UserService)
                        )
                        //로그인이 성공 했을 때 작동하는 핸들러
                        .successHandler(oAuth2SuccessHandler)
                )

                // jwt 필터 설정
                .addFilterBefore(jwtTokenFilter, UsernamePasswordAuthenticationFilter.class)

                .build();
    }
}

이렇게 Security Config 까지 설정을 해주면 jwtTokenFilter에 대한 설정이 끝난다

현재는 Access 코드를 재발급 하고 검사하는 코드가 작성되지 않았으며, ogout을 했을 때 Refresh 토큰을 블랙 리스트로 등록 하는 코드가 없다.

다음편에는 재발급 코드와 Logout 코드를 만들어보자.

profile
자바,스프링 백엔드 개발자를 꿈꾸는 초보아빠

0개의 댓글