모바일 어플리케이션 또는 일부 웹 앱을 사용하다 보면 보통 자동 로그인 체크 옵션이 있다.
또는 이런 옵션이 없어도 앱을 키면 이미 로그인 되어있는 경우가 많다.
카카오톡, 토스 등등
과연 어떻게 구현하는 것일까?
세션 방식과 토큰 방식에서 물론 차이가 있지만
내가 선호하는 방법인 토큰 방식으로 구현을 해보았다.
이 포스팅은 Spring Security, JWT, Redis 관련 세팅이
이미 되어있다고 가정하고 코드를 작성했습니다.
📌 자동 로그인 인증 과정
1) 유저가 성공적으로 로그인 했을 경우 리프레시 토큰과 엑세스 토큰을 응답받는다.
2) 유저는 이를 보관하고 사용하다가, 엑세스 토큰이 만료되는 시점이 올 것이다.
3) 유저의 서버 API 요청시 401 응답을 받으면 reissue 요청을 시도해야 한다
4) 로그인 시 응답받았던 리프레시 토큰과 만료된 엑세스 토큰을 통해 재발급 요청을 서버에 보낸다.
5) 유효한 토큰일 경우 새로운 엑세스 토큰과 새로운 리프레시 토큰을 응답받는다.
🔨 구현
📝 JwtTokenProvider
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtTokenProvider {
private static final long JWT_EXPIRATION_MS = 1000L * 60 * 40; //40분
private static final long REFRESH_TOKEN_EXPIRATION_MS = 1000L * 60 * 60 * 24 * 7; //7일
... 생략
public String generateRefreshToken(JwtDTO jwtDTO) {
final String encodedKey = Base64.getEncoder().encodeToString(JWT_SECRET.getBytes());
final Date now = new Date();
final Date refreshTokenExpiresIn = new Date(now.getTime() + REFRESH_TOKEN_EXPIRATION_MS);
final String refreshToken = Jwts.builder()
.setHeaderParam(Header.TYPE, Header.JWT_TYPE)
.setIssuer("naechinso")
.setExpiration(refreshTokenExpiresIn)
.signWith(SignatureAlgorithm.HS512, encodedKey)
.compact();
//redis에 해당 phone number 의 리프레시 토큰 등록
redisService.setValues(
jwtDTO.getPhoneNumber(),
refreshToken,
Duration.ofMillis(REFRESH_TOKEN_EXPIRATION_MS)
);
return refreshToken;
}
}
우선 TokenProvider에서 레지스터 토큰을 만드는 로직이다.
엑세스 토큰의(JWT) 만료 시간은 40분,
리프레시 토큰의 만료 시간은 7일로 잡아놨다.
만약 유저가 서비스를 사용 중인데,
그 순간 엑세스 토큰이 만료되면
더 이상 서비스를 이용하지 못하고 강제 로그아웃 될 것이다.
그렇기 때문에 더욱 토큰 자동 재발급 + 자동 로그인 기능이 필요했다
리프레시 토큰은 이럴 때 필요하다.
리프레시 토큰은 브라우저의 쿠키나 LocalStorage 에 저장하기도 하고
유저 DB에 저장하기도 하고 여러 가지 방법이 있다.
나는 유저가 로그인하여 토큰을 발급 받을 때
리프레시 토큰도 같이 Redis 에 저장하는 방법을 사용했다.
이후 유저의 ID를 통해 해당 리프레시 토큰에 접근할 수 있다.
📝MemberController
@Slf4j
@RestController
@RequestMapping("/member")
@RequiredArgsConstructor
public class MemberController {
private final MemberService memberService;
private final JwtTokenProvider jwtTokenService;
@PostMapping("/reissue")
@ApiOperation(value = "리프레시 토큰을 통해 새로운 토큰을 발급받는다 (RefreshToken)")
public CommonApiResponse<MemberReissueResponseDTO> reissue(
@RequestHeader("Authorization") String accessToken,
@RequestHeader("Refresh") String refreshToken
) {
return CommonApiResponse.of(memberService.reissue(accessToken, refreshToken));
}
... 생략
}
컨트롤러에는 reissue 라는 API를 생성했다.
유저가 현재 유효한 로그인 상태를 가지고 있을 경우
accessToken 과 refreshToken 을 모두 가지고 있을 것이다.
임의의 "Refresh" 라는 헤더를 통해 리프레시 토큰 값을 가져오도록 프론트와 말했다.
또 "Authorization" 헤더에는 원래의 엑세스 토큰 값이 들어있을 것이다.
📝 MemberService
@Service
@RequiredArgsConstructor
@Transactional
public class MemberService {
private final JwtTokenProvider jwtTokenProvider;
private final MemberRepository memberRepository;
(...생략)
public MemberReissueResponseDTO reissue(String accessToken, String refreshToken) {
String phone;
if (!jwtTokenProvider.validateTokenExceptExpiration(accessToken)){
throw new BadRequestException(ErrorCode.INVALID_ACCESS_TOKEN);
}
try {
phone = jwtTokenProvider.parseClaims(accessToken).getSubject();
} catch (Exception e) {
throw new BadRequestException(ErrorCode.INVALID_REFRESH_TOKEN);
}
Member authMember = findByPhone(phone);
(...생략)
jwtTokenProvider.validateRefreshToken(phone, refreshToken);
TokenResponseDTO tokenResponseDTO = jwtTokenProvider.generateToken(new JwtDTO(phone, authMember.getRole().toString()));
return MemberReissueResponseDTO.builder()
.accessToken(tokenResponseDTO.getAccessToken())
.refreshToken(tokenResponseDTO.getRefreshToken())
(...생략)
.build();
}
}
비즈니스 로직 처리 부분이다.
처음에 jwtTokenProvider 에서 다시 토큰 검증이 발생하는데
토큰의 유효성 중 만료 시간만 검증하는 validateTokenExceptExpiration 메소드를 만들었다.
//jwtTokenProvider
...
/**
* 토큰 예외 중 만료 상황만 검증 함수
* @param token 검사하려는 JWT 토큰
* @returns boolean
* */
public boolean validateTokenExceptExpiration(String token) {
final String encodedKey = Base64.getEncoder().encodeToString(JWT_SECRET.getBytes());
try {
Jwts.parser().setSigningKey(encodedKey).parseClaimsJws(token);
return true;
} catch(ExpiredJwtException e) {
return true;
} catch (Exception e) {
return false;
}
}
...
이 부분인데,
토큰이 정상이거나 토큰 만료 시 던져주는 ExpiredJwtException 만 catch 해서 true 로 처리해준다.
이렇게 하면 비 정상적인 토큰을 걸러낼 수 있다.
이후 parseClaims 를 호출하면 엑세스 토큰으로 부터
유저의 ID (이 프로젝트에서는 phone) 를 뽑아낼 수 있다.
//JwtTokenProvider
...
/** Redis Memory 의 RefreshToken 과
* User 의 RefreshToken 이 일치하는지 확인
* @param phone 검증하려는 유저 휴대전화
* @param refreshToken 검증하려는 리프레시 토큰
*/
public void validateRefreshToken(String phone, String refreshToken) {
String redisRefreshToken = redisService.getValues(phone);
if (!refreshToken.equals(redisRefreshToken)) {
throw new BadRequestException(ErrorCode.EXPIRED_REFRESH_TOKEN);
}
}
...
이번에는 리프레시 토큰 검증이다.
리프레시 토큰을 생성할 때 <Key, Value> 값으로
<Phone, RefreshToken> 쌍을 Redis 에 삽입했다.
추출할 때는 phone 을 참조하여 RefreshToken 값을 가져올 수 있다.
유저가 요청한 리프레시 토큰과 Redis 의 값을 비교해서 일치하면 검증 성공
검증이 완료되었을 경우 토큰을 재생성해서 응답한다.
📝 MemberReissueResponseDTO
/**
* 토큰 재발급을 담당하는 응답 DTO
* */
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
@ToString
@JsonInclude(JsonInclude.Include.NON_NULL) //NULL 필드 가림
public class MemberReissueResponseDTO {
private String accessToken;
private String refreshToken;
(생략)
}
응답 DTO는 별 거 없이 accessToken 과 refreshToken 필드만 가진다.
@RequestHeader 사용했기 때문에 스웨거에서 테스트도 가능하다.
🙄 로그아웃은?
자동 로그인까지 만든 건 좋다.
그렇다면 로그아웃은 어떻게 해야 할까?
유저가 분명 로그아웃 요청을 했는데
앱을 다시 키니 로그인이 되어있는 불상사가 있으면 안된다.
//MemberService
(...생략)
/**
* 로그아웃 -> Register Token 삭제
* */
public MemberLoginResponseDTO logout(Member authMember) {
Member member = findByMember(authMember);
//redis 에서 registerToken 삭제
jwtTokenProvider.deleteRegisterToken(member.getPhone());
return new MemberLoginResponseDTO("");
}
로그아웃은 딱히 별 거 없다.
자동 로그인 시 유저의 리프레시 토큰을
Redis 에 저장되어 있는 리프레시 토큰과 비교한다고 했다.
그러면 Redis 에 저장된 리프레시 토큰을 없애주면 될 것이다.
이러면 토큰 재발급 요청을 보내더라도 성공하지 못할 것이다.
//JwtTokenProvider
(...생략)
/** Redis 에서 RegistrerToken 을 제거
* @param phone 로그아웃 요청 유저
* @return true if redis 서버에 토큰이 있었을 경우
* false if 토큰이 없었을 경우
*/
public boolean deleteRegisterToken(String phone) {
try {
if (redisService.hasKey(phone)) {
redisService.deleteValues(phone);
return true;
}
} catch (Exception e) {
log.error("Redis 로그아웃 요청을 실패했습니다");
}
return false;
}
Redis 에서 유저 키 값을 참고해 삭제한다.