Everyday Dev System

Spring Security + JWT + Redis 활용 기초 본문

내배캠 주요 학습/매일 공부

Spring Security + JWT + Redis 활용 기초

chaeyoung- 2023. 8. 21. 16:42

서론

먼저, 저는 Spring Secutiry + JWT 를 활용하여 로그인 기능 구현이 선행되었습니다. 

 

JWT와 같은 클레임 기반 토큰을 사용하면 Refresh Token을 서버에서 갖고 있을 필요는 없으나,

사용자 강제 로그아웃 기능, 유저 차단, 토큰 탈취 시에 대비하기 위한 방법을 생각해보아야 합니다.

 

 

Access Token이 발급된 후 서버에 저장되지 않고 클라이언트 측에서 갖고 있는 토큰 자체로 검증을 하여 사용자 권한을 인증하는 것을 Stateless(무상태) 라고 합니다.

 

그렇기에, Access Token이 탈취되어 토큰이 만료되기 이전까지 유출된 토큰을 통해 인증을 거칠 수 있다는 취약점이 있습니다. 이러한 문제를 보완하기 위해 Access Token의 만료 기간을 줄이고, Refresh Token의 기간을 늘리는 방법을 시도해보겠습니다.

 

 

 

Refresh Token은 Access Token에 비해 훨씬 더 긴 유효 기간으로 발급되며,

Refresh Token의 경우 접근에 대한 권한을 가진 것이 아니라 Access Token 재발급에만 사용됩니다.

 

정리하자면, access token의 안전성을 확보하기 위해 유효 기간을 짧게 설정하고,

이때 생기는 사용자의 편의성 감소로 보완하기 위해 refresh token을 사용하도록 하겠습니다.

access token과  refresh token은 보안성과 성능 그리고 사용 편의성 등을 고려한 방법입니다.

 

Redis를 활용한 이유는 인메모리 데이터 저장소인 만큼 빠른 I/O 작업을 제공하여 성능적인 측면을 고려하여 선택하였습니다. 자세한 설명은 아래 참조 부탁드립니다.

더보기
  1. 속도 및 성능: Redis는 인메모리 데이터 저장소이므로 매우 빠른 읽기 및 쓰기 작업을 제공할 수 있습니다. Refresh token은 인증 프로세스 중에 신속하게 검증되어야 하며 Redis의 속도는 이 중요한 단계에서 지연을 최소화하는 데 도움이 됩니다.
  2. 낮은 지연 시간: Redis는 데이터 액세스에 대한 매우 낮은 지연 시간을 제공하므로 실시간 응답이 필요한 애플리케이션에 적합합니다. 이는 인증 흐름에서 Refresh token의 유효성을 확인할 때 중요합니다.
  3. 지속성 옵션: Redis는 기본적으로 메모리 내 저장소이지만 디스크에도 데이터를 저장할 수 있는 다양한 지속성 옵션을 제공합니다. 이는 Refresh token에 대한 내구성을 제공하여 서버가 다시 시작되거나 오류가 발생하는 경우에도 토큰이 손실되지 않도록 보장할 수 있습니다.
  4. 만료 관리: Redis는 키의 만료 시간을 설정할 수 있는 "TTL(Time To Live)"라는 기능을 제공합니다. 이는 Refresh token의 수명 주기를 관리하는 데 특히 유용합니다. Refresh token이 발급되면 설정된 만료 시간과 함께 Redis에 저장할 수 있습니다. 토큰이 사용되거나 만료되면 스토어에서 자동으로 제거됩니다.
  5. 확장성: Redis는 분산되도록 설계되었으며 높은 부하를 처리하기 위해 수평으로 쉽게 확장할 수 있습니다. 따라서 가변적인 트래픽 부하를 경험할 수 있는 인증 시스템과 같이 확장성이 필요한 애플리케이션에 적합합니다.
  6. 원자적 작업: Redis는 원자적 작업을 지원합니다. 즉, 복잡한 작업을 분할할 수 없는 단일 단위로 실행할 수 있습니다. 이는 토큰 유효성 검사, 만료 확인 및 업데이트가 모두 원자적으로 수행되도록 보장할 수 있으므로 Refresh token을 안전하게 관리하는 데 유용합니다.
  7. 데이터 구조: Redis는 단순한 키-값 쌍을 넘어 세트, 해시, 목록 등 다양한 데이터 구조를 지원합니다. 이러한 구조는 Refresh token 데이터를 보다 정교한 방식으로 구성하고 관리하는 데 유용할 수 있습니다.
  8. 캐싱 기능: Redis는 Refresh token 저장을 보완할 수 있는 캐싱 목적으로도 사용할 수 있습니다. 이는 기본 인증 서비스의 부하를 줄여 전반적인 시스템 성능을 향상시킬 수 있습니다.

 

 

