Backend/JWT

[JWT] JWT 인증/인가 적용기(2) - Spring Security를 통한 JWT 적용

호_두씨 2024. 1. 29. 09:43

개발 환경

spring boot 3.0.2

spring security : 6.0.1

Gradle

Spring Security를 통한 JWT 적용

JWT를 사용하기 위해 구현해야 할 것은 기본적으로 TokenProvider, TokenAuthenticationFilter이다.

  • JWT 토큰 제공을 위한 TokenProvider
  • HTTP Request에서 토큰을 읽어 들여 정상 토큰이면 Security Context에 저장하는 TokenAuthenticationFilter

그리고 Spring Security에 적용하기 위해 구현해야 할 것은 기본적으로 아래와 같다.

  • 기본적으로 Spring Security 설정을 위한 SecurityConfig

TokenProvider

@RequiredArgsConstructor
@Service
public class JwtTokenProvider {
    private final JwtProperties jwtProperties;

    private final UserDetailService userDetailService;

    private final RedisTemplate redisTemplate;

    private final static String TOKEN_PREFIX = "Bearer ";

    public String generateToken(User user, Duration expiredAt){
        Date now = new Date();
        return makeToken(new Date(now.getTime() + expiredAt.toMillis()),user);
    }

    private String makeToken(Date expiry, User user){
        Date now = new Date();

        return Jwts.builder()
                .setHeaderParam(Header.TYPE, Header.JWT_TYPE)
                .setIssuer(jwtProperties.getIssuer())
                .setIssuedAt(now)
                .setExpiration(expiry)
                .setSubject(user.getEmail())
                .claim("id",user.getId())
                .signWith(SignatureAlgorithm.HS256, jwtProperties.getSecretKey())
                .compact();
    }

    public boolean validToken(String token){
        try{
            Jwts.parser()
                    .setSigningKey(jwtProperties.getSecretKey())
                    .parseClaimsJws(token);
            return true;
        }
        catch (Exception e){
            return false;
        }
    }

    //토큰 기반으로 인증 정보를 가져오는 메서드
    public Authentication getAuthentication(String token) {
        //비밀값으로 토큰을 복호화한 뒤 클레임에 있는 사용자 이메일인 token subject를 가져온다
        Claims claims = getClaims(token);
        UserDetails userDetails = userDetailService.loadUserByUsername(claims.getSubject());
        return new UsernamePasswordAuthenticationToken(userDetails, token, userDetails.getAuthorities());
    }

    public Date getExpiration(String token){
        Claims claims = getClaims(token);

        return claims.getExpiration();
    }

    private Claims getClaims(String token) {
        return Jwts.parser()
                .setSigningKey(jwtProperties.getSecretKey())
                .parseClaimsJws(token)
                .getBody();
    }

    public String getToken(String authorizationHeader){
        //JWT가 Bearer 시작한다면 Bearer 빼고 return
        if(authorizationHeader != null && authorizationHeader.startsWith(TOKEN_PREFIX)){
            return authorizationHeader.substring(TOKEN_PREFIX.length());
        }
        return null;
    }

    public void removeTokenFromRedis(String token){
        if (redisTemplate.opsForValue().get(token) != null){
            // Refresh Token을 삭제
            redisTemplate.delete(token);

        }else{
            throw new IllegalArgumentException("refresh key에 대한 값이 없습니다.");
        }
    }

   	public Long getUserIdFromRedis(String token){
        Optional<Long> optionalValue = Optional.ofNullable(Long.valueOf((String) redisTemplate.opsForValue().get(token)));
        return optionalValue.orElseThrow(() -> new IllegalArgumentException("refresh key에 대한 값이 없습니다."));
    }

TokenAuthenticationFilter

@RequiredArgsConstructor
public class TokenAuthenticationFilter extends OncePerRequestFilter {
    private final JwtTokenProvider jwtTokenProvider;

    private final RedisTemplate redisTemplate;

