[백업] Refresh Token 발급과 Access Token 재발급

박솔찬·2022년 6월 12일
16
post-custom-banner

+ 이 글을 작성하던 당시(2021년 9월 3일) 비교적 적은 스프링부트에 대한 지식을 바탕으로 작성된 게시글입니다.

전체적인 Refresh Token발급과 Access Token 재발급 플로우만 확인 후, 코드를 클린하게 수정하여 작성하시길 바랍니다.

jwt는 한 번 발급하면 만료되기 전까지 삭제할 수 없다. 따라서 짧은 유효시간을 갖는 Access Token과 저장소에 저장해서 Access Token을 재발급이 가능한 Refresh Token이 있다. Refresh Token 발급과 관리 및 이를 통한 Access Token 재발급에 대해 알아보자.

본 포스트는 이전 JWT 발급 과정에 이어서 진행된다.

추가로 Access Token 재발급은 크게 2가지 방법으로 볼 수 있다.

  1. 요청마다 Access Token과 Refresh Token을 같이 넘기는 방법.
  2. 재발급 API를 만들고 서버에서 Access Token이 만료되었다고 응답하면 Refresh Token으로 요청하여 재발급 받기.

여기서는 1번의 방법으로 진행된다.

Access Token과 Refresh Token

리프레시 토큰을 발급하기 전, 리프레시 토큰이 어떻게 사용되는지 알아보자.

각 토큰의 이름이 뜻하는 대로,
어세스 토큰은 접근에 관여하는 토큰이고
리프레시 토큰은 재발급에 관여하는 토큰이다.

JWT는 발급한 후, 삭제가 불가능하기 때문에 접근에 관여하는 토큰은 유효시간을 길게 부여할 수 없다. 하지만 자동 로그인 혹은 로그인 유지를 위해서는 유효시간이 긴 토큰이 필요하다. 이때 사용되는 것이 Refresh Token이다.

생명 주기

어세스 토큰과 리프레시 토큰의 생명 흐름을 알아보자.

우선, Access Token 먼저 보자. Access Token은 발급된 이후, 서버에 저장되지 않고 토큰 자체로 검증을 하며 사용자 권한을 인증한다.

이런 역할을 하는 Access Token이 탈취되면 토큰이 만료되기 전 까지, 토큰을 획득한 사람은 누구나 권한 접근이 가능해진다. 따라서 Access Token의 유효 주기는 짧게 가져가야 한다.

그러면, 자동 로그인 혹은 로그인 유지는?
이제 Refresh Token의 일이 시작된다. Refresh Token은 한 번 발급되면 Access Token보다 훨씬 길게 발급된다. 대신에 접근에 대한 권한을 주는 것이 아니라 Access Token 재발급에 관여한다.

Access Token의 재발급 방법

그럼 어떻게 재발급에 관여하는지 알아보자.

보통 Refresh Token은 로그인 성공시 발급되며 저장소에 저장하여 관리된다.
그리고 사용자가 로그아웃을 하면 저장소에서 Refresh Token을 삭제하여 사용이 불가능하도록 한다. 사용이 불가능한 이유는 아래 재발급 과정을 확인하면 알 수 있다.

Access Token이 만료되어, 재발급이 진행되면 다음의 과정을 통해 재발급이 된다.

  1. Refresh Token 유효성 체크
  2. 저장소에 Refresh Token 존재유무 체크
  3. 1, 2 모두 검증되면 재발급 진행
  4. Response header에 새로 발급한 Access Token 저장

이후 클라이언트는 재발급된 Access Token을 Request헤더에 포함하여 요청을 보내면 정상적으로 접근이 허용된다.

적용해보기

Refresh Token 도메인 생성

Refresh Token은 저장소에 저장되기 때문에 도메인을 생성해야한다.

매우 매우 간단하게 만들 것이다.

// RefreshToken.class
@Entity
@AllArgsConstructor
@NoArgsConstructor
public class RefreshToken {

    @Id
    @Column(nullable = false)
    private String refreshToken;
}