본론

해당 글에서는 Redis를 활용하여 JWT 토큰을 갱신하는 방법에 대해 소개하고자 합니다.
큰 카테고리로 분류하면 아래 3가지가 되겠습니다.

 

  1. 로그인 시도 시 엑세스 토큰과 리프레시 토큰 동시 발급
  2. 엑세스 토큰 재발급 
  3. 로그아웃 시 토큰 모두 삭제

 

먼저, 사용자가 로그인 시, Spring Security 에서 설정한 Filter 단을 거쳐 Jwt 토큰을 발급받습니다.

발급된 해당 토큰을 클라이언트 측에서 쿠키에 담고 있다가,

서버에 다른 요청을 할 때에 해당 토큰을 통해 인증 및 인가 처리를 진행할 수 있습니다.

 

 

이러한 상황에서 JWT 토큰의 만료 시간이 얼마 남지 않아 인증을을 연장하기 위해서는 어떻게 처리를 하여야 할까요?

 

제가 선택한 방법은 JWT 토큰을 재발급 하는 것입니다.

먼저, JWT 재발급을 위해서는 검증 과정이 필요합니다. 재발급을 하여도 되는지를 판단해야 합니다.

이는 서버 측에서 판가름 합니다. 

 

 

먼저, 사용자가 최초로 로그인을 할 때에 JWT Access Token 뿐만이 아닌 Refresh Token도 발급 받습니다. 그리고 2개의 토큰은 쿠키에 담아 저장하고 있을 것 입니다. 로그인 인증 만료 시간이 다가오면 사용자는 연장 요청을 할 것입니다.

이때 서버 측에서 메모리에 담고 있던 Refresh Token과 사용자가 요청시 전달한 Refresh Token 을 검증하고 재발급합니다.

 

 

아래에 코드를 통해 보여 드리겠습니다.

 

 

 

1. 의존성 추가

    // redis
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'

 

2. application.properties 설정 추가

## redis 설정
spring.data.redis.host = localhost
spring.data.redis.port = 6379

 

 

 

 

스프링부트에서 Redis를 사용하는 방법에는 두가지가 있습니다.

Repository 인터페이스를 정의하는 방법과 Redis Template을 사용하는 방법입니다.

 

Redis는 많은 자료 구조를 지원하는데, Repository를 정의하는 방법은 Hash 자료구조로 한정하여 사용할 수 있습니다. Repository를 사용하면 객체를 Redis의 Hash 자료구조로 직렬화하여 스토리지에 저장할 수 있습니다.

 

 

1. Redis Repository

1) RefreshToken 

아래의 RefreshToken은 리프레시 토큰과 사용자의 ID 정보를 가지고 있는 간단한 객체로, Redis에 저장되어 사용됩니다.

package com.sangbu3jo.elephant.auth.redis;


import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;

// @RedisHash 어노테이션을 레디스 스토리지에 저장할 객체 클래스에 선언한다
// timeToLive 의 단위는 초단위이다.
@RedisHash(value = "refreshToken", timeToLive = 60)
public class RefreshToken {
  // 이 객체는 레디스에 저장되어 리프레시 토큰을 관리하는데 사용된다.


  @Id
  private String refreshToken;
  private Long memberId;

  public RefreshToken(final String refreshToken, final Long memberId) {
    this.refreshToken = refreshToken;
    this.memberId = memberId;
  }

  public String getRefreshToken() {
    return refreshToken;
  }