    //요청이 오면 헤더값을 비교해서 토큰유무 및 유효성 체크
    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {
        //요청 헤더의 Authorization 키의 값 조회
        String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
        String token = jwtTokenProvider.getToken(authorizationHeader);

        //토큰 유효성 검사
        if(StringUtils.hasText(token) && jwtTokenProvider.validToken(token)){
            //Redis에 해당 accessToken logout 여부 확인(블랙 리스트)
            String isLogout = (String) redisTemplate.opsForValue().get(token);

            //로그아웃 하지 않은 경우
            if(ObjectUtils.isEmpty(isLogout)){
                //토큰으로부터 유저 정보 가져오기
                Authentication authentication = jwtTokenProvider.getAuthentication(token);
                // SecurityContext 에 Authentication 객체를 저장
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }

        filterChain.doFilter(request, response);
    }

요청이 들어오면 SecurityConfig 클래스의 filterChain에 설정한

http.addFilterBefore(tokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)으로 인해 

UsernamePasswordAuthenticationFilter 전에 TokenAuthenticationFilter를 거치게 된다.

이때 토큰 유효성 검사(정상 토큰)를 통해 Security Context에 저장한다.

SecurityConfig

  • 스프링 시큐리티 5.7.0 이상일 때 고려할 점
    • 기존에는 하나의 SecurityFilterChain을 구성하기 위해서 WebSecurityConfigurerAdapter를 상속한 클래스에서 config 메소드를 오버라이딩한다. 하지만 스프링 시큐리티 5.7.0 버전부터 WebSecurityConfigurerAdapter를 사용하지 않는다고 되어있다.

참고 링크 : https://spring.io/blog/2022/02/21/spring-security-without-the-websecurityconfigureradapter

그럼 어떻게 시큐리티 설정을 할까?

이전 : WebSecurityConfigurerAdapter를 상속받아 설정을 오버라이딩 하는 방식

이후 : 모두 Bean으로 등록하는 방식

중요 코드

  • TokenAuthenticationFilter 빈 등록
  • formLogin과 logout 비활성화해주기
  • UsernamePasswordAuthenticationFilter 이전에 TokenAuthenticationFilter 추가
  • AccessToken 만료되었을 때 /token 엔드포인트를 통해 재발급해야하기 때문에 permitAll() 설정
@EnableWebSecurity
@RequiredArgsConstructor
@Configuration
public class SecurityConfig {
    private final TokenProvider tokenProvider;

    @Bean
    public WebSecurityCustomizer configure() {
        return (web) -> web.ignoring()
                .requestMatchers(toH2Console())
                .requestMatchers("/img/**", "/css/**", "/js/**");
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
				//formLogin과 logout 비활성화해주기
        http.csrf().disable()
                .httpBasic().disable()
                .formLogin().disable()
                .logout().disable();

        http.sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS);

				//UsernamePasswordAuthenticationFilter 이전에 TokenAuthenticationFilter 추가
        http.addFilterBefore(tokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);

        http.authorizeRequests()
								//AccessToken 만료되었을 때 /token 엔드포인트를 통해 재발급 필요
                .requestMatchers("/token","/login","/signup").permitAll()
                .requestMatchers("/**").authenticated()
                .anyRequest().permitAll();

        http.exceptionHandling()
                .defaultAuthenticationEntryPointFor(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED),
                        new AntPathRequestMatcher("/**"));


        return http.build();
    }

    @Bean
    public TokenAuthenticationFilter tokenAuthenticationFilter() {
        return new TokenAuthenticationFilter(jwtTokenProvider,redisTemplate);
    }
}

 

다음 포스팅

  • 로그인 적용

[JWT] JWT 인증/인가 적용기(3) - 로그인

 

[JWT] JWT 인증/인가 적용기(3) - 로그인

목표 기능 회원가입 (깃허브 참고) 로그인 Access Token, Refresh Token 발급 Authorization Header 에 Access Token, Refresh Token 담아서 응답 보내기 서버에서 Access Token과 Refresh Token을 어디에 담아서 클라이언트에

hohodu.tistory.com