본문 바로가기
개발/Spring

JWT (2) 스프링에서 JWT 사용하기

by baau 2023. 5. 31.

Gradle 의존성 추가

implementation 'com.auth0:java-jwt:4.2.1'

https://mvnrepository.com/artifact/com.auth0/java-jwt/4.2.1

 

JWT 관련 설정 파일 생성

application-jwt.yml

jwt:
  secretKey: <64 bytes이상의 영숫자 조합>

  access:
    expiration: 3600000 # 1시간
    header: Authorization

  refresh:
    expiration: 1209600000 # 2주
    header: Authorization-refresh
  • jwt.secretKey
    • 서버가 가지고 있는 개인키
    • 암호화 알고리즘으로 HS512를 사용할 것이기 때문에, 64 bytes 이상의 영숫자 조합으로 아무렇게 작성
  • jwt.access.expiration & jwt.refresh.expration
    • AccessToken과 RefreshToken의 만료 시간 설정
    • AccessToken은 1시간, RefreshToken은 2주로 설정
  • jwt.access.header & jwt.refresh.header
    • AccessToken과 RefreshToken이 저장될 헤더의 Key 설정
    • AccessToken은 Authorization, RefreshToken : Authorization-refresh

 

JWT 관련 클래스

JwtService.java

@Service
@RequiredArgsConstructor
@Getter
@Slf4j
public class JwtService {

    @Value("${jwt.secretKey}")
    private String secretKey;

    @Value("${jwt.access.expiration}")
    private Long accessTokenExpirationPeriod;

    @Value("${jwt.refresh.expiration}")
    private Long refreshTokenExpirationPeriod;

    @Value("${jwt.access.header}")
    private String accessHeader;

    @Value("${jwt.refresh.header}")
    private String refreshHeader;

    private static final String ACCESS_TOKEN_SUBJECT = "AccessToken";
    private static final String REFRESH_TOKEN_SUBJECT = "RefreshToken";
    private static final String EMAIL_CLAIM = "email";
    private static final String BEARER = "Bearer ";

    private final UserRepository userRepository;

    /**
     * AccessToken 생성 메서드
     */
    public String createAccessToken(String email) {
        Date now = new Date();

        return JWT.create()
                .withSubject(ACCESS_TOKEN_SUBJECT)
                .withExpiresAt(new Date(now.getTime() + accessTokenExpirationPeriod))

                .withClaim(EMAIL_CLAIM, email)
                .sign(Algorithm.HMAC512(secretKey));
    }

    /**
     * RefreshToken 생성 메서드
     */
    public String createRefreshToken() {
        Date now = new Date();

        return JWT.create()
                .withSubject(REFRESH_TOKEN_SUBJECT)
                .withExpiresAt(new Date(now.getTime() + refreshTokenExpirationPeriod))
                .sign(Algorithm.HMAC512(secretKey));
    }

    /**
     * 응답 메시지에 AccessToken 헤더에 실어서 보내기
     */
    public void sendAccessToken(HttpServletResponse response, String accessToken) {

        response.setStatus(HttpServletResponse.SC_OK);
        response.setHeader(accessHeader, accessToken);
        log.info("재발급된 Access Token : {}", accessToken);
    }

    /**
     * 응답 메시지에 AccessToken + RefreshToken 헤더에 실어서 보내기
     */
    public void sendAccessAndRefreshToken(HttpServletResponse response, String accessToken, String refreshToken) {
        response.setStatus(HttpServletResponse.SC_OK);

        response.setHeader(accessHeader, accessToken);
        response.setHeader(refreshHeader, refreshToken);
        log.info("Access Token, Refresh Token 헤더 설정 완료");
    }

    /**
     * 헤더에서 AccessToken 추출
     */
    public Optional<String> extractAccessToken(HttpServletRequest request) {
        return Optional.ofNullable(request.getHeader(accessHeader))
                .filter(refreshToken -> refreshToken.startsWith(BEARER))
                .map(refreshToken -> refreshToken.replace(BEARER, ""));
    }

    /**
     * 헤더에서 RefreshToken 추출
     */
    public Optional<String> extractRefreshToken(HttpServletRequest request) {
        return Optional.ofNullable(request.getHeader(refreshHeader))
                .filter(refreshToken -> refreshToken.startsWith(BEARER))
                .map(refreshToken -> refreshToken.replace(BEARER, ""));
    }

    /**
     * AccessToken 에서 Email 추출
     */
    public Optional<String> extractEmail(String accessToken) {
        try {
            return Optional.ofNullable(JWT.require(Algorithm.HMAC512(secretKey))
                    .build()
                    .verify(accessToken)
                    .getClaim(EMAIL_CLAIM)
                    .asString());
        } catch (Exception e) {
            log.error("액세스 토큰이 유효하지 않습니다.");
            return Optional.empty();
        }
    }

    /**
     * RefreshToken DB 업데이트
     */
    @Transactional
    public void updateRefreshToken(String email, String refreshToken) {
        userRepository.findByEmail(email)
                .ifPresentOrElse(
                        user -> user.updateRefreshToken(refreshToken),
                        () -> new Exception("일치하는 회원이 없습니다.")
                );
    }
    
    /**
     * 토큰 검증
     */
    public boolean isTokenValid(String token) {
        try {
            JWT.require(Algorithm.HMAC512(secretKey)).build().verify(token);
            return true;
        } catch (Exception e) {
            log.error("유효하지 않은 토큰입니다. {}", e.getMessage());
            return false;
        }
    }
}

 

JwtAuthenticationProcessingFilter.java