  public Long getMemberId() {
    return memberId;
  }
}

 

@RedisHash(value = "refreshToken", timeToLive = 60)

  • 해당 @RedisHash 어노테이션은 Redis Storage에 저장할 객체 클래스에 선언합니다.
  • timeToLive 속성은 해당 객체의 만료 시간을 초단위로 설정할 수 있습니다
  • value 속성값과 클래스 내에 @Id 어노테이션이 붙은 필드인 refreshToken 값을 합쳐 Redis의 key로 사용합니다.
    • 예를 들어, refreshToken 필드의 값이 '1' 이라면, Redis 의 key는 'refreshToken:1'

 

 

2) RefreshTokenRepository

CurdRepository 를 상속하고 첫번째 제네릭 타입에는 데이터를 저장할 객체의 클래스를, 두번째로는 객체의 ID 값 (@Id 어노테이션이 붙은) 타입 클래스를 넣어줍니다.

package com.sangbu3jo.elephant.auth.redis;

import org.springframework.data.repository.CrudRepository;

public interface RefreshTokenRepository extends CrudRepository<RefreshToken, String> {

}

 

 

 

 

통상적으로 refresh token은 30일 , access token은 24시간로 만료 시간을 준다고 합니다.

저는 해당 프로젝트에서 Repository보다 확장성이 큰 RedisTemplate을 활용하였습니다.

 

 

 

엑세스, 리프레시 토큰 처리 고려 사항들

  • Redis에 key-value 형태로 값이 들어가는데 key를 무엇을 줄지?
  • id(pk) 값 혹은 email(username) 값 중에 고민.

키 값을 1,2,3 ... 과 같은 id로 주게되면 DB에서 1에 해당하는 유저가 맞는지 확인이 필요하므로,

key를 username으로 할당하였습니다.

 

 

  • 만료된 토큰으로 갱신을 요청할 때 컨트롤 단에서 어떻게 처리할지 고민
  • 만료되기 전 토큰으로 갱신을 할 경우에는 어떻게 처리할지

엑세스 토큰이 만료가 되기 전이나 후 모두 토큰을 갱신을 할 수 있도록 합니다.

대신 refresh  token을 검증한 후 갱신할 수 있도록 돕습니다.

 

1. 만료 되기 전 재발급 → controller 단에 따로 url 메서드를 만들어 맵핑되도록 합니다.

또한, 만료 전에 사용자에게 프론트 단에서 만료 되기 5분 전 혹은 10분 전에 알림을 띄어주는 부분을 추가하면 좋을 것 같습니다. 

 

2. 만료 된 후 재발급 filter 단에서 처리합니다.

먼저 Filter 단에서 엑세스 토큰을 검증하여 유효하지 않을 경우에 Refresh token 검증 절차를 거칩니다.

Refresh token이 유효하다면 엑세스 토큰을 재발급합니다.

 

 

 

  • 로그아웃 시에 클라이언트 측의 쿠키에 토큰(엑세스,리프레시)을 어떻게 처리할지
  • 로그아웃 과 엑세스 토큰 갱신 API Method를 어떤 것으로 할지 

마지막으로, 로그아웃 시 클라이언트 측의 쿠키는 토큰의 만료 시간을 0으로 만들어 쿠키에 add를 하면 쿠키에 아무 값도 들어가지 않는 것을 확인했습니다. Redis 메모리에서도 refresh 토큰을 삭제하도록 로직을 설계했습니다.

 

 

 

 

Redis Template 활용

1) RedisConfig 파일 추가

package com.sangbu3jo.elephant.auth.redis;

import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Getter
@Configuration
@RequiredArgsConstructor
@EnableRedisRepositories // Redis Repository 활성화
public class RedisConfig {

  @Value("${spring.data.redis.host}")
  private String host;

  @Value("${spring.data.redis.port}")
  private int port;

  /**
   * 내장 혹은 외부의 Redis를 연결
   */
  @Bean
  public RedisConnectionFactory redisConnectionFactory(){
    return new LettuceConnectionFactory(host, port);
  }

