들어가면서
이 글에서는 Spring Security의 JWT 방식(이하 `기본 JWT 방식`)이 아닌 Jwt와 인증 테이블(DB)을 사용해서 인증과 인가를 구현할 때의 고민과 로그아웃을 구현한 방법을 기록한다. Jwt를 통한 인증과 인가에 대한 배경지식이 있어야 이해가 수월할 것이다.
기본 JWT 방식의 특성과 한계
기본 JWT 방식은 토큰 발급 후 `서버에 사용자 정보나 만료 시간을 저장하지 않아` 무상태(stateless)로 동작한다. 이로 인해 로그아웃이나 보안 이슈 발생 시, 서버에서 토큰을 세밀하게 관리하기 어렵다.
Refresh Token을 서버에 저장하지 않기 때문에, Access Token 만료 시 자동 재발급 기능 구현이 힘들 것이다. Access Token의 만료 기간을 짧게 하면 로그인을 자주해야하기 때문에 `사용자 경험이 저하`되고, 길게 하면 보안 위험이 증가한다.
기본 JWT 방식의 보안 문제
이 방식의 로그아웃은 `클라이언트에서 JWT를 삭제하는 로직`이다. 클라이언트 측에서 JWT를 삭제한다는 것은, 사용자가 로그아웃할 때 브라우저의 로컬 스토리지, 세션 스토리지, 혹은 쿠키 등에 저장되어 있던 JWT 토큰을 지워서 더 이상 인증 요청에 포함되지 않도록 만드는 것을 의미한다.
즉, 서버는 토큰 상태를 관리하지 않기 때문에 로그아웃 시 서버에 따로 "토큰 폐기"를 요청할 수 없고, 클라이언트에서 토큰을 제거함으로써 사용자가 이후 요청 시 인증 토큰 없이 접근하게 되는 것이다. 하지만 이미 발급된 토큰은 만료 시간까지 유효하기 때문에, 클라이언트에서 삭제한 것과 별개로 토큰이 탈취된 경우에는 여전히 위험할 수 있다.
더 심각한 상황으로 Refresh Token이 탈취되면, 해커가 이를 이용해 계속 새로운 토큰을 발급받을 위험이 있으므로, 서버에서 Refresh Token을 관리하고 재발급 시 검증하는 추가 보안 조치가 필요하다.
결국
`서버에서 토큰 관리를 할 수 있도록` DB에 인증 테이블을 만들고 Jwt를 함께 사용하면
1. 사용자 경험을 향상시킬 수 있고
2. 보안을 강화할 수 있다.
하지만 구현하기가 더 까다롭고, DB를 사용하는 시간적, 공간적 비용이 필요할 것이다. 단점보다 장점을 크게 봤기에 이 인증 방식을 선택했다.
그럼 로그아웃을 어떻게 구현할 건데? 블랙리스트 토큰 테이블로.
로그아웃 할 때 Refresh Token을 `블랙리스트 토큰 테이블에 저장`하는 것이다.
(처음엔 별생각 없이 로그아웃 하면 Refresh Token을 테이블에서 삭제하는 삽질을 해버렸다..)
Access Token이 만료되어 권한을 얻기 위해 Refresh Token으로 새로운 Access Token을 요청하는 경우, 서버는 `해당 Refresh Token이 블랙리스트 토큰 테이블에 저장되어있다면 로그아웃된 유저라는 것`을 알 수 있기 때문에 Access Token 재발급을 거절할 수 있다.
사실 Access Token이 탈취당한 경우, 만료될 때까지 기다려야 한다는 점은 두 방법이 동일하다.
하지만 Refresh Token을 통한 Access Token의 재발급이 가능하기에 Access Token의 만료 기간을 상대적으로 짧게 설정할 수 있어 보안 측면에서 확실히 개선된다.
AuthService.java
사용자가 `로그인`을 하면, UserId를 SaveAuth()에 전달하고 `Refresh Token과 Access Token`이 AuthResponse객체에 저장되어 반환된다.
@Transactional
public AuthResponse login(AuthRequest authRequest) {
Long userId = userService.getUserByEmail(authRequest.email()).getId();
AuthResponse authResponse = saveAuth(userId);
String plainPassword = authRequest.password();
String hashedPassword = userService.getUserById(userId).getPassword();
if (encryptHelper.isMatch(plainPassword, hashedPassword)) {
return authResponse;
}
throw new NotFoundException(FAILED_LOGIN_BY_ANYTHING);
}
사용자가 `로그아웃`을 하면 User의 RefreshToken을 블랙리스트 테이블에 추가해 준다.
@Transactional
public void logout(Long userId) {
authRepository.findByUserId(userId).ifPresentOrElse(
auth ->
blacklistRepository.save(BlacklistToken.of(auth.getRefreshToken())),
() -> {
throw new NotFoundException(NOT_FOUND_USER_ID);
}
);
}
로그인되어 있는 상태에서 사용자의 AccessToken이 만료됐을 때는 재로그인할 필요 없이 `액세스 토큰을 재발급`해주는 부분이다.
재발급 요청에 포함된 RefreshToken이 블랙리스트에 존재하면 재발급을 거부하고 예외를 던진다.
왜냐하면 블랙리스트에 존재한다는 것이 로그아웃했음을 의미하기 때문이다.
public String createAccessTokenByRefreshToken(String refreshToken) {
boolean isBlacklisted = blacklistRepository.existsByRefreshToken(refreshToken);
if (isBlacklisted) {
throw new AuthException(BLACKLISTED_TOKEN);
}
Auth auth = getAuthByRefreshToken(refreshToken);
Long userId = userService.getUserById(auth.getUserId()).getId();
return jwtProvider.createAccessToken(userId);
}
개선 사항
프로젝트 당시에 팀원의 코드리뷰를 통해 고민하게 된 부분이 있다.
블랙리스트 토큰 테이블에 추가된 {UserId, RefreshToken}의 삭제를 고려해주지 않았다. 유효한 Refresh Token을 가지고 있어도 로그인은 정상적으로 될 것이다. 이후 만료기간이 지나면 Access Token 재발급 과정에서 서버가 해당 Refresh Token을 "로그아웃 상태"로 인식하게 되어 재발급을 거부할 것이다.
(처음엔 스케줄링을 통해 특정 주기마다 한 번씩 CLEAN 시켜주면 되겠다.라고 생각했지만)
아마 로그인 API 로직에서 해당 유저의 Refresh Token을 블랙리스트 토큰 테이블에서 삭제하도록 개선하면 될 것이다. 그러면 정상적으로 로그인 시 Access Token을 발급받을 수 있고 권한을 가지게 될 것이다.
참고한 글
'Spring' 카테고리의 다른 글
Spring 프로젝트에 로컬 캐시를 도입해보자. (26) | 2024.01.12 |
---|---|
[Spring] 의존성을 주입해주는 주체 - jar, @Profile, @Order (0) | 2023.09.27 |
[Spring] 의존 주입 패턴을 예제와 함께 알아보자. (0) | 2023.09.27 |
[Spring] 의존 역전에 대해 알아보자. (0) | 2023.09.26 |
[Spring] getter, setter, 생성자로 알아보는 객체지향적인 코드에 대하여 (0) | 2023.09.25 |