단순히 토큰 값만 저장하면 되기 때문에 ID로 따로 생성하지 않았다.

이번에는 H2 인메모리 디비를 사용한 방법이고 다음 포스팅에서 Redis를 통해 키-벨류로 저장할 예정이다.

이제 JpaRepository를 상속받는 TokenRepository 인터페이스를 생성한다.

public interface TokenRepository extends JpaRepository<RefreshToken, Long> {

    boolean existsByRefreshToken(String token);
}

토큰 저장 및 존재여부를 판단하기 위해 생성하였다.

참고로 데이터베이스는 현재 H2 인메모리 환경이다.

Controller Login 메서드 수정

기존 login 메서드는 Access Token 발급만 하였다.
이제는 Refresh Token발급과 저장소에 저장을 해야한다.

   // 로그인
    @PostMapping("/login")
    public ResponseEntity login(@RequestBody UserDTO user, HttpServletResponse response) {
        // 유저 존재 확인
        User member = userService.findUser(user);
        // 비밀번호 체크
        userService.checkPassword(member, user);
        // 어세스, 리프레시 토큰 발급 및 헤더 설정
        String accessToken = jwtTokenProvider.createAccessToken(member.getEmail(), member.getRoles());
        String refreshToken = jwtTokenProvider.createRefreshToken(member.getEmail(), member.getRoles());
        jwtTokenProvider.setHeaderAccessToken(response, accessToken);
        jwtTokenProvider.setHeaderRefreshToken(response, refreshToken);
        // 리프레시 토큰 저장소에 저장
        tokenRepository.save(new RefreshToken(refreshToken));

        return ResponseEntity.ok().body("로그인 성공!");
    }

응답 헤더에 토큰을 추가하는 작업을 Access Token 재발급 진행하면서 사용하기 때문에 jwtProvider의 메서드로 작성하였다.

JwtTokenProvider 수정

기존 token을 발급하던 메서드를 Access Token과 Refresh Token의 발급으로 나누어 수정하였다.
토큰 발급에 대해서는 같은 작업을 수행하기 때문에 하나의 메서드로 분리하여 작성하였다.

@Slf4j
@RequiredArgsConstructor
@Component
public class JwtTokenProvider {

    // 키
    private String secretKey = "lalala";

    // 어세스 토큰 유효시간 | 20s
    private long accessTokenValidTime = 20 * 1000L; // 30 * 60 * 1000L;
    // 리프레시 토큰 유효시간 | 1m
    private long refreshTokenValidTime = 1 * 60 * 1000L;

    private final CustomUserDetailService customUserDetailService;
    private final TokenRepository tokenRepository;
    private final UserRepository userRepository;

    // 의존성 주입 후, 초기화를 수행
    // 객체 초기화, secretKey를 Base64로 인코딩한다.
    @PostConstruct
    protected void init() {
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
    }

    // Access Token 생성.
    public String createAccessToken(String email, List<String> roles){
        return this.createToken(email, roles, accessTokenValidTime);
    }
    // Refresh Token 생성.
    public String createRefreshToken(String email, List<String> roles) {
        return this.createToken(email, roles, refreshTokenValidTime);
    }

    // Create token
    public String createToken(String email, List<String> roles, long tokenValid) {
        Claims claims = Jwts.claims().setSubject(email); // claims 생성 및 payload 설정
        claims.put("roles", roles); // 권한 설정, key/ value 쌍으로 저장

        Date date = new Date();
        return Jwts.builder()
                .setClaims(claims) // 발행 유저 정보 저장
                .setIssuedAt(date) // 발행 시간 저장
                .setExpiration(new Date(date.getTime() + tokenValid)) // 토큰 유효 시간 저장
                .signWith(SignatureAlgorithm.HS256, secretKey) // 해싱 알고리즘 및 키 설정
                .compact(); // 생성
    }