  /**
   * RedisConnection 에서 넘겨준 byte 값 객체 직렬화 RedisTemplate 은 Redis 데이터를 저장하고 조회하는 기능을 하는 클래스 Redis cli
   * 를 사용해 Redis 데이터를 직접 조회할 때, Redis 데이터를 문자열로 반환하기 위한 설정
   */
  @Bean
  public RedisTemplate<String, String> redisTemplate() {
    RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
    redisTemplate.setConnectionFactory(redisConnectionFactory());
    redisTemplate.setKeySerializer(new StringRedisSerializer());
    redisTemplate.setValueSerializer(new StringRedisSerializer());
    return redisTemplate;
  }

}

Redis는 많은 자료구조를 지원하는데, Repository를 정의하는 방법은 Hash 자료구조로 한정하여 사용할 수 있습니다. Repository를 사용하면 객체를 Redis의 Hash 자료구조로 직렬화하여 스토리지에 저장할 수 있습니다.

 

 

2) RefreshToken  

package com.sangbu3jo.elephant.auth.redis;


import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import org.springframework.data.annotation.Id;

@Builder
@Getter
@AllArgsConstructor
public class RefreshToken {
  // 이 객체는 레디스에 저장되어 리프레시 토큰을 관리하는데 사용된다.
  @Id
  private String username; // email id 값 저장

  private String refreshToken;

}

 

 

3) RefreshTokenRepository

Redis Template에서는 Repository를 인터페이스로 정의하지 않고, 직접 아래와 같이 구현합니다.

package com.sangbu3jo.elephant.auth.redis;

import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Repository;

@Repository
@RequiredArgsConstructor
public class RefreshTokenRepository {

  private final RedisTemplate redisTemplate;

  // 리프레스 토큰 만료시간
  private final long RRFRESH_TOKEN_TIME = 60 * 60 * 1000L; // 60분

  public void save(final RefreshToken refreshToken) {
    ValueOperations<String, String> valueOperations = redisTemplate.opsForValue();
    valueOperations.set(refreshToken.getUsername(),refreshToken.getRefreshToken());
    redisTemplate.expire(refreshToken.getUsername(), RRFRESH_TOKEN_TIME, TimeUnit.SECONDS);
  }

  public Boolean delete(String username) {
    return redisTemplate.delete(username);
  }

  public Optional<RefreshToken> findByUsername(final String username) {
    ValueOperations<String, String> valueOperations = redisTemplate.opsForValue();
    String refreshToken = String.valueOf(valueOperations.get(username));

    if (Objects.isNull(refreshToken)) {
      return Optional.empty();
    }

    return Optional.of(new RefreshToken(username, refreshToken));
  }
}

 

 

 

Controller 코드 추가

package com.sangbu3jo.elephant.auth.controller;

import com.sangbu3jo.elephant.auth.dto.SignupRequestDto;
import com.sangbu3jo.elephant.auth.service.AuthServiceImpl;
import com.sangbu3jo.elephant.security.UserDetailsImpl;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api")
public class AuthController {

  private final AuthServiceImpl authService;


  @PostMapping("/auth/signup")
  public ResponseEntity<String> signUp(
      @RequestBody @Valid SignupRequestDto requestDto) {
    String result = authService.signup(requestDto);
    return ResponseEntity.ok().body(result);
  }


  // 로그아웃 메서드 구현 요망
  @DeleteMapping("/auth/logout")
  public ResponseEntity<String> logout(
      HttpServletRequest request, HttpServletResponse response,
      @AuthenticationPrincipal UserDetailsImpl userDetails){
    String result = authService.logout(request, response, userDetails.getUser());
    return ResponseEntity.ok(result);
  } 


  // 만료된 access token 으로, 만료 전 refresh token
  @PostMapping("/auth/refresh/access-token")
  public ResponseEntity<String> generateRefreshToken(
      HttpServletRequest request, HttpServletResponse response,
      @AuthenticationPrincipal UserDetailsImpl userDetails) {
    String result = authService.generateRefreshToken(request, response, userDetails.getUser());
    return ResponseEntity.ok(result);
  } 


}

 

 

Service 코드 추가

package com.sangbu3jo.elephant.auth.service;

