Intro
프로젝트 진행 중 순수 액세스 토큰만으로 로그인 및 로그아웃, 회원가입을 구현했더니
액세스 토큰이 만료된 시점에서 갑자기 JWT 토큰이 만료됐다면서 모든 기능이 마비됐었다.
인터넷 쿠키, 캐시 삭제도 해보고, DB를 싹 비워봐도 해결이 안 되길래 찾아 봤더니
액세스 토큰만으로 이 기능들을 구현하기엔 불안정하다고 하더라.
구체적으론 보안, 안정성, 사용자 경험 면에서 어려움이 있다고 한다.
그래서 리프레시 토큰을 사용해서 액세스 토큰을 갱신하는 방식으로 진행했다.
(문제 원인은 토큰 만료 후 후처리를 제대로 하지 않은 것 같다..)
Refresh Token을 사용하는 이유
- 액세스 토큰의 짧은 만료 시간 보완
➡ 액세스 토큰은 보안 강화를 위해 짧은 만료 시간을 가진다. 사용자 입장에선 매우 불편하다.
➡ 리프레시 토큰을 사용해서 새로운 액세스 토큰을 발급 받을 수 있도록 한다.
➡ 사용자는 로그아웃 되지 않고 연속적으로 애플리케이션을 이용할 수 있다.
➡ 재로그인이 필요 없어진다. - 안정성 강화
➡ Refresh Token은 서버 측에서 관리되므로, 보안 사고가 발생했을 때 Refresh Token을 무효화할 수 있다. - 애플리케이션 성능 최적화
➡ 서버 부하 감소
➡ 사용자가 자주 재로그인하지 않아도 되므로 인증 서버의 요청 횟수가 줄어든다. - 보안 강화
➡ Refresh Token은 서버에서 주기적으로 검증된다.
( Refresh Token이 블랙리스트에 있는지, 사용자 세션이 유효한지 등) - 세션 관리
➡ 사용자가 활동하지 않는 동안 세션을 유효하게 유지한다.
➡ 비정상적인 활동이나 세션 하이재킹을 감지하면 토큰을 무효화하여 세션을 종료한다.
Refresh Token 동작 과정 (in my 프로젝트)
내가 프로젝트에 도입한 토큰의 동작 과정을 정리해 보겠다.
1️⃣ 로그인
//Controller
@PostMapping("/user/login-page")
public ResponseEntity<?> authenticateUser(@RequestBody LoginRequestDto loginRequest) {
try {
AuthResponse authResponse = authService.authenticateUser(loginRequest.getUsername(), loginRequest.getPassword());
return ResponseEntity.ok(authResponse);
} catch (Exception e) {
log.error("Authentication failed", e);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication failed");
}
}
//Service
public AuthResponse authenticateUser(String username, String password) {
//사용자 인증
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(username, password)
);
//SecurityContext에 인증 정보 저장
SecurityContextHolder.getContext().setAuthentication(authentication);
//username으로 access token, refresh token 발급
String authenticatedUsername = ((UserDetails) authentication.getPrincipal()).getUsername();
String accessToken = jwtUtil.generateAccessToken(authenticatedUsername);
String refreshToken = jwtUtil.generateRefreshToken(authenticatedUsername);
refreshTokenRepository.save(new RefreshToken(authenticatedUsername, refreshToken));
return new AuthResponse(accessToken, refreshToken);
}
- 로그인 시도 [ POST ("/로그인") ]
- Service에서 인증 처리
➡ authenticationManager.authenticate() - 사용자 인증
➡ 인증에 성공하면 SecurityContextHolder에 인증 객체 세팅
➡ 인증 객체에서 username 추출해서 JWT 액세스 및 리프레시 토큰 생성
➡ 리프레시 토큰 생성 후 데이터베이스에 저장
➡ 응답으로 AuthResponse(access Token, refresh Token) 반환 - Controller가 AuthResponse 객체 반환하여 클라이언트에게 액세스 토큰과 리프레시 토큰 전달
2️⃣ 로그아웃
//Controller
@PostMapping("/user/logout")
public ResponseEntity<?> logout(@RequestBody Map<String, String> request) {
String refreshToken = request.get("refreshToken");
log.info("Received refresh token: {}", refreshToken);
try {
//로그아웃 시 refresh token을 삭제하고 블랙리스트에 추가
authService.logout(refreshToken);
tokenBlacklistService.blacklistToken(refreshToken);
return ResponseEntity.ok().contentType(APPLICATION_JSON)
.body("{\"msg\" : \"로그아웃 되었습니다.\"}");
} catch (IllegalArgumentException e) {
log.error("Invalid refresh token", e);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage());
}
}
//Service
public void logout(String refreshToken) {
if (jwtUtil.validateToken(refreshToken)) {
refreshTokenRepository.deleteByToken(refreshToken);
} else {
throw new IllegalArgumentException("Invalid refresh token");
}
}
- 로그아웃 시도 [ POST ("/로그아웃") ]
- Map<String, String> 형태의 요청 수신 받음 { "refreshToken": "토큰 값" }
3. request.get("refreshToken") - 토큰 값 꺼냄
4. Service에서 토큰의 유효성 검증 후, 토큰 값으로 DB에서 리프레시 토큰을 찾아 삭제
5. 블랙리스트 데이터베이스에 해당 리프레시 토큰 저장
3️⃣ Refresh Token으로 새로운 Access Token 발급
//Controller
@PostMapping("/refresh")
public ResponseEntity<?> refreshAccessToken(@RequestParam String refreshToken) {
try {
AuthResponse authResponse = authService.refreshAccessToken(refreshToken);
return ResponseEntity.ok(authResponse);
} catch (IllegalArgumentException e) {
log.error("Refresh token is invalid or does not exist", e);
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(e.getMessage());
}
}
//Service
public AuthResponse refreshAccessToken(String refreshToken) {
if (!jwtUtil.validateToken(refreshToken)) {
throw new IllegalArgumentException("Refresh token이 유효하지 않습니다.");
}
//Refresh token에서 username 추출
String username = jwtUtil.getUsernameFromToken(refreshToken);
RefreshToken storedToken = refreshTokenRepository.findByToken(refreshToken);
//Refresh token이 존재하고 username이 일치하면 새로운 access token 발급
if (storedToken != null && storedToken.getUsername().equals(username)) {
String newAccessToken = jwtUtil.generateAccessToken(username);
return new AuthResponse(newAccessToken, null);
}
throw new IllegalArgumentException("Refresh token이 존재하지 않습니다.");
}
- 액세스 토큰 만료
➡ 사용자가 액세스 토큰 만료 상테에서 액세스 토큰 사용
➡ 서버가 클라이언트에게 더 이상 액세스가 허용되지 않음을 알림 - 클라이언트 요청 [ POST ("/발급 요청")
➡ 클라이언트는 서버에서 발급 받은 리프레시 토큰을 사용하여 새로운 액세스 토큰 요청 - Controller에서 요청 수신
➡ 리프레시 토큰을 파라미터로 받음 - Service에서 새로운 액세스 토큰 발급
➡ 토큰의 유효성 검증
➡ 리프레시 토큰에서 username 추출 후, 데이터베이스에서 해당하는 리프레시 토큰 조회
➡ 조회한 리프레시 토큰이 존재하고 username이 일치하면 new 액세스 토큰 생성
➡ new 액세스 토큰을 포함한 AuthResponse 객체 반환 - Controller 응답 반환
BlackListToken 관리
@Scheduled(cron = "0 0 0 * * ?") //매일 자정에 실행
public void cleanUpExpiredTokens() {
LocalDateTime expiryDate = LocalDateTime.now().minusDays(7); //7일 이전 토큰 삭제
log.info("오래된 토큰이 삭제됩니다. {}", expiryDate);
blackListRepository.deleteByCreatedDateBefore(expiryDate);
}
스케줄러를 이용해서 매 자정마다 7일 이전의 토큰을 삭제하도록 하였다.
'log.info' 카테고리의 다른 글
[회복 탄력성] Resilience4j의 Circuit Breaker, Retry (2) | 2024.07.01 |
---|---|
[Kafka] 이론 (0) | 2024.06.30 |
[Docker] Docker Compose (0) | 2024.06.18 |
[Docker] 볼륨 vs 바인드 마운트 (1) | 2024.06.17 |
[Redis] 데이터 추가 및 관리 명령어 (0) | 2024.06.17 |