    // JWT 에서 인증 정보 조회
    public Authentication getAuthentication(String token) {
        UserDetails userDetails = customUserDetailService.loadUserByUsername(this.getUserEmail(token));
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

    // 토큰에서 회원 정보 추출
    public String getUserEmail(String token) {
        return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
    }

    // Request의 Header에서 AccessToken 값을 가져옵니다. "authorization" : "token'
    public String resolveAccessToken(HttpServletRequest request) {
        if(request.getHeader("authorization") != null )
            return request.getHeader("authorization").substring(7);
        return null;
    }
    // Request의 Header에서 RefreshToken 값을 가져옵니다. "authorization" : "token'
    public String resolveRefreshToken(HttpServletRequest request) {
        if(request.getHeader("refreshToken") != null )
            return request.getHeader("refreshToken").substring(7);
        return null;
    }

    // 토큰의 유효성 + 만료일자 확인
    public boolean validateToken(String jwtToken) {
        try {
            Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken);
            return !claims.getBody().getExpiration().before(new Date());
        } catch (ExpiredJwtException e) {
            log.info(e.getMessage());
            return false;
        }
    }

    // 어세스 토큰 헤더 설정
    public void setHeaderAccessToken(HttpServletResponse response, String accessToken) {
        response.setHeader("authorization", "bearer "+ accessToken);
    }

    // 리프레시 토큰 헤더 설정
    public void setHeaderRefreshToken(HttpServletResponse response, String refreshToken) {
        response.setHeader("refreshToken", "bearer "+ refreshToken);
    }

    // RefreshToken 존재유무 확인
    public boolean existsRefreshToken(String refreshToken) {
        return tokenRepository.existsByRefreshToken(refreshToken);
    }

    // Email로 권한 정보 가져오기
    public List<String> getRoles(String email) {
        return userRepository.findByEmail(email).get().getRoles();
    }
}

Refresh Token의 유효 시간 설정 변수이다.

 // 리프레시 토큰 유효시간 | 1m
    private long refreshTokenValidTime = 1 * 60 * 1000L;

각기 다른 유효시간으로 발급이 된다.

// Access Token 생성.
    public String createAccessToken(String email, List<String> roles){
        return this.createToken(email, roles, accessTokenValidTime);
    }
    // Refresh Token 생성.
    public String createRefreshToken(String email, List<String> roles) {
        return this.createToken(email, roles, refreshTokenValidTime);
    }

토큰을 가져오는 메서드 또한 각각 작성되었다.

 // Request의 Header에서 AccessToken 값을 가져옵니다. "authorization" : "token'
    public String resolveAccessToken(HttpServletRequest request) {
        if(request.getHeader("authorization") != null )
            return request.getHeader("authorization").substring(7);
        return null;
    }
    // Request의 Header에서 RefreshToken 값을 가져옵니다. "authorization" : "token'
    public String resolveRefreshToken(HttpServletRequest request) {
        if(request.getHeader("refreshToken") != null )
            return request.getHeader("refreshToken").substring(7);
        return null;
    }

Refresh Token 재발급 진행 후, 권한 부여를 위한 권한을 반환해주는 메서드이다.

// Email로 권한 정보 가져오기
    public List<String> getRoles(String email) {
        return userRepository.findByEmail(email).get().getRoles();
    }

JwtAuthenticationFilter 수정

JwtAuthenticationFilter는 상속 객체를 변경하였다.

GenericFilterBead에서 OncePerRequestFilter로 변경하였다.
기존 Filter는 jwt 검증 예외가 발생하는 경우 Filter가 여러번 동작하는 것을 확인하여 새로운 방안으로 적용하였다.