import com.sangbu3jo.elephant.auth.dto.SignupRequestDto;
import com.sangbu3jo.elephant.auth.redis.RefreshToken;
import com.sangbu3jo.elephant.auth.redis.RefreshTokenRepository;
import com.sangbu3jo.elephant.security.jwt.JwtUtil;
import com.sangbu3jo.elephant.users.entity.User;
import com.sangbu3jo.elephant.users.entity.UserRoleEnum;
import com.sangbu3jo.elephant.users.repository.UserRepository;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import org.springframework.util.StringUtils;

@Slf4j(topic = "Auth Service")
@Service
@RequiredArgsConstructor
public class AuthServiceImpl implements AuthService {

  private final PasswordEncoder passwordEncoder;
  private final UserRepository userRepository;
  private final RefreshTokenRepository refreshTokenRepository;
  private final JwtUtil jwtUtil;

  private final String ADMIN_TOKEN = "AAABnvxRVklrnYxKZ0aHgTBcXukeZygoC";

  @Override
  public String signup(SignupRequestDto signupRequestDto) {
    // 중복 체크
    if (userRepository.existsByUsername(signupRequestDto.getUsername())) {
      log.error("중복된 사용자가 회원가입을 시도하였습니다.");
      throw new IllegalArgumentException("중복된 사용자가 존재합니다.");
    }

    // DB에 저장하기 이전에 데이터 전처리
    String inputUsername = signupRequestDto.getUsername();
    String password = passwordEncoder.encode(signupRequestDto.getPassword());
    String nickname = signupRequestDto.getNickname();
    String introduction = signupRequestDto.getIntroduction();

    // 사용자 ROLE 확인
    UserRoleEnum role = UserRoleEnum.USER;
    if (StringUtils.hasText(signupRequestDto.getAdminToken())) {
      if (!ADMIN_TOKEN.equals(signupRequestDto.getAdminToken())) {
        throw new IllegalArgumentException("관리자 암호가 틀려 등록이 불가능합니다.");
      }
      role = UserRoleEnum.ADMIN;
    }

    // 해당 정보를 생성자 메서드로 User 객체 생성 후 DB 에 저장
    User user = new User(signupRequestDto, password, role);
    userRepository.save(user);

    log.info("회원가입에 성공하였습니다.");
    return "회원가입 성공";
  }

  @Override
  public String generateRefreshToken(HttpServletRequest request, HttpServletResponse response, User user) {
    // 클라이언트 쿠키에서 refresh token 추출
    String clientRefreshToken = jwtUtil.getRefreshTokenFromRequest(request);
    String username = user.getUsername();
    UserRoleEnum role = user.getRole();

    // refresh token 없을 경우 예외 처리
    if (!StringUtils.hasText(clientRefreshToken)) {
      throw new IllegalArgumentException("RefreshToken is null. please login");
    }

    // redis 에서 해당 유저에 따른 refresh token 추출
    RefreshToken refreshToken = refreshTokenRepository.findByUsername(username).get();

    // refresh token 일치 여부 확인
    if (clientRefreshToken.equals(refreshToken.getRefreshToken())) {
      // 엑세스 토큰 생성
      createAccessToken(response, username, role);
    }
    return "Access Token 생성 성공";
  }

  @Override
  public String logout(HttpServletRequest request, HttpServletResponse response, User user) {
    // redis refresh token 삭제
    Boolean result = refreshTokenRepository.delete(user.getUsername());

    log.info("result: " + result);
    if(!result) { throw new IllegalArgumentException("RefreshToken couldn't deleted."); }

    // Delete client-side cookie
    jwtUtil.deleteCookie(request, response);
    return "Logout 성공";
  }

  private void createAccessToken(HttpServletResponse response, String username, UserRoleEnum role) {
    // access token 발급 및 쿠키에 저장
    String accessToken = jwtUtil.createToken(username, role);
    jwtUtil.addJwtToCookieAccessToken(accessToken, response);
  }

}

 

 

 

 

JwtUtil 클래스 코드 추가

package com.sangbu3jo.elephant.security.jwt;