@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationProcessingFilter extends OncePerRequestFilter {

    private static final String NO_CHECK_URL = "/login";

    private final JwtService jwtService;
    private final UserRepository userRepository;

    private GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper();

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        
        // "/login" 요청은 jwt 검증 X
        if (request.getRequestURI().equals(NO_CHECK_URL)) {
            filterChain.doFilter(request, response);
            return;
        }

        String refreshToken = jwtService.extractRefreshToken(request)
                .filter(jwtService::isTokenValid)
                .orElse(null);

        // AccessToken 만료되어, refreshToken이 요청 헤더에 포함되어 있을 경우
        if (refreshToken != null) {
            checkRefreshTokenAndReIssueAccessToken(response, refreshToken);
            return;
        }

        // AccessToken가 만료되지 않아, refreshToken이 없다.
        if (refreshToken == null) {
            checkAccessTokenAndAuthentication(request, response, filterChain);
        }

    }

    /**
     * AccessToken 검증 - 검증 성공 시 saveAuthentication 호출
     */
    private void checkAccessTokenAndAuthentication(HttpServletRequest request, HttpServletResponse response,
                                                   FilterChain filterChain) throws ServletException, IOException {

        jwtService.extractAccessToken(request)
                .filter(jwtService::isTokenValid)
                .ifPresent(accessToken -> jwtService.extractEmail(accessToken)
                        .ifPresent(email -> userRepository.findByEmail(email)
                                .ifPresent(this::saveAuthentication)));

        filterChain.doFilter(request, response);
    }

    /**
     * AccessToken 인증 성공 이후, 해당 유저 객체를 SecurityContextHolder에 담아 인증 처리
     */
    private void saveAuthentication(User user) {
        String password = user.getPassword();

        // 소셜 로그인의 경우 password가 null이기 때문에, 랜덤 패스워드 부여
        if (password == null) {
            password = PasswordUtil.generateRandomPassword();
        }

        UserDetails userDetails = org.springframework.security.core.userdetails.User.builder()
                .username(user.getEmail())
                .password(password)
                .roles(user.getRole().name())
                .build();

        Authentication authentication =
                new UsernamePasswordAuthenticationToken(userDetails, null
                        , authoritiesMapper.mapAuthorities(userDetails.getAuthorities()));

        SecurityContextHolder.getContext().setAuthentication(authentication);
    }

    /**
     * RefreshToken 검증 후, AccessToken과 RefreshToken 재발급
     */
    public void checkRefreshTokenAndReIssueAccessToken(HttpServletResponse response, String refreshToken) {
        userRepository.findByRefreshToken(refreshToken)
                .ifPresent(user -> {
                    String reIssuedRefreshToken = reIssueRefreshToken(user);
                    jwtService.sendAccessAndRefreshToken(response, jwtService.createAccessToken(user.getEmail()),
                            reIssuedRefreshToken);
                });
    }

    /**
     * RefreshToken 재발급 후, DB에 반영
     */
    private String reIssueRefreshToken(User user) {
        String reIssuedRefreshToken = jwtService.createRefreshToken();
        user.updateRefreshToken(reIssuedRefreshToken);
        userRepository.saveAndFlush(user);
        return reIssuedRefreshToken;
    }
}

 

  • AccessToken 만료 전 [if refreshToken == null]
    1. 클라이언트는 매 요청마다 Http Request Header에 AccessToken을 담아서 요청
    2. 서버에서는 Http Request Header에서 AccessToken을 추출하여, 검증 후 인증 처리
  • AccessToken 만료 후 [if refreshToken != null]
    1. 클라이언트에서 자체적으로 AccessToken의 만료를 판단 후, Http Request Header에 RefreshToken을 담아서 요청
    2. 서버는 요청 받은 RefreshToken이 DB에 저장된 RefreshToken과 일치하는지 판단
      1. 일치한다면, AccessToken과 RefreshToken을 재발급하여, Http Response Header에 담아서 전달
      2. 재발급한 RefreshToken으로 DB의 RefreshToken을 업데이트 (RTR 방식)
    3. 클라이언트는 서버로부터 재발급받은 AccessToken을 Http Request Header에 AccessToken을 담아서 요청
    4. 서버에서는 Http Request Header에서 AccessToken을 추출하여, 검증 후 인증 처리
  • RTR 방식
    • Refresh Token Rotation의 약자로, Refresh Token을 일회용을 사용하는 방법
    • RefreshToken을 사용하여 만료된 AccessToken을 재발급할 때, RefreshToken도 재발급한다.
    • RefreshToken을 AccessToken과 함께 재발급함으로써, RefreshToken이 탈취되는 위험을 줄일 수 있다.
    • 따라서 AccessToken을 재발급할 때, RefreshToken까지 재발급하여 DB에 업데이트해야 한다.

 

SecurityConfig.java

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final UserRepository userRepository;
    private final JwtService jwtService;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .formLogin().disable()
                .httpBasic().disable()
                .csrf().disable()
                .headers().frameOptions().disable()
                .and()

                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()

                .authorizeRequests()
                .requestMatchers("/sign-up").permitAll()
                .anyRequest().authenticated();

        http.addFilterBefore(jwtAuthenticationProcessingFilter(), CustomJsonAuthenticationFilter.class);

        return http.build();
    }

    @Bean
    public JwtAuthenticationProcessingFilter jwtAuthenticationProcessingFilter() {
        return new JwtAuthenticationProcessingFilter(jwtService, userRepository);
    }
}

 

  • http.addFilterBefore(jwtAuthenticationProcessingFilter(), CustomJsonAuthenticationFilter.class)
    • CustomJsonAuthenticationFilter(로그인 필터) 이전에 jwtAuthenticationProcessingFilter가 동작할 수 있도록
    • addFilterBefore(A, B) : B 필터 이전에 A필터가 동작하도록 하는 메서드