redisUtil에 기본적인 메소드들을 다 만들어 뒀으니 이제 컨트롤러와 필터에 적용시킬 차례다.
로그아웃 구현
우선 사용자가 로그아웃을 요청했을 시, 시용자의 request 헤더에서 액세스 토큰을 추출하여 해당 액세스 토큰을 블랙리스트 처리한다.
@GetMapping("/member/{id}/logout")
public ResponseEntity<KakaoLogoutDto> logoutKakao(@PathVariable("id") Long id, @RequestHeader("Authorization") String accessToken){
Member byId = memberService.findById(id);
ClientResponse clientResponse = CallApiService.checkKakaoToken(byId.getKakaoAccessToken());
//redis를 통한 블랙리스트 등록
redisUtil.setBlackList(JwtUtils.getTokenFromHeader(accessToken), "accessToken", 30);
if(clientResponse.statusCode() == HttpStatusCode.valueOf(401)) {
return ResponseEntity.ok(new KakaoLogoutDto(1L));
}
return ResponseEntity.ok(CallApiService.logoutKakao(byId, byId.getKakaoAccessToken()));
}
필자는 카카오 소셜 로그인을 구현하였기에 카카오 리소스 서버에서 로그아웃을 요청하는 코드까지 작성해둔 상태이다.
소셜 로그인을 구현하지 않은 사람은 주석이 적힌 부분의 코드만 확인하면 된다.
다음과 같이 추출한 액세스 토큰을 key로 설정한다. value는 의미가 없기에 "accessToken"이란 고정값으로 설정해 주었다.
마지막 인자는 TTL이다. 해당 프로젝트에서 액세스 토큰의 만료 시간을 30분으로 설정해뒀기에, 블랙리스트 TTL을 30분으로 설정해 주었다.
이제 로그아웃한 사용자의 액세스 토큰을 탈취한 범죄자가 해당 액세스 토큰으로 유저 api에 접근하는 상황을 생각해보자.
해당 상황을 방지하기 위해 SpringSecurity Filter Chain에 Jwt Verify Filter를 하나 등록해 주었고, 해당 필터에서 액세스 토큰이 블랙리스트에 등록된 토큰인지 아닌지 확인하는 코드를 작성해주었다.
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String authHeader = request.getHeader(JwtConstants.JWT_HEADER);
try {
checkAuthorizationHeader(authHeader);
String token = JwtUtils.getTokenFromHeader(authHeader);
System.out.println("token = " + token);
if (redisUtil.hasKeyBlackList(token)){
throw new CustomException("로그아웃된 사용자 입니다.");
}
Authentication authentication = JwtUtils.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response); // 다음 필터로 이동
} catch (Exception e) {
Gson gson = new Gson();
String json = "";
if (e instanceof CustomExpiredJwtException) {
json = gson.toJson(Map.of("Token_Expired", e.getMessage()));
response.setStatus(401);
} else {
json = gson.toJson(Map.of("error", e.getMessage()));
response.setStatus(500);
}
response.setContentType("application/json; charset=UTF-8");
PrintWriter printWriter = response.getWriter();
printWriter.println(json);
printWriter.close();
}
}
마찬가지로 사용자의 request에서 액세스 토큰을 추출한 뒤 해당 액세스 토큰이 블랙리스트에 key로 등록돼 있는지 확인한다.
만약 블랙리스트에 해당 액세스 토큰이 key로 등록되어 있다면 예외처리를 해준다.
간단하게 로그아웃을 구현해봤고, 이제 토큰 재발급을 구현해 보겠다.
토큰 재발급
사용자가 로그인에 성공하면 서버 내부에서 자체적으로 jwt 토큰을 발급하여 반환해준다.
필자는 Spring Security Oauth2 기능을 활용하여 소셜 로그인만 구현한 상황이기에 로그인에 성공한 이후 동작한 Handler를 등록해 주었고, 해당 핸들러에서 토큰을 발급해 주었다.
소셜로그인이 아닌 api로 로그인을 구현한 사람들은 해당 api 내부에서 다음과 같은 작업을 진행해주면 된다.
@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
PrincipalDetail principal = (PrincipalDetail) authentication.getPrincipal();
log.info("authentication.getPrincipal() = {}", principal);
String refreshToken = JwtUtils.generateToken(principal.getMemberInfo(), JwtConstants.REFRESH_EXP_TIME);
String accessToken = JwtUtils.generateToken(principal.getMemberInfo(), JwtConstants.ACCESS_EXP_TIME);
// principal에서 로그인하는 유저의 이메일을 기반으로 refresh 토큰을 redis에 저장
redisUtil.set(principal.getMemberDto().email(),refreshToken,60*24);
다음과 같이 handler 인자의 authentication을 통해 사용자의 정보를 받아왔고,
사용자의 정보를 바탕으로 액세스 토큰과 리프레쉬 토큰을 발급받아 주었다.
이후 redis에 사용자의 email을 key로 리프레쉬 토큰을 value로 하여 저장해 주었다.
TTL은 리프레쉬 토큰의 만료 시간을 1일로 설정했기 때문에 똑같이 1일로 TTL을 설정해 주었다.
이제 남은 흐름은 다음과 같다.
1. 유저가 리프레쉬 토큰을 보내며 토큰 재발급을 요청한다.
2. 유저의 이메일을 key 값으로 넣어 value를 redis에서 찾아온다.
3. 찾아온 value와 유저가 보낸 리프레쉬 토큰이 일치하는지 확인한다.
4. 만약 일치한다면 새로운 액세스 토큰과 리프레쉬 토큰을 발급하여 반환해준다.
4-1. 일차하지 않는다면 예외 처리를 해준다.
5. 새로 발급해준 리프레쉬 토큰을 다시 redis에 저장시켜 다음 토큰 재발급에 활용할 수 있게 해준다.
위와 같은 flow를 구현한 api 코드를 살펴 보겠다.
@GetMapping("/member/{id}/refresh")
public ResponseEntity<?> refresh(@PathVariable("id") Long id, @RequestHeader("Authorization") String authHeader) {
Member byId = memberService.findById(id);
String refreshToken = JwtUtils.getTokenFromHeader(authHeader);
Map<String, Object> claims = JwtUtils.validateToken(refreshToken);
Object o = redisUtil.get(byId.getEmail());
if (o == null) {
throw new CustomException("해당 유저 캐시에 refreshToken이 존재하지 않습니다.");
}
String redisRefreshToken = o.toString();
if (redisRefreshToken.equals(refreshToken)) {
redisUtil.delete(byId.getEmail());
String newAccessToken = JwtUtils.generateToken(claims, JwtConstants.ACCESS_EXP_TIME);
String newRefreshToken = JwtUtils.generateToken(claims, JwtConstants.REFRESH_EXP_TIME);
redisUtil.set(byId.getEmail(), newRefreshToken,60*24);
return ResponseEntity.ok(new RefreshDto(newAccessToken, newRefreshToken));
}
return ResponseEntity.notFound().build();
}
토큰 재발급 요청을 보낼 때 헤더에 액세스 토큰이 아닌 리프레쉬 토큰을 담아 보내달라고 프론트에 요청해둔 상황이었다.
다음과 같이 리프레쉬 토큰을 기반으로 토큰을 재발급 하여 반환해주는 api까지 작성해 보았다.
redis는 활용할 방안이 무궁무진 한 거 같다. 다음 번에 캐시가 아닌 저장소로 활용하여 AOF를 생성하는 방안도 프로젝트에 적용시켜 봐야겠다.
'Tools & Libraries > redis' 카테고리의 다른 글
redis로 토큰 재발급과 로그아웃 구현하기 - 2 (Spring boot에 적용하기) (0) | 2024.07.25 |
---|---|
redis로 토큰 재발급과 로그아웃 구현하기 - 1 (redis의 기본 개념) (2) | 2024.07.25 |