import com.sangbu3jo.elephant.users.entity.UserRoleEnum;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.SignatureException;
import io.jsonwebtoken.UnsupportedJwtException;
import io.jsonwebtoken.security.Keys;
import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Base64;
import java.util.Date;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;


@Component
public class JwtUtil {
  // Header KEY 값
  public static final String AUTHORIZATION_HEADER = "Authorization";
  public static final String REFRESH_HEADER = "RefreshToken";
  // 사용자 권한 값의 KEY
  public static final String AUTHORIZATION_KEY = "auth";

  // Token 식별자
  public static final String BEARER_PREFIX = "Bearer ";

  // 엑세스 토큰 만료시간
  private final long TOKEN_TIME = 60 * 10 * 1000L; // 10분

  @Value("${jwt.secret.key}") // Base64 Encode 한 SecretKey
  private String secretKey;
  private Key key;
  private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;

  // 로그 설정
  public static final Logger logger = LoggerFactory.getLogger("JWT 관련 로그");

  @PostConstruct
  public void init() {
    byte[] bytes = Base64.getDecoder().decode(secretKey);
    key = Keys.hmacShaKeyFor(bytes);
  }

  // 엑세스 토큰 생성
  public String createToken(String username, UserRoleEnum role) {
    Date date = new Date();

    return BEARER_PREFIX +
        Jwts.builder()
            .setSubject(username) // 사용자 식별자값(ID)
            .claim(AUTHORIZATION_KEY, role) // 사용자 권한
            .setExpiration(new Date(date.getTime() + TOKEN_TIME)) // 만료 시간
            .setIssuedAt(date) // 발급일
            .signWith(key, signatureAlgorithm) // 암호화 알고리즘
            .compact();
  }

  // JWT Cookie 에 access token 저장
  public void addJwtToCookieAccessToken(String token, HttpServletResponse res) {
    token = URLEncoder.encode(token, StandardCharsets.UTF_8).replaceAll("\\+", "%20"); // Cookie Value 에는 공백이 불가능해서 encoding 진행

    Cookie cookie = new Cookie(AUTHORIZATION_HEADER, token); // Name-Value
    cookie.setPath("/");

    cookie.setHttpOnly(true); // For security, make the cookie HttpOnly
    res.addCookie(cookie);
  }

  // JWT Cookie 에 refresh token 저장
  public void addJwtToCookieRefreshToken(String refreshToken, HttpServletResponse res) {
    refreshToken = URLEncoder.encode(refreshToken, StandardCharsets.UTF_8).replaceAll("\\+", "%20"); // Cookie Value 에는 공백이 불가능해서 encoding 진행

    Cookie cookie = new Cookie(REFRESH_HEADER, refreshToken); // refreshToken
    cookie.setPath("/");

    cookie.setHttpOnly(true); // For security, make the cookie HttpOnly
    res.addCookie(cookie);
  }

  // JWT 토큰 substring
  public String substringToken(String tokenValue) {
    if (StringUtils.hasText(tokenValue) && tokenValue.startsWith(BEARER_PREFIX)) {
      return tokenValue.substring(7);
    }
    logger.error("Not Found Token");
    throw new NullPointerException("Not Found Token");
  }

  // 토큰 검증
  public boolean validateToken(String token) {
    try {
      Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
      return true;
    } catch (SecurityException | MalformedJwtException | SignatureException e) {
      logger.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.");
    } catch (ExpiredJwtException e) {
      logger.error("Expired JWT token, 만료된 JWT token 입니다.");
    } catch (UnsupportedJwtException e) {
      logger.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.");
    } catch (IllegalArgumentException e) {
      logger.error("JWT claims is empty, 잘못된 JWT 토큰 입니다.");
    }
    return false;
  }

  // 토큰에서 사용자 정보 가져오기
  public Claims getUserInfoFromToken(String token) {
    return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
  }

  // HttpServletRequest 에서 Cookie Value : JWT access Token 가져오기
  public String getAccessTokenFromRequest(HttpServletRequest req) {
    Cookie[] cookies = req.getCookies();
    if(cookies != null) {
      for (Cookie cookie : cookies) {
        if (cookie.getName().equals(AUTHORIZATION_HEADER)) {
          return URLDecoder.decode(cookie.getValue(), StandardCharsets.UTF_8); // Encode 되어 넘어간 Value 다시 Decode
        }
      }
    }
    return null;
  }

