Spring Security (Access Token, Refresh Token), Redis

형석이의 성장일기·2024년 4월 2일
1

Spring Security

목록 보기
2/6

Access Token

  • 기존의 JWT 와 똑같음
  • 하지만 보안상의 문제를 극복하기 위해 기존의 JWT만 사용하는 방법보단 유효기간이 훨씬 짧음 → 금방 만료됨

Refresh Token

  • Refresh Token을 사용하는 목적은 금방 만료되는 Access Token 을 재발급 받기 위함임 → 그렇기 때문에 Access Token보다 긴 유효기간을 가짐
  • 주로 보관에 Redis 를 사용함 (근데 DB에 보관해도 되긴 함)
  • 동작 과정
    1. Access Token이 만료된 경우, Payload 에 적힌 유저 정보를 보고 Redis의 Refresh Token조회
    2. Refresh Token이 만료된 경우
      1. 다시 재 로그인 해야 함
    3. Refresh Token이 살아있는 경우
      1. 유저에게 Access Token을 재발급

Access Token + Refresh Token 동작 과정


Spring Security + (Access Token + Refresh Token) + Redis 예제 코드

Spring Security 를 사용한 토큰 발급 흐름

  1. Authentication Token (인증용 토큰) 발급
  2. Authentication Manager 가 위에서 생성된 인증용 토큰을 전달 받고, Authentication 객체를 생성함
  3. 생성된 Authentication 객체를 JwtTokenProvider 에게 전달하여 Access Token 과 Refresh Token 을 생성함

또한 위의 토큰 발급 과정을 구현하기 위해선 Spring Security 를 위한 기본 설정이 필요함

Spring Security 기본 설정

  • JwtSecurityConfig : 토큰 발급용 코드인 JwtTokenProvider 와 인증/인가용 필터인 JwtFilter 을 Spring Security Filter Chain 에 추가하기 위한 설정 코드
    • Spring Security Filter Chain 이란 Filter Chain 의 개념과 동일함. 요청이 발생했을 때, 적용시키기 위한 Filter 를 설정하는 것
  • SecurityConfig : 인증/인가를 사용할 API 설정 코드

