
개요
최종 프로젝트를 시작하면서, 인증/인가 부분을 한 번 더 맡게 되었다. 이전에는 RefreshToken을 RDB에 관리하며, 'RefreshToken을 Redis에서 관리하는 것이 더 낫지 않을까?'란 느낌이 있었는데, 정확히 어떤 점이 좋은지는 머리 속으로 그려봐도 크게 그려지지는 않았다. 인증/인가 부분을 한 번 더 맡게 된 김에 이번에는 Redis를 이용하여 RefreshToken을 관리해보고, 이전에 잘못 이해했던 RefreshToken 사용 방식을 다시 공부하며 더불어 RDB로 관리할 때의 차이점을 비교해보려 한다.
요구사항
함께 살펴볼 부분은 다음과 같다.
- 로그인 시 토큰들을 발급받으며 RefreshToken을 Redis에 저장한다.
- AccessToken 만료 시 RefreshToken을 통해 AccessToken을 재발급 한다.
1. 로그인 시
로그인 시, RefreshToken 저장 흐름은 다음과 같다.
- OAuth 로그인
- AccessToken & RefreshToken 발급
- Redis에 RefreshToken 저장
- 로그인 응답으로 AccessToken & RefreshToken ID 전달
1. OAuth 로그인
OAuth2LoginSuccessHandler
@Slf4j
@Component
@RequiredArgsConstructor
public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final ObjectMapper objectMapper = new ObjectMapper();
private final TokenProvider tokenProvider;
private final UserRepository userRepository;
private String toJson(UserLoginResponse<?> userLoginResponse) throws JsonProcessingException {
return objectMapper.writeValueAsString(userLoginResponse);
}
private void sendResponse(HttpServletResponse response, String userLoginResponse)
throws IOException {
response.setStatus(HttpServletResponse.SC_OK);
response.setContentType("application/json;charset=UTF-8");
response.setContentLength(userLoginResponse.getBytes().length);
response.getWriter().write(userLoginResponse);
}
private UserLoginResponse<?> checkJoined(OAuthUserInfo oAuthUserInfo) {
UserLoginResponse<?> userLoginResponse;
//회원가입 여부 확인
Optional<User> optionalUser = userRepository.findByProviderId(
oAuthUserInfo.providerId());
//회원가입하지 않은 유저라면
if (optionalUser.isEmpty()) {
// todo : 회원가입 페이지 리다이렉트
}
//이미 회원가입한 유저라면
else {
log.info("oAuthUser's providerId : {}", oAuthUserInfo.providerId());
//회원 정보를 통해 토큰 발급
User findUser = optionalUser.get();
Token token = tokenProvider.createToken(findUser.getId(), findUser.getRole());
userLoginResponse = new UserLoginResponse<>(true, token);
}
return userLoginResponse;
}
@Override
public void onAuthenticationSuccess(
HttpServletRequest request,
HttpServletResponse response,
Authentication authentication
) throws IOException {
log.info("OAuth Login Success!!");
if (authentication instanceof OAuth2AuthenticationToken authenticationToken) {
Map<String, Object> userAttributes = authenticationToken.getPrincipal().getAttributes();
String provider = authenticationToken.getAuthorizedClientRegistrationId();
//1. OAuth 로그인 유저 정보 매핑
OAuthUserInfo oAuthAttribute = OAuthAttributeMapper.toOAuthUserInfo(userAttributes,
provider);
//2. 유저 정보 토큰을 담은 dto 생성
UserLoginResponse<?> userLoginResponse = checkJoined(oAuthAttribute);
//3. 응답
sendResponse(response, toJson(userLoginResponse));
}
}
}
OAuth 로그인이 성공적으로 완료되면 호출되는 클래스로, OAuth 사용자에 대한 정보들을 매핑해주고, Token을 생성하여 응답해준다.
OAuth에 대한 자세한 설명은 생략하겠다!
2. AccessToken & RefreshToken 발급
JwtTokenProvider
@Component
@RequiredArgsConstructor
public class JwtTokenProvider implements TokenProvider {
private static final String JWT_ROLE = "JWT_ROLE";
private final JwtProperty jwtProperty;
private final RefreshTokenRepository refreshTokenRepository;
private String generateToken(
Long id,
Role role,
long expireTime
) {
Date now = new Date();
Date expireDate = new Date(now.getTime() + expireTime);
byte[] keyBytes = Decoders.BASE64.decode(jwtProperty.getClientSecret());
Claims claims = Jwts.claims().setSubject(String.valueOf(id));
claims.put(JWT_ROLE, role);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(expireDate)
.signWith(Keys.hmacShaKeyFor(keyBytes), SignatureAlgorithm.HS256)
.compact();
}
//토큰 생성 메서드
@Override
public Token createToken(Long id, Role role) {
//만료 시간 설정
long accessExpiryMinute = jwtProperty.getAccessExpiryTime() * 1000 * 60;
long refreshExpiryMinute = jwtProperty.getRefreshExpiryTime() * 1000 * 60;
//AccessToken & RefreshToken 생성
String accessToken = generateToken(id, role, accessExpiryMinute);
String refreshToken = generateToken(id, role, refreshExpiryMinute);
//로그인 할 때마다 RefreshToken 새로 저장
String refreshTokenId = UUID.randomUUID().toString();
refreshTokenRepository.save(refreshTokenId, refreshToken, jwtProperty.getRefreshExpiryTime());
//토큰 전달
return new Token(accessToken, refreshTokenId, role);
}
.
.
}
Jwt 토큰을 전체적으로 관리하는 provider이다.
로그인 시 createToken()이 호출되며, AccessToken과 RefreshToken이 생성된다.
새로 생성된 RefreshToken은 Redis에 저장된다.
클라이언트에게는 (AccessToken, RefreshToken ID, 권한)이 전달된다.
이 때, RefreshToken ID(Redis key)는 아무 의미가 없는 UUID를 사용하였다.
3. Redis에 RefreshToken 저장
RefreshTokenRepository
@Component
@RequiredArgsConstructor
public class RefreshTokenRepository {
private final StringRedisTemplate redisTemplate;
// RefreshToken 저장
public void save(String refreshTokenId, String refreshToken, Long refreshExpiryTime) {
redisTemplate.opsForValue()
.set(refreshTokenId, refreshToken, refreshExpiryTime, TimeUnit.MINUTES);
}
.
.
}
RefreshToken을 관리하는 Redis 저장소이다.
key는 UUID, value는 RefreshToken이다.
TTL은 토큰 만료시간으로 설정하여, 만료가 된 RefreshToken은 Redis에서 자동 삭제된다.
2. AccessToken 만료 시
AccessToken 만료 시, RefreshToken 조회 흐름은 다음과 같다.
- 클라이언트에서 RefreshToken ID 값과 함께 AccessToken 재발급 api 요청
- 서버에서 RefreshToken ID 값을 통해 RefreshToken 조회
- RefreshToken을 통해 회원 정보 조회 후, 새로운 AccessToken 발급
1. AccessToken 재발급
AuthService
@Service
@RequiredArgsConstructor
public class AuthService {
private final TokenProvider tokenProvider;
// RefreshToken Id를 통해 AccessToken 재발급
public String issueAccessToken(String refreshTokenId) {
return tokenProvider.createAccessToken(refreshTokenId);
}
}
클라이언트가 현재 갖고있는 AccessToken이 만료된다면, RefreshToken ID를 통해 AccessToken 재발급 요청을 보낼 것이다.
이 때 issueAccessToken()이 호출되며, RefreshToken을 발급하여 응답으로 보낼 것이다.
2. RefreshToken 조회 & 새로운 AccessToken 발급
JwtTokenProvider
@Component
@RequiredArgsConstructor
public class JwtTokenProvider implements TokenProvider {
private static final String JWT_ROLE = "JWT_ROLE";
private final JwtProperty jwtProperty;
private final RefreshTokenRepository refreshTokenRepository;
private String generateToken(
Long id,
Role role,
long expireTime
) {
Date now = new Date();
Date expireDate = new Date(now.getTime() + expireTime);
byte[] keyBytes = Decoders.BASE64.decode(jwtProperty.getClientSecret());
Claims claims = Jwts.claims().setSubject(String.valueOf(id));
claims.put(JWT_ROLE, role);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(expireDate)
.signWith(Keys.hmacShaKeyFor(keyBytes), SignatureAlgorithm.HS256)
.compact();
}
private Jws<Claims> getClaims(String token) {
byte[] keyBytes = Decoders.BASE64.decode(jwtProperty.getClientSecret());
try {
return Jwts.parserBuilder()
.setSigningKey(Keys.hmacShaKeyFor(keyBytes))
.build()
.parseClaimsJws(token);
} catch (ExpiredJwtException e) {
throw new ValidationException(AuthErrorCode.AUTH_TOKEN_EXPIRED);
} catch (
SecurityException |
MalformedJwtException |
UnsupportedJwtException |
IllegalArgumentException e
) {
throw new ValidationException(AuthErrorCode.AUTH_TOKEN_INVALID);
}
}
@Override
public String createAccessToken(String refreshTokenId) {
//id를 통해 redis에서 refreshToken 검색
Optional<String> optional = refreshTokenRepository.findById(refreshTokenId);
//redis에 refreshToken이 존재하지 않는다면 -> 새로 로그인하여 파기된 refreshToken
if (optional.isEmpty()) {
throw new ValidationException(AuthErrorCode.AUTH_TOKEN_EXPIRED);
}
String refreshToken = optional.get();
TokenPayload payLoad = getPayLoad(refreshToken);
//accessToken 재발급
long accessExpiryMinute = jwtProperty.getAccessExpiryTime() * 1000 * 60;
return generateToken(payLoad.userId(), payLoad.role(), accessExpiryMinute);
}
@Override
public TokenPayload getPayLoad(String token) {
Jws<Claims> claims = getClaims(token);
Long userId = Long.parseLong(claims.getBody().getSubject());
String name = claims.getBody().get(JWT_ROLE).toString();
Role role = Role.valueOf(name);
return new TokenPayload(userId, role);
}
.
.
}
RefreshToken Id를 통해 AccessToken을 발급하는 createAccessToken() 메서드의 흐름은 다음과 같다.
- 우선 요청과 함께 들어온 RefreshToken ID를 통해 RefreshToken을 조회한다. (존재하지 않을 시, 토큰 만료 예외)
- RefreshToken을 통해 사용자 정보를 조회한다.
- 사용자 정보를 통해 AccessToken을 재발급한다.
RDB vs Redis
- RDB 저장 시 PK를 자동으로 할당하지만, Redis는 UUID를 따로 생성하여 key로 사용하였다.
- 로그아웃 하지 않고 RefreshToken이 만료된다면, RDB에 데이터는 그대로 남게 되지만, Redis는 TTL을 설정했기에 만료된 RefreshToken은 삭제된다.
- RDB로 관리 시, 로그인 로그아웃마다 쿼리를 날린다.
정리
Redis를 통해 RefreshToken을 관리해보았다.
필자의 기술적 한계일 수도 있지만, Redis로 관리하면 더 빠르고 편리할 줄 알았는데 크게 체감은 되지 않았고 나름의 트레이드오프도 있었던 것 같다.
그래도 Redis와 더불어 보안 쪽에 좀 더 생각해보고 공부한 시간이었던 것 같다.
인증/인가 이제 그만!!
'Database > Redis' 카테고리의 다른 글
| [Redis] Redis를 이용한 예약 선점 로직 리팩토링 (1) | 2024.01.24 |
|---|