  // HttpServletRequest 에서 Cookie Value : JWT access Token 가져오기
  public String getRefreshTokenFromRequest(HttpServletRequest req) {
    Cookie[] cookies = req.getCookies();
    if(cookies != null) {
      for (Cookie cookie : cookies) {
        if (cookie.getName().equals(REFRESH_HEADER)) {
          return URLDecoder.decode(cookie.getValue(), StandardCharsets.UTF_8); // Encode 되어 넘어간 Value 다시 Decode
        }
      }
    }
    return null;
  }

  // 로그아웃 시 엑세스, 리프레시 쿠키 날짜를 0으로 만들어 만료시킴
  public void deleteCookie(HttpServletRequest request, HttpServletResponse response) {
    Cookie[] cookies = request.getCookies();
    if (cookies == null) {
      return;
    }
    for (Cookie cookie : cookies) {
      if(cookie.getName().equals(AUTHORIZATION_HEADER)
          | cookie.getName().equals(REFRESH_HEADER)){
        cookie.setMaxAge(0);
        response.addCookie(cookie);
      }
    }
  }

}

 

 

결과

위와 같이 Redis에 잘 들어갑니다.

또한, 클라이언트 쿠키에도 RefreshToken 값과 Authorization JWT 값이 모두 잘 설정됩니다.

 

 

 

 

잘 작동이 되지만, 다음의 예외 사항도 고려해 볼 필요가 있습니다.

 

 

1. RefreshTokeen이 탈취당했을 경우

 

이를 위해 최초 로그인 시, 로그인 요청 IP 주소를 서버에 저장하고,

재발급 요청을 들어왔을 때, 요청한 측의 IP와 비교하여 다른 경우 토큰을 재발급하지 않거나,

알림을 보내 경고를 하도록 하는 것도 방법이 될 수 있습니다.

 

 

서버 개발자의 역량에 맞게 CS 지식을 동원하여 고려해야 할 것 같습니다.

 

 

 


reference:

https://wildeveloperetrain.tistory.com/245

 

(Spring Security + JWT) Refresh Token을 통한 토큰 재발급에 대해서

Spring Security + JWT / Refresh Token을 통한 토큰 재발급 방식 spring security + jwt를 사용한 인증 방식을 구현할 때, 토큰 재발급은 어떤 방식으로 이루어지는 게 좋을지에 대해 생각해 보며 정리한 내용입

wildeveloperetrain.tistory.com

https://hudi.blog/refresh-token-in-spring-boot-with-redis/

 

Spring Boot와 Redis를 사용하여 Refresh Token 구현하기

배경 바로 직전에 작성한 Access Token의 문제점과 Refresh Token 글에서 Refresh Token이 무엇인지 글로 알아보았다. 하지만, 글만 읽어서는 공부를 끝냈다고 할 수 없다. 실제로 코드를 작성해야 지식을

hudi.blog

https://www.bezkoder.com/spring-security-refresh-token/

https://github.com/bezkoder/spring-security-refresh-token-jwt/blob/master/README.md

https://hou27.tistory.com/entry/Spring-Boot-Redis%EC%99%80-%ED%95%A8%EA%BB%98-Refresh-Token-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0


 

[Spring Boot] Redis와 함께 Refresh Token 구현하기

https://hou27.tistory.com/entry/Spring-Security-JWT Spring Security - JWT 이번에는 지난번 세션 인증을 적용한 포스트에 이어서 JWT를 이용한 로그인을 구현해보도록 하겠다. 지난 포스트 https://hou27.tistory.com/entry/

hou27.tistory.com

 

'내배캠 주요 학습 > 매일 공부' 카테고리의 다른 글

코딩하는 사람끼리 : 협업 커뮤니티, KPT 회고  (0) 2023.09.19
Elephant 협업 사이트 프로젝트  (0) 2023.09.04
Task Rail 영상  (0) 2023.08.14
react 코드  (0) 2023.08.08
React로 프론트 구현하기  (0) 2023.08.08