JwtTokenProvider

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtTokenProvider {

    private final RedisTemplate<String, String> redisTemplate;

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

    @Value("${spring.jwt.token.access-expiration-time}")
    private long accessExpirationTime;

    @Value("${spring.jwt.token.refresh-expiration-time}")
    private long refreshExpirationTime;

    private final UserDetailsServiceImpl userDetailsService;

    /**
     * Access Token 생성
     */
    public String generateAccessToken(Authentication authentication){
        Claims claims = Jwts.claims().setSubject(authentication.getName());
        Date now = new Date();
        Date expireDate = new Date(now.getTime() + accessExpirationTime);

        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(expireDate)
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();
    }

    /**
     * Refresh Token 생성
     */
    public void generateRefreshToken(Authentication authentication){
        Claims claims = Jwts.claims().setSubject(authentication.getName());
        Date now = new Date();
        Date expireDate = new Date(now.getTime() + refreshExpirationTime);

        String refreshToken = Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(expireDate)
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();

        // redis 에 저장
        redisTemplate.opsForValue().set(
                authentication.getName(),
                refreshToken,
                refreshExpirationTime,
                TimeUnit.MILLISECONDS
        );
    }

    /**
     * 토큰으로부터 Claims 을 만들고, 이를 통해 User 객체와 Authentication 객체 리턴
     */
    public Authentication getAuthentication(String token) {
        String email = Jwts.parser().
                setSigningKey(secretKey)
                .parseClaimsJws(token)
                .getBody().getSubject();

        UserDetails userDetails = userDetailsService.loadUserByUsername(email);

        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

    /**
     * Token 검증
     */
    public boolean validateToken(String token){
        try{
            Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
            return true;
        } catch(ExpiredJwtException e) {
            log.error("Token 만료");
            throw new RuntimeException("Token 만료. 재발급 필요");
        } catch(JwtException e) {
            log.error("잘못된 Access Token 타입");
            throw new RuntimeException("잘못된 Access Token 타입");
        } catch (IllegalArgumentException e) {
            log.error("헤더가 비어있음");
            throw new RuntimeException("헤더가 비어있음");
        }
    }
}
  • generateAccessToken : Authentication 객체를 사용해 Access Token 을 생성
  • generateRefreshToken : Authentication 객체를 사용해 Refresh Token 을 생성 및 Redis 저장
    • 키 : 유저 이메일
    • 값 : Refresh Token
  • getAuthentication : Access Token 이나 Refresh Token 에서 유저의 정보를 추출할 때 사용
  • validateToken : 파라미터로 입력된 Access Token 이나 Refresh Token 을 검증함

JwtFilter

@Slf4j
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;

    private static final List<String> EXCLUDE_URL =
            Collections.unmodifiableList(
                    Arrays.asList(
                            "/api/v1/user/login",
                            "/api/v1/user/join",
                            "/api/v1/user/reissue"
                    ));

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        String token = resolveToken(request);

        if (jwtTokenProvider.validateToken(token)) {
            // HTTP 헤더에 입력한 Access Token을 사용해 인증용 객체인 authentication을 생성
            Authentication authentication = jwtTokenProvider.getAuthentication(token);

            // 접근한 유저의 authentication 객체를 SecurityContextHolder에 저장함
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

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

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
        return EXCLUDE_URL.stream().anyMatch(exclude -> exclude.equalsIgnoreCase(request.getServletPath()));
    }

    /**
     * HTTP 헤더에서 Access Token 추출
     */
    public String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");

        if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }

        return null;
    }
}
  • EXCLUDE_URL : 필터를 거치면 안되는 요청 URL을 담은 리스트
  • doFilterInternal : 요청이 들어오면 거치는 필터 로직
    • 토큰 추출 → 토큰 검증 → 인증용 객체 저장
  • shouldNotFilter : 필터를 거치면 안되는 예외 URL 설정
  • resolveToken : HTTP 헤더에서 Access Token 추출

JwtSecurityConfig

@RequiredArgsConstructor
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public void configure(HttpSecurity http) throws Exception {
        JwtFilter jwtFilter = new JwtFilter(jwtTokenProvider);
        http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
    }
}
  • 이 클래스는 Spring Security 를 사용하기 때문에, 직접 구현한 JwtTokenProvider와 JwtFilter를 SecurityConfig에 적용할 때 사용됨

SecurityConfig

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtTokenProvider jwtTokenProvider;

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

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf(AbstractHttpConfigurer::disable);

        // 권한 규칙 설정
        http.authorizeHttpRequests(
                authorize -> authorize
                        .requestMatchers("/api/v1/user/login").permitAll()
                        .requestMatchers("/api/v1/user/join").permitAll()
                        .requestMatchers("/api/v1/user/reissue").permitAll()
                        .anyRequest().authenticated()
        ).apply(new JwtSecurityConfig(jwtTokenProvider));

        return http.build();
    }

}
  • 사용할 Filter(JwtTokenProvider, JwtFilter)filterChain 으로 사용하기 위해 스프링 컨테이너에 등록

UserController

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/user")
public class UserController {

    private final AuthService authService;

    /**
     * 회원가입
     */
    @PostMapping("/join")
    public ResponseEntity<String> join(@RequestBody UserDto userDto) {
        authService.join(userDto);
        return ResponseEntity.status(HttpStatus.OK).body("회원가입 완료");
    }

    /**
     * 로그인
     */
    @PostMapping("/login")
    public ResponseEntity<UserDto> login(@RequestBody UserDto userDto) {
        return ResponseEntity.status(HttpStatus.OK).body(authService.login(userDto));
    }

    @GetMapping("/reissue")
    public ResponseEntity<TokenDto> reissue(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization-refresh");
        String refreshToken = "";

        if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
            refreshToken = bearerToken.substring(7);
        }

        String newAccessToken = authService.reissueAccessToken(refreshToken);

