안녕!
우아한테크코스 5기 [스탬프크러쉬]팀 깃짱이라고 합니다.
사장모드: stampcrush.site/admin
고객모드: stampcrush.site
💋 인트로
안녕하세요.
우아한테크코스 5기 깃짱입니다.
이번 포스팅에서는 이전에 JWT 방식에서 로그아웃, Refresh Token 만들기(1)에 이어서, Refresh Token과 로그아웃의 구현에 대해서 포스팅하려고 합니다.
💋 현재 상황
현재 상황은 아래와 같습니다.
✔️ 서버의 Token 발급 응답
인증을 완료하면, 서버에서 아래와 같은 응답을 보내줍니다.
Header
HTTP/1.1 200
Content-Type: application/json
Transfer-Encoding: chunked
Date: Wed, 08 Nov 2023 01:12:12 GMT
Keep-Alive: timeout=60
Connection: keep-alive
Body
{
"accessToken": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwiZXhwIjoxNjk5NDkyMzMyfQ.kAsL4WF8lcIX8vQszUsVVn55qGsLvbUogFqksIDDUnteNravGt_i8OD3X0vq4ELXK9i_SiWen08L7fouAcrnPg",
"refreshToken": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwiZXhwIjoxNzAwMDEwNzMyfQ.36Yjn3oUw4wq9qE-S0XZEzgOGSJt9VaRdVCoO7OJhBIsF9jUWd-r-4uMwYOY98mu7lZ1Ltc6KjrrkPYznH4Rlg",
"grantType": "Bearer",
"expiresIn": 86400
}
클라이언트(프론트엔드)는 위 응답에서 accessToken
만을 local에 저장해두고 있다가, 이후 인증이 필요한 모든 요청에서, Request Header에 Authorization
파라미터를 포함해 요청을 보냅니다.
계속해서 요청을 보내다가, accessToken
가 만료되면 서버에서 예외를 발생시키고, 이후에 로그인이 유지되지 않습니다.
✔️ 토큰 생성 코드
현재 토큰을 생성하고 있는 클래스입니다. accessToken
의 만료는 24시간마다 발생합니다.
하지만, 카페 사장님이 이용하고 있는 계정에서 너무 로그인이 자주 풀린다는 피드백이 들어왔습니다.
@Component
@RequiredArgsConstructor
public class AuthTokensGenerator {
private static final String BEARER_TYPE = "Bearer";
private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24; // 24시간
private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 7; // 7일
private final JwtTokenProvider jwtTokenProvider;
// ...
}
보안 상의 이유로 액세스 토큰의 유효기간을 24시간보다 더 늘리는 것은 좋지 않다고 생각해서, 현재 이미 발급중인 refreshToken
을 사용해서 로그인을 계속해서 유지할 수 있도록 하기로 결정했습니다.
refreshToken
를 사용한다면, 앞선 포스팅에서 이야기했듯, 토큰의 정신(?)을 쪼금은 포기하면서 데이터베이스를 조회해야 하는 일이 발생합니다. refreshToken
을 데이터베이스에 저장하도록 했습니다.
💋 소스 코드
변동된 코드는 아래 Pull Request를 참고하시면 될 것 같습니다.
https://github.com/woowacourse-teams/2023-stamp-crush/pull/923
💋 로그아웃한 refreshToken을 저장할 BlackList 테이블을 만든다
아래와 같이 invalid_refresh_token
만을 저장하는 테이블을 생성했습니다.
이제 이곳에 저장된 refreshToken
은 로그아웃 된 토큰이므로, 이곳에 저장된 것을 사용해서 새롭게 토큰을 발급하면 안됩니다.
@AllArgsConstructor
@NoArgsConstructor(access = PROTECTED)
@Getter
@Entity
public class BlackList {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "invalid_refresh_token")
private String invalidRefreshToken;
public BlackList(String invalidRefreshToken) {
this.invalidRefreshToken = invalidRefreshToken;
}
}
💋 로그아웃 시 Refresh Token을 서버에서 관리
로그아웃 시에 해당 API를 사용하면 서버 측에서도 해당 refreshToken
이 로그아웃된 사용자의 것이라는 사실을 인지해, 로그아웃 이후에도 refreshToken
을 누군가 사용하는 것을 막을 수 있습니다.
✔️ Logout API
[Request]
GET /api/admin/logout
Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwiZXhwIjoxNjk5NDk2NzUzfQ.p-gAUwu6QgnD-8xSHeKDYPuT9ak413UOpiRmhkUluqYoDbbEBJsq1XQg1ahqHKzmufvc4MaTpoJI2g8yb7CkHQ
Refresh: eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwiZXhwIjoxNzAwMDE1MTUzfQ.fGIV5IyT9aHptd-1c_fOhPVudpDkR-JOIKqZyAZ9Na_ZPwnOs-61OjBPehWhbVv5MMRa9qcseIZdo8I6GJ5PHA
[Response]
204 NO CONTENT HTTP/1.1
✔️ 구현 방법
대략적인 절차는 아래와 같습니다.
클라이언트로부터 요청과 함께 refreshToken
을 받습니다.
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/admin")
public class ManagerLogoutController {
private final ManagerLogoutService managerLogoutService;
@PostMapping("/logout")
public ResponseEntity<Void> logout(
OwnerAuth owner,
@RequestHeader("Refresh") String refreshToken
) {
managerLogoutService.logout(owner.getId(), refreshToken);
return ResponseEntity.noContent().build();
}
}
refreshToken
이 유효한지(=조작된 토큰이 아닌지), 이미 로그아웃된 사용자의 토큰이 아닌지(=BLACK_LIST
에 없는지), 현재 요청을 보내는 사용자의 토큰이 맞는지(=함께 받은 accessToken
에서 추출한 ID와 refreshToken
에서 추출한 ID가 동일한지) 검증합니다.
@RequiredArgsConstructor
@Service
@Transactional
public class ManagerLogoutService {
private final BlackListRepository blackListRepository;
private final RefreshTokenValidator refreshTokenValidator;
public void logout(Long id, String refreshToken) {
refreshTokenValidator.validateToken(refreshToken);
refreshTokenValidator.validateTokenOwnerId(refreshToken, id);
refreshTokenValidator.validateLogoutToken(refreshToken);
blackListRepository.save(new BlackList(refreshToken));
}
}
위의 검증을 모두 통과하면 refreshToken
을 BLACK_LIST
테이블에 저장합니다.
검증과 관련된 로직들은 별도의 클래스로 뺐습니다.
@RequiredArgsConstructor
@Component
public class RefreshTokenValidator {
private final AuthTokensGenerator authTokensGenerator;
private final BlackListRepository blackListRepository;
public void validateToken(String refreshToken) {
if (!authTokensGenerator.isValidToken(refreshToken)) {
throw new UnAuthorizationException("[ERROR] 유효하지 않은 Refresh Token입니다!");
}
}
public void validateTokenOwnerId(String refreshToken, Long id) {
final Long ownerId = authTokensGenerator.extractMemberId(refreshToken);
if (!ownerId.equals(id)) {
throw new UnAuthorizationException("[ERROR] 로그인한 사용자의 Refresh Token이 아닙니다!");
}
}
public void validateLogoutToken(String refreshToken) {
if (blackListRepository.existsByInvalidRefreshToken(refreshToken)) {
throw new UnAuthorizationException("[ERROR] 이미 로그아웃된 사용자입니다!");
}
}
}
💋 Refresh Token을 통해 전체 토큰 재발급
✔️ 2가지 방식 중 선택
- 프론트엔드에서 요청을 보낼 때, 항상
accessToken
,refreshToken
을 함께 보내고,accessToken
만료 시 자동으로refreshToken
이 유효한지 확인해서 유효하다면 인증을 통과시키고,accessToken
,refreshToken
을 새롭게 발급한다. accessToken
만 보내다가,accessToken
이 만료되어 요청이 실패한 경우에,refreshToken
을 보내accessToken
,refreshToken
을 새롭게 발급받고, 이후에 동일한 요청을 새로운accessToken
으로 다시 보낸다.
1번은 refreshToken
을 매 요청에 노출시켜서 보안상의 문제가 있다고 판단해 배제했습니다. 2번 방법을 사용해서 구현하기로 결정했습니다.
✔️ Refresh Token API
아래는 refreshToken
을 재발급 받는 API입니다.
[Request]
Header
GET /api/admin/auth/reissue-token
Refresh: eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwiZXhwIjoxNzAwMDE1MTUzfQ.fGIV5IyT9aHptd-1c_fOhPVudpDkR-JOIKqZyAZ9Na_ZPwnOs-61OjBPehWhbVv5MMRa9qcseIZdo8I6GJ5PHA
[Response]
refreshToken
가 정상(조작되지 않음)이고, 만료되지 않은 경우
Header
200 OK HTTP/1.1
Body
{
"accessToken": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwiZXhwIjoxNjk5NDkyMzMyfQ.kAsL4WF8lcIX8vQszUsVVn55qGsLvbUogFqksIDDUnteNravGt_i8OD3X0vq4ELXK9i_SiWen08L7fouAcrnPg",
"refreshToken": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwiZXhwIjoxNzAwMDEwNzMyfQ.36Yjn3oUw4wq9qE-S0XZEzgOGSJt9VaRdVCoO7OJhBIsF9jUWd-r-4uMwYOY98mu7lZ1Ltc6KjrrkPYznH4Rlg",
"grantType": "Bearer",
"expiresIn": 86400
}
refreshToken
가 만료되었거나, 비정상인 경우
Header
401 BAD REQUEST HTTP/1.1
Body
None
그리고, 추가적으로 Refresh Token을 사용하면서 서버 쪽에서도 refreshToken
을 데이터베이스를 통해 관리하게 되었습니다. 따라서 로그아웃 로직을 구현할 때 기존에 프론트엔드에서 로컬 저장소에서 토큰을 모두 삭제하는 불완전한 방식으로 구현했던 것을 보완할 수 있게 되었습니다.
✔️ 구현 방법
대략적인 절차는 아래와 같습니다.
클라이언트로부터 refreshToken
을 별도로 전달 받습니다.
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/admin/auth")
public class ManagerReissueTokenController {
private final ManagerReissueTokenService managerReissueTokenService;
@GetMapping("/reissue-token")
public ResponseEntity<AuthTokensResponse> reissueToken(
@RequestHeader("Refresh") String refreshToken
) {
return ResponseEntity.ok(
managerReissueTokenService.reissueToken(refreshToken)
);
}
}
refreshToken
이 유효한지(=조작되지 않았는지), 이미 로그아웃된 사용자의 토큰이 아닌지(=BlackList
에 저장된 토큰인지) 검증합니다.
위의 검증을 모두 통과했다면, accessToken
, refreshToken
현재 시간 기점으로 새롭게 발급합니다.
@RequiredArgsConstructor
@Service
@Transactional
public class ManagerReissueTokenService {
private final AuthTokensGenerator authTokensGenerator;
private final RefreshTokenValidator refreshTokenValidator;
public AuthTokensResponse reissueToken(final String refreshToken) {
refreshTokenValidator.validateToken(refreshToken);
refreshTokenValidator.validateLogoutToken(refreshToken);
final Long memberId = authTokensGenerator.extractMemberId(refreshToken);
return authTokensGenerator.generate(memberId);
}
}
여기서 사용하는 검증도 앞선 검증과 겹치는 로직이 많아서, 검증만을 위한 도메인으로 분리했습니다. (앞에서 언급한 클래스와 동일합니다.)
@RequiredArgsConstructor
@Component
public class RefreshTokenValidator {
private final AuthTokensGenerator authTokensGenerator;
private final BlackListRepository blackListRepository;
public void validateToken(String refreshToken) {
if (!authTokensGenerator.isValidToken(refreshToken)) {
throw new UnAuthorizationException("[ERROR] 유효하지 않은 Refresh Token입니다!");
}
}
public void validateTokenOwnerId(String refreshToken, Long id) {
final Long ownerId = authTokensGenerator.extractMemberId(refreshToken);
if (!ownerId.equals(id)) {
throw new UnAuthorizationException("[ERROR] 로그인한 사용자의 Refresh Token이 아닙니다!");
}
}
public void validateLogoutToken(String refreshToken) {
if (blackListRepository.existsByInvalidRefreshToken(refreshToken)) {
throw new UnAuthorizationException("[ERROR] 이미 로그아웃된 사용자입니다!");
}
}
}
도움이 되었다면, 공감/댓글을 달아주면 깃짱에게 큰 힘이 됩니다!🌟
비밀댓글과 메일을 통해 오는 개인적인 질문은 받지 않고 있습니다. 꼭 공개댓글로 남겨주세요!
'PROJECT > Stamp Crush' 카테고리의 다른 글
[우테코] 스탬프크러쉬를 마무리하며 (2) | 2023.12.04 |
---|---|
[우테코] 무중단 배포 자동화(2): 배포서버에 Github Actions self-hosted runners, Nginx, Docker 설정 (2) | 2023.10.28 |
[우테코] 무중단 배포 자동화(1): Github Actions workflow 생성, Secrets 설정 (2) | 2023.10.27 |
[우테코] 스탬프크러쉬의 HikariCP 커넥션 풀 사이즈 설정 (0) | 2023.10.17 |
[모집공고] 스탬프크러쉬 기획/영업/마케팅 팀원 모집 (0) | 2023.10.16 |