블로그 이미지

ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Refresh Token 으로 자동 로그인 구현하기 (2)
    Diary/우아한테크코스 2022. 8. 8. 00:17

    Refresh Token 으로 자동 로그인 구현하기 

     

    Refresh Token 으로 자동 로그인 구현하기

    우테코 내에서 팀 프로젝트로 속닥속닥 이라는 익명 커뮤니티를 개발하고 있다. 현재 인증/인가를 JWT를 이용해 구현했는데, 토큰 만료 시간이 지났을 경우 로그인이 풀리는 문제가 있다. 토큰

    easthshin.tistory.com


    Refresh token 저장소에는 어떤 정보를 넣을까?

    refresh token에는 access token과 달리 그 자체로는 회원의 정보를 담고있지 않고, 만료 기한만 갖고 있도록 하였다. 이는 만료 기한이 긴 refresh token이 탈취 되더라도 그 자체로는 어떠한 정보도 얻을수 없기 위함이다. 토큰 자체로는 만료 기한 외의 정보를 가지고 있지 않기 때문에 해당 멤버의 토큰을 식별하기 위해 refresh token 테이블에 token만 저장하는 것이 아니라 회원 id column을 추가하였다.

    @Entity
    @Getter
    public class RefreshToken {
    
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        @Column(name = "refresh_token_id")
        private Long id;
    
        @Column(name = "member_id")
        private Long memberId;
    
        @Column(name = "token")
        private String token;
    
        protected RefreshToken() {
        }
    
        public RefreshToken(Long memberId, String token) {
            this.memberId = memberId;
            this.token = token;
        }
    }

    login API의 변경점

    이제 로그인 요청을 받을 때, access token의 발급과 함께 refresh token도 발급해서 응답해주어야 한다.

    @RestController
    public class AuthController {
        private final AuthService authService;
        private final TokenManager tokenManager;
        private final RefreshTokenService refreshTokenService;
        
    //    ...
        
        @PostMapping("/login")
        public ResponseEntity<Void> login(@Valid @RequestBody LoginRequest loginRequest) {
            AuthInfo authInfo = authService.login(loginRequest);
            
            String accessToken = tokenManager.createAccessToken(authInfo);
            String refreshToken = tokenManager.createRefreshToken();
            refreshTokenService.saveToken(refreshToken, authInfo.getId());
    
            return ResponseEntity.ok()
                    .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
                    .header("Refresh-Token", "Bearer " + refreshToken)
                    .build();
        }
    
    }

    위에서 서술한 대로 refresh token에는 회원의 정보를 담고있지 않기 때문에 access token을 생성할때는 authInfo를 넘겨주고, refresh token을 생성할 때는 아무것도 넘기지 않고 있는 것을 볼 수 있다.

    @Component
    public class JwtTokenProvider implements TokenManager {
    
    //	...
        @Override
        public String createAccessToken(AuthInfo authInfo) {
            Date now = new Date();
            Date validity = new Date(now.getTime() + validityInMilliseconds);
    
            return Jwts.builder()
                    .claim("id", authInfo.getId())
                    .claim("role", authInfo.getRole())
                    .claim("nickname", authInfo.getNickname())
                    .setIssuedAt(now)
                    .setExpiration(validity)
                    .signWith(signingKey)
                    .compact();
        }
    
        @Override
        public String createRefreshToken() {
            Date now = new Date();
            Date validity = new Date(now.getTime() + refreshTokenValidityMilliseconds);
    
            return Jwts.builder()
                    .setIssuedAt(now)
                    .setExpiration(validity)
                    .signWith(signingKey)
                    .compact();
        }
    }

    로그인을 할 때 refresh token을 생성해 db에 저장하게 되는데, 이미 존재하는 refresh token이 있다면 해당 토큰은 삭제 시켜주어야 무분별한 refresh token 생성을 막아 보안성을 높일 수 있다고 생각이 들었다. 따라서, 로그인할 때 마다 해당 멤버가 가진 refresh token을 모두 지워준 후 새로 생성한 refresh token을 저장한다.

     

    Refresh 요청

    Access token이 만료되었을 때는 refresh 요청을 보내는데, 두 가지 구현 방법이 있다.

    1. 인가가 필요한 요청에 대해 access token이 만료되었을 경우 401 응답을 보내고, 응답을 받은 클라이언트는 refresh 요청을 보내 새로운 access token을 발급 받은 뒤 재요청을 보낸다.
    2. 클라이언트가 요청을 보내기 전, access token의 payload를 통해 만료 기한을 얻고 만료 기한이 지난 토큰이라면 refresh 요청을 보낸 후에 새로운 access token을 발급받아 원래 하려던 요청을 한다.

    속닥속닥은 두 가지 방법중, 후자의 방법이 요청을 덜 보내기 때문에 성능상 우위가 있다고 생각해 후자의 방법으로 구현하였다.

     

    참고로, JWT는 header, payload, signature로 섹션이 나뉘어져 있는데, signature 는 서버가 보유한 secret key를 통해서만 복호화 할 수 있지만 나머지 둘은 공개된 정보이기 때문에 클라이언트 측에서도 자체적으로 payload 를 복호화해 만료 기한 정보를 얻을 수 있는 것이다.

    refresh api에 대한 구현 코드는 다음과 같다.

    @RestController
    public class AuthController {
    
    //	...
    
        @GetMapping("/refresh")
        public ResponseEntity<Void> refresh(HttpServletRequest request, @Login AuthInfo authInfo) {
            validateExistHeader(request);
            Long memberId = authInfo.getId();
            String refreshToken = AuthorizationExtractor.extractRefreshToken(request);
    
            refreshTokenService.matches(refreshToken, memberId);
    
            String accessToken = tokenManager.createAccessToken(authInfo);
    
            return ResponseEntity.noContent()
                    .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
                    .build();
        }
    
        private void validateExistHeader(HttpServletRequest request) {
            String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
            String refreshTokenHeader = request.getHeader("Refresh-Token");
            if (Objects.isNull(authorizationHeader) || Objects.isNull(refreshTokenHeader)) {
                throw new TokenNotFoundException();
            }
        }

    access token과 refresh token을 담는 헤더가 존재하는지 확인하고 헤더에 담긴 refresh token을 뽑아온 뒤, refresh token 테이블에 담긴 값과 요청받은 값을 비교한다. 비교한 값이 일치하고, refresh token이 만료되지 않았다면 새로운 access token을 발급하여 응답해준다.

    @Service
    public class RefreshTokenService {
    
    //	...
    
        @Transactional
        public void matches(String refreshToken, Long memberId) {
            RefreshToken savedToken = refreshTokenRepository.findByMemberId(memberId)
                    .orElseThrow(InvalidRefreshTokenException::new);
    
            if (!tokenManager.isValid(savedToken.getToken())) {
                refreshTokenRepository.delete(savedToken);
                throw new InvalidRefreshTokenException();
            }
            savedToken.validateSameToken(refreshToken);
        }
    }
    @Entity
    @Getter
    public class RefreshToken {
    
    //	...
    
        public void validateSameToken(String token) {
            if (!this.token.equals(token)) {
                throw new InvalidRefreshTokenException();
            }
        }
    }

    이때, db에 저장된 refresh token이 만료되었다면 해당 데이터를 삭제하고, 401 응답을 던져 클라이언트측에서 재 로그인 하도록 한다.

    댓글

Designed by Tistory.