        return ResponseEntity.status(HttpStatus.OK).body(new TokenDto(newAccessToken, ""));
    }

    @GetMapping("/test")
    public ResponseEntity<String> accessTokenTest() {
        return ResponseEntity.status(HttpStatus.OK).body("인증 성공");
    }
}

AuthService

@Slf4j
@Service
@RequiredArgsConstructor
public class AuthService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final JwtTokenProvider jwtTokenProvider;
    private final RedisTemplate<String, String> redisTemplate;
    private final AuthenticationManagerBuilder authenticationManagerBuilder;

    public void join(UserDto userDto) {
        User user = userDto.toUser(passwordEncoder);
        userRepository.save(user);
    }

    @Transactional
    public UserDto login(UserDto userDto) {
        UsernamePasswordAuthenticationToken authenticationToken = userDto.toAuthentication();

        Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);

        String accessToken = jwtTokenProvider.generateAccessToken(authentication);
        
        jwtTokenProvider.generateRefreshToken(authentication);

        return new UserDto(userDto.getUserName(), userDto.getEmail(), accessToken);
    }

    /*
     * Access Token 이 만료되었으므로, Redis 에 있는 Refresh Token 조회
     * 만약 Refresh Token 까지 만료되었으면 재로그인 해야 함
     * 하지만 Refresh Token 이 만료되지 않았으면, Access Token 재발급
     */
    public String reissueAccessToken(String refreshToken) {
        if (jwtTokenProvider.validateToken(refreshToken)) {
            Authentication authentication = jwtTokenProvider.getAuthentication(refreshToken);

            if (refreshToken.equals(redisTemplate.opsForValue().get(authentication.getName())))
                return jwtTokenProvider.generateAccessToken(authentication);
            else {
                throw new RuntimeException("요청한 Refresh Token 불일치");
            }
        } else {
            throw new RuntimeException("Access Token && Refresh Token 만료. 재로그인 필요");
        }
    }
}
  • join : 스프링 컨테이너에 등록한 PasswordEncoder 을 사용해 유저가 회원가입을 위해 입력한 패스워드를 암호화하고 DB에 저장함
  • login
    1. 로그인할 때 입력한 이메일과 비밀번호로 인증용 토큰 생성
    2. 인증용 토큰으로 실제 존재하는 유저인지 체크
    3. 생성된 authentication 객체를 사용해 Access Token, Refresh Token 토큰 생성
  • reissueAccessToken
    1. 유저가 헤더에 입력한 Refresh Token 을 사용해 인증 객체 생성
    2. 유저의 이메일을 사용해 Redis 에 입력된 Refresh Token 과 유저의 헤더에 있던 Refresh Token 비교
    3. 만약 Refresh Token 까지 만료되었으면 재로그인 해야 함
    4. 하지만 Refresh Token 이 만료되지 않았으면, Access Token 재발급

UserDetailsServiceImpl

@Slf4j
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {

    private final UserRepository userRepository;

    // username (email) 이 DB에 존재하는지 확인
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 시큐리티 세션에 유저 정보 저장
        return userRepository.findUserByEmail(username)
                .map(this::createUserDetails)
                .orElseThrow(() -> new UsernameNotFoundException("사용자가 존재하지 않습니다."));
    }

    // DB 에 User 값이 존재한다면 UserDetails 객체로 만들어서 리턴
    private UserDetails createUserDetails(User user) {
        GrantedAuthority grantedAuthority = new SimpleGrantedAuthority(user.getAuthority().toString());

        return new org.springframework.security.core.userdetails.User(
                user.getEmail(),
                user.getPassword(),
                Collections.singleton(grantedAuthority)
        );
    }
}
  • loadUserByUsername : username (email) 이 DB에 존재하는지 확인
  • createUserDetails : DB 에 User 값이 존재한다면 UserDetails 객체로 만들어서 리턴

전체 코드

https://github.com/gudtjr2949/springsecurity-redis

profile
이사중 .. -> https://gudtjr2949.tistory.com/

0개의 댓글

관련 채용 정보