전체적인 뼈대는 같으나 오버라이딩 하는 메서드 명과 doFilter 방식이 다르다.

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // 헤더에서 JWT 를 받아옵니다.
        String accessToken = jwtTokenProvider.resolveAccessToken(request);
        String refreshToken = jwtTokenProvider.resolveRefreshToken(request);

        // 유효한 토큰인지 확인합니다.
        if (accessToken != null) {
            // 어세스 토큰이 유효한 상황
            if (jwtTokenProvider.validateToken(accessToken)) {
                this.setAuthentication(accessToken);
            }
            // 어세스 토큰이 만료된 상황 | 리프레시 토큰 또한 존재하는 상황
            else if (!jwtTokenProvider.validateToken(accessToken) && refreshToken != null) {
                // 재발급 후, 컨텍스트에 다시 넣기
                /// 리프레시 토큰 검증
                boolean validateRefreshToken = jwtTokenProvider.validateToken(refreshToken);
                /// 리프레시 토큰 저장소 존재유무 확인
                boolean isRefreshToken = jwtTokenProvider.existsRefreshToken(refreshToken);
                if (validateRefreshToken && isRefreshToken) {
                    /// 리프레시 토큰으로 이메일 정보 가져오기
                    String email = jwtTokenProvider.getUserEmail(refreshToken);
                    /// 이메일로 권한정보 받아오기
                    List<String> roles = jwtTokenProvider.getRoles(email);
                    /// 토큰 발급
                    String newAccessToken = jwtTokenProvider.createAccessToken(email, roles);
                    /// 헤더에 어세스 토큰 추가
                    jwtTokenProvider.setHeaderAccessToken(response, newAccessToken);
                    /// 컨텍스트에 넣기
                    this.setAuthentication(newAccessToken);
                }
            }
        }
        filterChain.doFilter(request, response);
    }

    // SecurityContext 에 Authentication 객체를 저장합니다.
    public void setAuthentication(String token) {
        // 토큰으로부터 유저 정보를 받아옵니다.
        Authentication authentication = jwtTokenProvider.getAuthentication(token);
        // SecurityContext 에 Authentication 객체를 저장합니다.
        SecurityContextHolder.getContext().setAuthentication(authentication);
    }
}

이제 join과 login을 진행하고, 헤더로 응답받은 Access Token과 Refresh Token을 요청 헤더에 추가하여 요청을 보내면 된다.

Access Token이 만료된 후, 요청을 보내면 응답 헤더에 새로운 Access Token이 담겨 올 것이다. 그러면 새로운 토큰을 헤더에 새로 저장후 요청을 진행하면 정상적으로 접근이 가능하다.


추후 Refresh Token으로 재발급 요청이 일정 수준 이상 반복된다면, 클라이언트에서 오는 요청이 아닐수 있기 때문에, 재발급을 금지하는 방법을 한 번 적용해봐야 겠다.


본 포스트는 공부하면서 알아가는 내용을 기록하면서 작성됩니다.
혹시 잘못된 내용이 있다면 언제든 피드백 감사하겠습니다!.

profile
Why? How? What?
post-custom-banner

6개의 댓글

comment-user-thumbnail
2022년 7월 20일

안녕하세요. 글 너무 잘 읽었습니다~ 혹시 궁금한 게 Access Token 재발급 방법을 1번으로 진행했을 때 매번 호출 시 AT, RT 모두 넘겨줄 경우 탈취를 시도했을 때 두 토큰 다 탈취가 되면 RT가 의미가 없어질 거 같은데 보안상 위험하지 않을까 싶어서 의견을 여쭈어보고 싶습니다!

1개의 답글
comment-user-thumbnail
2022년 8월 1일

안녕하세요 글 잘읽었습니다. 그런데 jwt토큰이 만료되면 io.jsonwebtoken.ExpiredJwtException: JWT expired at 2022-08-01T16:04:34Z. Current time: 2022-08-01T16:20:06Z, a difference of 932424 milliseconds. Allowed clock skew: 0 milliseconds. 이런식으로 에러가나서 else if이 돌지않는데 혹시 어디가 문제일까요?

답글 달기
comment-user-thumbnail
2022년 11월 20일

안녕하세요 :) 작성한 글 덕분에 도움이 많이 됐습니다. userDetails를 어떻게 Authentication으로 바꿀 지 고민 많이 했는데 참고해서 해결하였습니다 감사합니다 😄

답글 달기
comment-user-thumbnail
2022년 11월 30일

엑세스토큰과 갱신토큰 둘다 같은 클레임을 담아서 둘 다 요청헤더로 보내는건 너무 위험하지 않나요?

1개의 답글