앞에서 살펴본 내용은 인증이 필요한 API가 요청되었을때, Spring Security와 JWT Filter가 어떻게 Access Token으로 인증을 처리하는지 알아보았습니다.
이젠 인증이 필요없지만 토큰 관리에 있어서 가장 중요한 로그인과 로그아웃의 로직에 대해 알아보겠습니다.
1️⃣ 로그인
✔️ API 컨트롤러
@PostMapping("/login")
public ResponseEntity<TokenResponse> login(@RequestBody @Valid AuthRequest.LoginRequest request){
TokenResponse tokenResponse = authService.login(LoginDto.from(request));
return ResponseEntity.ok().body(tokenResponse);
}
로그인 요청이 성공하면 Access Token, Refresh Token, ExpiresIn 에 대한 정보가 담겨있는 Token을 반환해줍니다.
✔️ API 로직
@Transactional
public TokenResponse login(LoginDto loginDto){
Member member = memberRepository.findByEmail(loginDto.getEmail())
.orElseThrow(() -> new EmailException("존재하지 않는 이메일입니다."));
// 패스워드 검증
member.validatePassword(loginDto.getPassword());
// jwt 토큰 발급
TokenResponse tokenResponse = jwtTokenProvider.generate(loginDto.getEmail(), member.getRole().toString());
return tokenResponse;
}
아이디와 패스워드 검증을 거치면, JWT 토큰을 발급해야 합니다.
사용자 정보를 바탕으로 Access Token과 Refresh Token을 생성하여 반환합니다.
✔️ JWT Provider : Access Token / Refresh Token 생성
/* 토큰 생성 */
public TokenResponse generate(String email, String authorities) {
// access/refresh 토큰 설정
long now = (new Date()).getTime();
Date accessTokenExpiredAt = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
Date refreshTokenExpiredAt = new Date(now + REFRESH_TOKEN_EXPIRE_TIME);
// 토큰 생성
String accessToken = createToken(email, authorities, accessTokenExpiredAt);
String refreshToken = createToken(email, authorities, refreshTokenExpiredAt);
// refresh 토큰 redis에 저장
refreshTokenRepository.save(RefreshToken.of(email, refreshToken, REFRESH_TOKEN_EXPIRE_TIME / 1000L));
return TokenResponse.of(accessToken, refreshToken, "Bearer", ACCESS_TOKEN_EXPIRE_TIME / 1000L);
}
/* 토큰 빌드 */
public String createToken(String email, String authorities, Date expiredAt) {
return Jwts.builder()
.setSubject(email)
.claim(AUTHORITIES_KEY, authorities)
.setExpiration(expiredAt)
.signWith(key, SignatureAlgorithm.HS512)
.compact();
}
토큰들을 생성할 때, Refresh Token은 생성한 뒤 그 값을 사용자의 아이디를 key로 Redis에 저장합니다.
- Refresh Token은 API 요청 시 Access Token이 만료되었을 때 Access Token을 재발급하기 위해 사용됩니다.
- Redis에 해당 아이디의 Refresh Token이 존재하고, 유효해야 발급됩니다. (레디스는 만료시간이 지나면 자동으로 삭제됩니다)
- Redis에 Refresh Token이 없다면 다시 로그인해서 모두 재발급할 수 있도록 요청해야 합니다.
2️⃣ Access Token 재발급
앞선 과정에서 로그인이 성공해 Token들이 모두 정상적으로 발급이 되었다면, Front에서는 인증이 필요한 모든 요청에 Access Token을 Header에 담으면 됩니다.
Request Header에 담는 Access Token 예시
Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJtbWRfbWVtYmVyQG5hdmVyLmNvbSIsImF1dGgiOiJVU0VSIiwiZXhwIjoxNzAyMzYxNDE4fQ.t-9F2C7tIUJ6Wlg-8nMc_xFkHLDyleWPdZk8c2O19xhh7YpMTC_GqoOQkoHQRcw0GTRbxwOsUOypWsdbDVMKBQ
토큰 재발급이 필요한 이유
JWT Filter에서 Access Token이 유효한지 검사하게 되는데, 기간이 만료가 되었거나 유효하지 않은 토큰일 경우, 해당 아이디에 유효한 Refresh Token이 있다면 Access Token을 재발급하면 됩니다.
하지만 이 때 Refresh Token도 만료되었다면 모든 토큰이 유효하지 않은 상황입니다. 따라서 재로그인해서 모든 토큰을 재발급하는 과정이 필요합니다.
✔️ API 컨트롤러
@GetMapping("/reissue")
public ResponseEntity<TokenResponse> reissue(@AuthenticationPrincipal MemberDetails memberDetails
) {
TokenResponse tokenResponse = authService.reissueAccessToken(memberDetails);
return ResponseEntity.ok()
.body(tokenResponse);
}
✔️ API 로직
public TokenResponse reissueAccessToken(MemberDetails memberDetails) {
return jwtTokenProvider.reissueAccessToken(memberDetails.getUsername(),
memberDetails.getAuthorities().toString());
}
public TokenResponse reissueAccessToken(String email, String authorities) {
// Refresh Token 유무 확인
RefreshToken refreshToken = refreshTokenRepository.findById(email)
.orElseThrow(() -> new ExpiredRefreshTokenException("만료된 토큰입니다."));
// Refresh Token 유효성 검증
validateToken(refreshToken.getRefreshToken());
Claims claims = parseClaims(refreshToken.getRefreshToken());
if(!claims.getSubject().equals(email)) {
throw new JwtException("로그인된 사용자의 refresh token이 아닙니다.");
}
// Access Token 재발급
long now = (new Date()).getTime();
Date accessTokenExpiredAt = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
String accessToken = createToken(email, authorities, accessTokenExpiredAt);
return TokenResponse.of(accessToken, refreshToken.getRefreshToken(), "Bearer", ACCESS_TOKEN_EXPIRE_TIME / 1000L);
}
- 프론트에서 위와 같이 Access Token을 담아 API를 요청합니다.
- 백엔드는 JWT Filter에서 Access Token의 검증이 실패하면 에러를 응답합니다.
- 에러응답을 받은 프론트에서 Refresh Token과 함께 재발급 API를 요청하면, 백엔드에서는 Refresh Token을 검증해 유효하다면 Access Token을 재발급하고, 없으면 에러를 응답합니다.
- 프론트는 응답에 따라 발급받은 Access Token으로 API를 재요청하거나, 로그인 주소로 리다이렉트합니다.
3️⃣ 로그아웃
✔️ API 컨트롤러
@PostMapping("/logout")
public ResponseEntity<Void> logout(
Principal principal,
@RequestHeader("refreshToken") String refreshToken
) {
authService.logout(principal.getName(), refreshToken);
return ResponseEntity.noContent().build();
}
✔️ API 로직
public void logout(String email, String refreshToken) {
RefreshToken token = refreshTokenRepository.findById(email)
.orElseThrow(() -> new JwtException("이미 로그아웃된 사용자입니다."));
if(!refreshToken.equals(token.getRefreshToken())) {
throw new JwtException("로그인된 사용자의 Refresh 토큰이 아닙니다.");
}
refreshTokenRepository.delete(token);
}
- 프론트에서 넘긴 Refresh Token값이 저장소에 있고, 로그인한 사용자의 Refresh Token인지 검증합니다.
- 검증에 통과하면 저장소에 있던 Refresh Token을 삭제함으로써, Access Token을 재발급하지 못하게 합니다.
이 경우 서버에서는 Access Token을 관리할 수 없기 때문에 로그아웃 처리가 불가능하지만, 프론트에서 API를 요청할 때 Request Header 값에 해당 값을 제외하면 됩니다.
물론 Access Token이 만료될 때까지 Request Header를 조작한다면 계속 로그인한 상태를 유지할 수 있다는 단점이 있습니다.
'프로젝트 Project' 카테고리의 다른 글
테스트코드 작성하기 (0) | 2024.08.06 |
---|---|
OAuth2 로그인 구현하기 (2) | 2024.08.06 |
추상 클래스로 예외 규격 설정하기 (1) | 2024.08.06 |
static 내부 클래스로 DTO 관리하기 (0) | 2024.08.06 |
Spring Security + JWT로 로그인 구현하기 (1) (0) | 2024.08.05 |