💻 뚝딱뚝딱/북북클럽

[개발일지 #008] 로그인 구현3 (refreshToken 재발급, AccessToken 블랙리스트 기능)

뚜루리 2025. 4. 23. 13:10
728x90
320x100

🎯 오늘의 목표

  • refreshToken 재발급 기능 구현
  • AccessToken 블랙리스트 기능 구현

 


⚙️ 진행한 작업

  • refreshToken 재발급 기능 구현
  • AccessToken 블랙리스트 기능 구현

🛠️ 개발내용

📌  RefreshToken 재발급 기능

  • AccessToken 재발급 요청이 들어왔을 때, 기존 RefreshToken도 새로 발급해서 교체 하는 것을 뜻함.

 

✅ 현재 로그인/로그아웃 흐름

  1. 로그인 시 → AccessToken + RefreshToken 발급
  2. AccessToken: 요청 시마다 헤더에 포함해 인증
  3. RefreshToken: Redis에 저장 (서버), 클라이언트에도 전달
  4. AccessToken 만료 시 → /token/refresh 호출로 재발급
  5. 로그아웃 시 → 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