ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Refresh Token으로 자동로그인 구현하기
    카테고리 없음 2022. 8. 8. 00:20

    우테코 내에서 팀 프로젝트로 속닥속닥 이라는 익명 커뮤니티를 개발하고 있다. 현재 인증/인가를 JWT를 이용해 구현했는데, 토큰 만료 시간이 지났을 경우 로그인이 풀리는 문제가 있다. 토큰 만료로 갑작스레 로그인이 풀린다면 사용자는 불편을 겪을 것이다. 예를 들어, 속닥속닥 사용자가 게시판에 글을 열심히 쓰고, 작성 버튼을 눌러 글 작성을 완료하려 한다. 하지만, 작성하는 도중에 토큰이 만료되었고 글 작성 버튼을 누르는 순간 로그인 화면으로 돌아가 하던 작업을 잃게 된다. 이런 상황을 방지하기 위해 토큰이 만료된다면 자동으로 다시 발급해주어 로그인이 풀리지 않게 구현하려 한다. 이를 위해, Refresh Token을 이용하면 된다.

     

    자동로그인 동작 시나리오

    1. 로그인하면 access token과 refresh token을 생성해 둘 다 발급해준다. 이 때, refresh token은 db에 저장한다.

    2. 클라이언트에서 요청을 보낼때 마다 access token을 담아 서버에 요청한다.

    3.a

      1. 만료된 토큰을 담아 요청하면, 서버에서 401 응답을 보낸다.

      2. 401 응답을 받은 클라이언트는 access token과 refresh token을 같이 보낸다.

    3.b 클라이언트에서 access token의 payload를 통해 만료기간이 지났다는 것을 확인해 바로 refresh 요청을 보낸다. (이렇게 하면 네트워크 요청을 줄일 수 있다.)

    4. refresh 요청을 받은 서버는 만료된 access token을 통해 회원 정보를 뽑아내고, 그 정보와 매치되는 refresh token을 db에서 가져온다.

    5.  가져온 refresh token을 클라이언트로부터 받은 refresh token과 일치하는지 확인한다.

    6.a. 일치한다면, 새로운 access token을 발급해준다.

    6.b. 일치하지 않는다면, refresh token이 유효하지 않은 것이니 401 응답을 보내고 클라이언트는 재 로그인을 하게된다.

     

    refresh token이 만료되었다면 해당 refresh token을 db에서 제거하고 401 응답을 보내 재 로그인 하도록 한다.

     

    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.