사용자가 로그인을 하면, access token을 발급해준다.
토큰 검증은 토큰 자체에 담긴 사용자 정보를 바탕으로 검증만 한다. 로그인된 사용자가 발급받은 토큰이 맞는지 확인하는 과정이 없다.
발급 받은 토큰이 제3자에게 탈취당하게 된다면 해당 사용자의 정보가 노출될 수 있는 위험이 있다. 또한 access token의 expire time이 1일로 엄청나게 길기 때문에 보안에 취약한 구조이다. 따라서 유효기간이 짧은 access token과 유효기간이 긴 refresh token을 나누어 관리하기로 했다.
원리
만료된 access token을 보내면 refresh token이 redis db에 저장된 값과 일치할때 access token을 재발급
public class AuthController {
private final AccessTokenService accessTokenService;
private final RefreshTokenService refreshTokenService;
private final CookieUtils cookieUtils;
@GetMapping("/reissue")
public ResponseEntity<?> refreshToken(@RequestHeader(name = HttpHeaders.AUTHORIZATION) String accessToken,
@CookieValue(name = "refresh-token", required = false) String refreshToken) {
if (refreshToken == null) {
return ResponseEntity.badRequest()
.body("Refresh token is not valid. try to login again.");
}
JwtTokenDto jwtTokenDto = refreshTokenService.reissueToken(accessToken, refreshToken);
ResponseCookie refreshTokenCookie = cookieUtils.createRefreshTokenCookie(refreshToken);
return ResponseEntity.status(HttpStatus.OK)
.header(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString())
.body(new RefreshTokenResponse(jwtTokenDto));
}
}
public class RefreshTokenService {
private final UserDetailsService userService;
private final JwtUtils jwtUtils;
private final UserRepository userRepository;
private final TokenRedisRepository tokenRepository;
@Transactional
public JwtTokenDto reissueToken(String accessToken, String refreshToken) {
String userId = jwtUtils.getUserId(accessToken.replace("Bearer", ""));
TokenEntity targetToken = tokenRepository.findById(userId).orElseThrow(
() -> new UserNotFoundException("저장된 정보가 존재하지 않습니다.")
);
String targetTokenId = targetToken.getTokenId();
String findTokenId = jwtUtils.getRefreshTokenId(refreshToken.replace("Bearer", ""));
if (!jwtUtils.isValidToken(refreshToken.replace("Bearer", "")) || !targetTokenId.equals(findTokenId)) {
tokenRepository.delete(targetToken);
throw new RefreshTokenNotValidException();
}
UserEntity user = userRepository.findById(Long.valueOf(userId)).orElseThrow(
() -> new UserNotFoundException("user not found"));
String newAccessToken = jwtUtils.generateAccessToken(user.getId(), user.getRoles());
Date expiredTime = jwtUtils.getExpiredTime(newAccessToken);
return JwtTokenDto.builder()
.accessToken(newAccessToken)
.refreshToken(refreshToken)
.accessTokenExpiredDate(expiredTime)
.build();
}
}
public class AuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final UserService userService;
private final RefreshTokenService refreshTokenService;
private final Environment env;
public AuthenticationFilter(AuthenticationManager authenticationManager, UserService userService,
RefreshTokenService refreshTokenService, Environment env) {
super(authenticationManager);
this.userService = userService;
this.refreshTokenService = refreshTokenService;
this.env = env;
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
Authentication authResult) throws IOException, ServletException {
CookieUtils cookieUtils = new CookieUtils(env);
JwtUtils jwtUtils = new JwtUtils(env);
String email = ((CustomUserDetails) authResult.getPrincipal()).getUsername();
UserDto user = userService.getUserByEmail(email);
String accessToken = jwtUtils.generateAccessToken(user.getUserId(), user.getRole());
String refreshToken = jwtUtils.generateRefreshToken();
refreshTokenService.updateRefreshToken(user.getUserId(), jwtUtils.getRefreshTokenId(refreshToken));
ResponseCookie resCookie = cookieUtils.createRefreshTokenCookie(refreshToken);
Cookie cookie = cookieUtils.of(resCookie);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.addCookie(cookie);
Map<String, Object> token = Map.of(
"access-token", accessToken,
"expired-time", new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
.format(jwtUtils.getExpiredTime(accessToken))
);
new ObjectMapper().writeValue(response.getOutputStream(), token);
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
try {
LoginDto creds = new ObjectMapper().readValue(request.getInputStream(), LoginDto.class);
UserDetails user = userService.loadUserByUsername(creds.getEmail());
return getAuthenticationManager().authenticate(
new UsernamePasswordAuthenticationToken(user, creds.getPassword(), user.getAuthorities()));
} catch (Exception e) {
throw new RuntimeException("[ERROR] LOGIN FAILED");
}
}
}
처음에 access token 하나만 하루단위로 발급했을때, 분명 다른 테스트 계정으로 로그인한건데도 다른 계정의 정보가 뜨는 것을 보고 빨리 바꿔야겠다는 생각이 들었다. 이번 토큰을 수정하면서 나는 사용자가 id, pw를 입력하고 내부에서 어떤식으로 인증과정을 처리하는지 쭈욱 따라가면서 코드단위로 학습했다. 또한, cookie를 사용하여 클라이언트에 저장하여 활용할 수 있는 방법을 배웠다. 이 점들을 이용하여 마이크로서비스끼리 통신을 할 때에 검증하는 과정을 최소화할 수 있도록 변경해봐야겠다.