💻 뚝딱뚝딱/북북클럽
[개발일지 #008] 로그인 구현3 (refreshToken 재발급, AccessToken 블랙리스트 기능)
뚜루리
2025. 4. 23. 13:10
728x90
320x100
🎯 오늘의 목표
- refreshToken 재발급 기능 구현
- AccessToken 블랙리스트 기능 구현
⚙️ 진행한 작업
- refreshToken 재발급 기능 구현
- AccessToken 블랙리스트 기능 구현
🛠️ 개발내용
📌 RefreshToken 재발급 기능
- AccessToken 재발급 요청이 들어왔을 때, 기존 RefreshToken도 새로 발급해서 교체 하는 것을 뜻함.
✅ 현재 로그인/로그아웃 흐름
- 로그인 시 → AccessToken + RefreshToken 발급
- AccessToken: 요청 시마다 헤더에 포함해 인증
- RefreshToken: Redis에 저장 (서버), 클라이언트에도 전달
- AccessToken 만료 시 → /token/refresh 호출로 재발급
- 로그아웃 시 → Redis에서 RefreshToken 삭제 + 프론트에서 AccessToken 제거 예정
- 4번을 보면, AccessToken 만료 시 재발급을 할 때, RefreshToken도 같이 재발급을 해줌.
- 즉, AccessToken과 RefreshToken을 모두 새로 발급하고, 기존 RefreshToken을 교체해서 Redis에 덮어씀
📌 RefreshToken 재발급 기능은 왜 필요할까?
이유 | 상세 |
🔒 보안 강화 | RefreshToken도 유출 가능성 있음 → 짧게 자주 교체하는 게 안전함 |
🚫 재사용 방지 | 예전 RefreshToken이 탈취됐을 경우, 한 번만 쓰이고 무효화되도록 유도 |
🔁 토큰 수명 갱신 | 유저가 계속 활동하면 토큰도 계속 살아있게 하기 위해 |
🧹 Redis 자동 정리 유도 | TTL로 만료되니까 새 토큰 갱신이 Redis 유지에도 도움이 됨 |
- RefreshToken도 AccessToken처럼 주기적으로 바꾸는 게 더 안전하고 실용적.
📌 UserController.java 수정
@PostMapping("/token/refresh")
public ResponseEntity<ApiResponse<LoginResponse>> refresh(@RequestBody RefreshTokenRequest request) {
if (!refreshTokenService.isValid(request.getUserId(), request.getRefreshToken())) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ApiResponse.fail("Refresh Token이 유효하지 않습니다"));
}
User user = userService.findById(request.getUserId());
// ✅ 새 토큰들 발급
String newAccessToken = jwtUtil.createToken(user.getEmail());
String newRefreshToken = jwtUtil.createRefreshToken();
// ✅ Redis 저장소에 새 리프레시 토큰으로 교체
refreshTokenService.save(user.getId(), newRefreshToken, REFRESH_EXPIRATION_DAYS, TimeUnit.DAYS);
// ✅ 응답에 새 토큰 포함
return ResponseEntity.ok(ApiResponse.success(
new LoginResponse(newAccessToken, newRefreshToken, UserResponse.from(user))
));
}
[Postmam을 통한 테스트]
[POST] /api/users/login
Body: application/json
{ "email": "test@example.com", "password": "123456" }
[POST] /api/users/token/refresh
Headers: Content-Type: application/json
Body (raw → JSON):
{ "userId": 1, "refreshToken": "복사한_리프레시_토큰_값" }
먼저 로그인을 해서 토큰을 발급받고, 재발급 받는 API를 요청하면 리프레시 토큰이 변경되어 있는 것을 확인할 수 있음.
📌 AccessToken 블랙리스트 기능
✅ AccessToken 블랙리스트 기능이란?
- 로그아웃 후, 남은 AccessToken 을 차단하는 기능임.
✅ AccessToken 블랙리스트 기능은 왜 필요할까?
- JWT는 stateless 구조라 서버는 토큰을 "기억"하지 않음 그래서 로그아웃을 해도 AccessToken은 유효 기간이 끝날 때까지 계속 사용 가능해서 아래와 같은 문제 발생 가능
- 어떤 사용자가 로그인 → AccessToken 탈취됨 로그아웃 요청으로 RefreshToken은 삭제됐지만, 유효시간이 남아 있는 AccessToken은 계속 악용 가능
🛡 블랙리스트 기능을 쓰면?
기능 | 설명 |
🚫 로그아웃한 토큰 차단 | AccessToken을 Redis에 blacklist:<token>으로 저장 |
🔍 요청마다 검사 | 필터에서 Redis에 있는지 확인 → 있으면 차단 |
⏱ TTL 설정 | 토큰의 남은 만료시간만큼 블랙리스트 유지 |
✅ 프론트에서 AccessToken 삭제하는 것으로는 충분하지 않을까?
- ❌ 보안적으로는 불충분함. 프론트에서 토큰을 제거하는 건 “내 브라우저에선 안 쓰겠다”는 뜻일 뿐 이미 서버에선 여전히 “쓸 수 있는 토큰”이기 때문에 외부에서 악용 가능성이 남아 있음
✅ 전체 구현 흐름 요약
1. JwtBlacklistService 생성 (Redis에 저장/조회)
2. 로그아웃 시 AccessToken을 블랙리스트에 등록
3. JwtAuthenticationFilter에서 요청 들어올 때 블랙리스트 확인
📌 블랙리스트 저장/조회용 서비스 생성 (JwtBlacklistService.java)
package ddururi.bookbookclub.global.jwt;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
@Service
@RequiredArgsConstructor
public class JwtBlacklistService {
private final RedisTemplate<String, String> redisTemplate;
private final String PREFIX = "blacklist:access:";
// 블랙리스트에 등록
public void blacklist(String token, long expirationMillis) {
redisTemplate.opsForValue().set(PREFIX + token, "logout", expirationMillis, TimeUnit.MILLISECONDS);
}
// 블랙리스트에 있는지 확인
public boolean isBlacklisted(String token) {
return redisTemplate.hasKey(PREFIX + token);
}
}
📌 로그아웃 시 AccessToken 블랙리스트 등록 (UserController.logout())
@PostMapping("/logout")
public ResponseEntity<ApiResponse<Void>> logout(@AuthenticationPrincipal CustomUserDetails userDetails,
HttpServletRequest request) {
// 1. 리프레시 토큰 삭제
refreshTokenService.delete(userDetails.getId());
// 2. 엑세스 토큰 추출
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
String accessToken = header.substring(7);
long remainingTime = jwtUtil.getRemainingExpiration(accessToken);
blacklistService.blacklist(accessToken, remainingTime);
}
return ResponseEntity.ok(ApiResponse.success(null));
}
📌 JwtUtil에 남은 만료 시간 계산 메서드 추가 (JwtUtil.java)
public long getRemainingExpiration(String token) {
Date expiration = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody()
.getExpiration();
return expiration.getTime() - System.currentTimeMillis();
}
📌 JwtAuthenticationFilter에서 블랙리스트 확인 (JwtAuthenticationFilter.java)
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
// 블랙리스트 확인
if (blacklistService.isBlacklisted(token)) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
return;
}
if (jwtUtil.validateToken(token)) {
// 기존 인증 처리 로직 유지
}
}
[Postman으로 테스트]
먼저 로그아웃한 후 다시 정보를 요청해서 401이 뜬다면 정상적으로 테스트 완.
728x90
320x100