배포환경에서 프론트-백엔드간 API를 테스트하던 중 계속해서 제기됐던 문제가 하나있다.
바로 여러기기를 사용해서 로그인 하는 경우, 기존에 사용하던 기기는 액세스토큰 만료와 동시에 리프레쉬 토큰이 INVALID해진다는것!
이렇게만 말하면 대체 무슨소리인가 싶을수도 있는데, 좀더 자세히 설명해보도록 하겠다.
문제상황 분석
우선, 각 토큰이 포함하고 있는 데이터는 다음과같이 설정되어있었다.
"Access-Token" : userId + providerCode + 동의한약관버전 + 비밀key + 만료시간(1분)
"Refresh-Token" : 비밀key + 만료시간(7일)
위 상태에서, 로그인로직의 경우 다음과같이 동작한다.
로그인에 성공하면 두 토큰을 모두 재발급해준다. 또한 동시에 Refresh토큰값을 DB에 저장한다(이후 검증용도로 사용하기 위해서이다).
여기서 다른 기기를 사용해 다중로그인을 시도한다면 어떨까?
위와같이 새로운 토큰이 발급되면서, DB의 Refresh_token값이 업데이트 된다.
어라? 다중로그인이 되는것같다?
아니다. 진짜 문제는 액세스토큰이 만료되었을때 발생한다.
기존기기의 AccessToken이 1분이 지나 Expired되면, Refresh토큰을 통한 재발급을 시도할것이다.
그런데, 이 기기의 브라우저는 이미 DB에서 사라져버린 Refresh토큰 값을 갖고있다. 따라서 검증중에 에러가 발생하게 된다. 결국 Access토큰 재발급에 실패하고, 로그인을 다시해야하는것이다.
물론 로그인을 다시하면 아까 두번째로 로그인을 시도했던 핸드폰도 같은 에러를 반복하게 될것이다..^^
위와같은 문제를 해결하기 위해서 다양한 방법을 고려해보았다.
가장 처음에 생각했던 해결법은 이미 해당 유저가 발급받은 refersh_token이 있다면 그걸 그대로 리턴해주는 방법이었다(즉 모든 기기의 리프레쉬토큰이 동일해짐). 그러나 이 방법은 refresh_token을 업데이트 해주는데 문제가 될 수 있었다. 로그인을 계속해서 유지하고, 두 토큰간 기한을 일관성있게 유지하기 위해 액세스토큰 발급과정에서 리프레쉬토큰 잔여기한에 따라 재발급해주는 로직이 있는데, 이게 실행되면 다른기기들에 변경된 리프레쉬 토큰을 적용해줄 방법이 없다.
그래서 많은 고민을 하던 중, 구글의 다중기기 정책구현방법에 대해 탐색하게 되었다.
구글은 위와같이 개인정보 설정에서 로그인된 기기를 확인할 수 있다. 즉, 모든 로그인이 다른방식으로 저장되고 있다는 의미였다. 이를 기반으로 로직을 고민해본 결과, 기기에 따라 다른 고유값을 줌으로써 Refresh토큰 로직을 분리할 수 있으리라는 생각이 들었다.
DEVICE_ID를 통해 해결하기
DEVICE_ID를 포함해서 구분하게 되면 다음과 같이 문제가 해결된다.
이 디바이스 아이디 설정에 고민이 많았는데, 가장 좋은 방법은 DeviceId를 획득해 구분하는것이나.. 네이티브앱이 아닌 웹 환경에서 해당 아이디를 획득할 방법이 없었다. cordova API라는게 있긴 했는데, 결국 클라이언트단에서 UUID를 통해 랜덤값을 생성해주는 방식이었다.
그래서 어차피 UUID라면 그냥 백엔드에서 처리하는게 낫다 싶어 백엔드에서 Refresh_token과 같은 수명을 가지는 DEVICE_ID 쿠키를 만들기로 했다. DEVICE_ID쿠키가 없다면 첫 로그인인 기기이므로 새로 만들어주고, 있다면 이전에 로그인한 기록이 있는 쿠키이므로 해당 row를 찾아 업데이트 해주면 되는것이다.
이 방식을 활용하면, 인당 로그인할수있는 기기의 갯수도 제한 할 수 있게 된다. 구글처럼 설정에서 로그인중인 기기를 띄워두고 원격으로 로그아웃(DB에서 리프레쉬토큰 삭제하면 됨)할 수 있는것! 또한 row저장시 최근로그인 시간을 함께 저장함으로써 Batch작업으로 기한이 만료된 row들을 삭제해 DB의 효율성까지 갖출 수 있을것이다.
아무튼, 로직을 정리하면 다음과 같다.
프론트에서 로그인 요청이 왔을때
- 로그인 성공 핸들러에서 쿠키값(key=”device_id”)을 찾는다
- 쿠키가 없다면(처음 로그인 하는 기기이다)
- 액세스토큰 로직 그대로
- 리프레쉬 토큰 로직도 그대로
- UUID 생성한 뒤, 리프레쉬 토큰 DB 저장로직에 device_id로 최근 로그인 시간과 함께 저장한다.
- 리프레쉬 토큰 및 device_id를 HttpOnly쿠키에 저장한다.
- 쿠키가 있다면(이전에 로그인한 적 있는 기기이다)
- 액세스토큰 로직 그대로
- 리프레쉬 토큰 로직도 그대로
- 쿠키값을 꺼낸 뒤, 리프레쉬 테이블에서 (쿠키값+userId)를 바탕으로 리프레쉬 토큰을 찾는다. 그리고 해당 row의 리프레쉬 토큰값과 최근 로그인 시간을 업데이트 한다.
- 리프레쉬 토큰을 저장한다.
- 액세스토큰 재발급 API에선 device-id와 userId기준으로 refersh토큰을 DB에서 검색하므로, 다른 기기에서 로그인한 적이 있더라도 개별적으로 처리되어 영향을 주지 않는다.
(CF.) 쿠키가 존재하는데, 너무 옛날에 로그인했기 때문에 DB에선 데이터가 지워져있을수있다.
이경우, 기존 row를 찾으려고 하면 에러가 남. 따라서 백엔드 로직에선 요청객체의 쿠키존재여부로 분기점을 만드는게 아닌, 해당 row가 DB에 존재하는지를 기준으로 분기를 만든다. 즉, 쿠키가 존재하나 너무 옛날 로그인이여서 row값이 없는경우엔 쿠키가 없는것과 같이 처리한다(새로 저장)
구현하기
로그인
로그인 성공핸들러에서 토큰을 생성해 쿠키를 셋팅하고 있는데, 이때 DEVICE_ID 관련 로직을 추가하면 된다(참고로 나는 Spring Security를 사용하고있다)
@Component
@Slf4j
@RequiredArgsConstructor
public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
//..기타 로직 생략
//쿠키가 있다면 -> 그값을 기반으로 DB 업데이트, 쿠키가 없다면 -> UUID 생성해 DB에 저장+deviceId를 쿠키에 저장
Optional<Cookie> optionalDeviceId = CookieUtil.getCookie(request, DEVICE_CODE);
String deviceId;
if (optionalDeviceId.isPresent()) deviceId = optionalDeviceId.get().getValue();
else deviceId = UuidUtil.createUUID().toString();
Optional<UserRefreshToken> userRefreshToken = userRefreshTokenRepository.findByUserIdAndDeviceId(userInfo.getId(), deviceId);
if (userRefreshToken.isPresent()) userRefreshToken.get().updateRefreshToken(refreshToken.getToken());
else {
User userEntity = userRepository.findByUserId(userInfo.getId());
UserRefreshToken newUserRefreshToken = UserRefreshToken.createUserRefreshToken(userEntity,
refreshToken.getToken(), deviceId);
userRefreshTokenRepository.saveAndFlush(newUserRefreshToken);
//새로운 디바이스코드를 쿠키에 저장
saveCookie(response, request, DEVICE_CODE, refreshTokenExpiry, deviceId);
}
//..기타 로직 생략
}
로그아웃
기존엔 로그아웃시 userId를 기준으로 DB를 탐색하고 삭제했는데, 이대로하면 모든 디바이스에 대해 로그아웃처리가 되어버린다. 따라서 데이터 삭제 기준에 deviceId를 추가한다.
@PostMapping("/logout")
public ResponseDto logout(HttpServletRequest request, HttpServletResponse response) {
String accessToken = request.getHeader("Authorization");
if (accessToken != null && accessToken.startsWith("Bearer ")) {
accessToken = accessToken.substring(7);
}
if (accessToken != null) {
AuthToken authToken = tokenProvider.convertAuthToken(accessToken);
Claims claims = null;
if (authToken.validate()) claims = authToken.getTokenClaims();
else claims = authToken.getExpiredTokenClaims();
if (claims != null) {
String userId = claims.getSubject();
String deviceId = CookieUtil.getCookie(request, DEVICE_CODE).map(Cookie::getValue)
.orElseThrow( () -> new GeneralException(Code.REFRESH_COOKIE_NOT_FOUND));
clearRefreshToken.deleteRefreshTokenByUserIdAndDeviceId(userId, deviceId);
}
}
// 쿠키에서 리프레시 토큰 및 deviceId 제거
CookieUtil.deleteCookie(request, response, REFRESH_TOKEN);
CookieUtil.deleteCookie(request, response, DEVICE_CODE);
return DataResponseDto.of(null, "Logout successful.");
}
결과적으로 아래와 같은 DB가 완성된다. 같은 user임에도 불구하고 device_id 따로 저장하므로 refresh_token을 독립적으로 관리할 수 있게 되었다.
(last_login_at을 기준으로 주기적으로 동면세션을 delete하는 로직은 이후 추가 예정이다.)
과거 몇가지 어플에서 3개이상의 기기에서 로그인이 불가능하다는 정책을 본 적이 있는데, 그 구현방식에 대해 알아볼 수 있는 시간이었다. 네이티브앱의 경우 DEVICE_CODE를 직접 받을 수 있으므로 보다 세밀한 컨트롤이 가능할것같다. 이후 전환을 하게된다면 이부분을 리팩토링해보고싶다ㅎㅎ
'프로젝트 > 빼곡' 카테고리의 다른 글
Spring 명시적 Null값으로 부분 업데이트(PATCH) 구현하기 (3) | 2025.01.02 |
---|---|
다른 도메인 환경에서 쿠키 셋팅하기(Feat.samesite Cookie) (4) | 2024.12.12 |
Redis로 최근검색어 구현하기 (1) | 2024.11.25 |
서버이전을 고려한 Jenkins기반 아키텍처 개선(Feat. GithubActions) (3) | 2024.11.22 |
라즈베리파이를 이용한 홈서버 구축 (1) | 2024.11.21 |