본문 바로가기
프로젝트/Kimstagram

[내멋대로 만드는 Kimstagram] 10. Jwt Refresh Token 발급하기

by kim-dev 2024. 1. 28.
반응형

사실 지금까지 애써 외면해 왔었는데, 아무래도 이제 슬슬 리프레시 토큰을 활용해야 할 것 같다.
테스트하는데 계속 10분이 지나면 팅겨버려서... 이게 사소한데 너무 귀찮아서 그냥 리프레시 토큰 발급에 도전하게 됐다.

 

리프레시 토큰의 로직은 대충 아래와 같이 구상했다.

  1. 로그인 시 액세스 토큰(기존의 JWT토큰)과 함께 리프레시 토큰을 생성한다.
  2. 액세스 토큰과 리프레시 토큰은 헤더에 담아서 클라이언트에 전송하고,
    리프레시 토큰은 DB에 따로 저장해 둔다.
  3. 클라이언트의 매 요청 시 마다 액세스 토큰과 리프레시 토큰을 보내며, 액세스 토큰으로 인증과 권한을 검사한다.
    그런데 만약 액세스 토큰의 만료 시간이 다 지났다면?
  4. DB에 해당 유저의 리프레시 토큰이 존재하는지 확인한다.
    만약 리프레시 토큰의 만료 기간이 남아있다면, 액세스 토큰을 새로 발급하여 클라이언트에게 넘겨준다.
    리프레시 토큰의 만료 기간 마저 만료되었다면 사용자는 로그인 창으로 튕기게 한다. (어쩔 수 없음...)

그러면 이제... 구현을 시작해 보자...
그런데 로직만 보면 생각보다 쉬울 듯? 야매로 짜는 거긴 하지만...


1. 리프레시 토큰의 생성

앞서 만든 JwtAuthenticationFilter.java에서 액세스 토큰을 만들고 발급했었다.
이 부분을 아래와 같이 조금 수정한다.

@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
    PrincipalDetail principalDetail = (PrincipalDetail) authResult.getPrincipal();

    // Hash 암호 방식으로 JWT토큰 생성 (RSA방식은 아님)
    String jwtToken = JWT.create()
            .withSubject("JWT_TOKEN")
            .withExpiresAt(new Date(System.currentTimeMillis() + (60000*10))) // 토큰의 만료 시간 (10분)
            .withClaim("id", principalDetail.getAccount().getId()) // 비공개 클레임인데 그냥 값 암거나 넣으면 될 듯?
            .withClaim("username", principalDetail.getAccount().getUsername())
            .sign(Algorithm.HMAC512("kimdevAuth")); // 토큰에 붙일 고유한 Secret 값

    // 리프레시 토큰 생성
    String refreshToken = JWT.create()
            .withSubject("JWT_TOKEN")
            .withExpiresAt(new Date(System.currentTimeMillis() + (60000*60 * 24))) // 24시간 동안 유효
            .withClaim("refresh", "refresh")
            .sign(Algorithm.HMAC512("kimdevRefresh"));


    // 만든 토큰들을 헤더에 붙여서 응답
    response.addHeader("Authorization", "Bearer "+jwtToken);
    response.addHeader("Refresh-Token", "Bearer "+refreshToken);
}

로그인(인증)이 완료되면, 기존의 액세스 토큰 뿐만 아니라 리프레시 토큰도 함께 생성하여 클라이언트에게 보내준다.

 

2. 리프레시 토큰을 DB에 저장

JPA를 사용하기 위해서 아래와 같이 DB 모델을 만들어 주자 (당연히 Repository도!)

package com.kimdev.kimstagram.model;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.OnDelete;
import org.hibernate.annotations.OnDeleteAction;

import javax.persistence.*;
import java.sql.Timestamp;

@Builder
@NoArgsConstructor
@AllArgsConstructor
@Data
@Entity
public class Refreshtoken {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;

    // 좋아요를 누른 사람
    @ManyToOne
    @JoinColumn(name="accountid")
    @OnDelete(action = OnDeleteAction.CASCADE)
    public Account account;
    
    public String refreshToken;

    @CreationTimestamp
    private Timestamp createDate;
}

 

그리고 DB에 리프레시 토큰을 저장할 수 있는 메서드를 SecurityService와 같은 서비스 클래스에 만들어 준다.

@Transactional
public void saveRefreshToken (String refreshToken, Account principal) {
    Refreshtoken refreshtoken = new Refreshtoken();
    refreshtoken.setRefreshToken(refreshToken);
    refreshtoken.setAccount(principal);

    refreshtokenRepository.save(refreshtoken);
}

 

그리고 아까 JwtAuthenticationFilter에서 리프레시 토큰을 만들면, 해당 메서드를 호출해서 DB에 저장해주면 된다.

securityService.saveRefreshToken(refreshToken, principalDetail.getAccount());

 

3. 클라이언트 측에서 토큰 다루기

자 이제 서버에서 토큰을 만들고 DB에 저장한 후 클라이언트에게 보내는 로직은 모두 완성했다.
그렇다면 이제 클라이언트가 토큰을 받아서 저장하고, 요청 시 마다 리프레시 토큰도 함께 보내도록 설정해 줘야겠지?

 

일단 로그인 요청 시, 그 응답으로 토큰들을 받아서 LocalStorage에 저장하도록 아래와 같이 로직을 수정해 주었다.

$.ajax({
    type: "POST",
    url: "/auth/loginProc",
    data: JSON.stringify(data),
    contentType: "application/json; charset=utf-8"
}).done(function(resp, status, xhr) {
    accesstoken = xhr.getResponseHeader('Authorization')
    refreshtoken = xhr.getResponseHeader('Refresh-Token')

    if (accesstoken != null && refreshtoken != null) { // 로그인에 성공한 경우
        localStorage.setItem('Authorization', accesstoken)
        localStorage.setItem('Refresh-Token', refreshtoken)
        location.href = "/index";
    } else { // 로그인에 실패한 경우
   		...

 

그리고 그동안 액세스 토큰을 헤더에 담아서 보내던 모든 GET요청에, 리프레시 토큰도 함께 보내주도록 수정했다.

check: function() {
    $.ajax({
        type: "GET",
        url: "/checkToken",
        headers: {'Authorization':localStorage.getItem('Authorization'),
                  'Refresh-Token':localStorage.getItem('Refresh-Token')},
        contentType: "application/json; charset=utf-8",
        dataType: "html"
    }).done(function(resp) {
    	...

대략 이런 식으로... ?
물론 좀 있다가 아래 부분을 좀 더 수정해야 될 것 같다...

 

그러면 이제 서버에서 만든 액세스 토큰과 리프레시 토큰을 받아서 LocalStorage에 저장한 후,
매 GET 요청 시마다 이 두 토큰을 헤더에 담아서 전송하면 된다.

 

4. GET 요청으로 들어오는 토큰들을 서버에서 다루기

이제 서버에서는 매 요청을 받을 때, 토큰을 두 개 받는다.
액세스 토큰이 유효하다면, 기존의 필터를 타게 한다.
그러나 액세스 토큰이 만료되었다면, 이제 리프레시 토큰의 유효성을 판단해야 한다.
리프레시 토큰이 유효하다면, 그 리프레시 토큰을 가지고 DB에 접근하여 유저 정보를 탐색하여 다시 액세스 토큰을 만들어서 클라이언트에게 돌려준다.
그러나 리프레시 토큰도 만료되었다면, 그 땐 TokenExpiredException을 발생시키면 된다.

 

Http 요청 마다 검사하는 필터는 OncePerRequestFilter이라고 한다 (아닐 수도 ㅋㅋ)

여튼 이 필터의 doFilterInternal을 오버라이딩 해서 구현하면, 토큰이 만료되었는지 검사할 수 있다!

package com.kimdev.kimstagram.Security.filter;

import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.TokenExpiredException;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.kimdev.kimstagram.Repository.RefreshtokenRepository;
import com.kimdev.kimstagram.model.Account;
import com.kimdev.kimstagram.model.Refreshtoken;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Date;

public class JwtRefreshFilter extends OncePerRequestFilter {

    private RefreshtokenRepository refreshtokenRepository;

    public JwtRefreshFilter(RefreshtokenRepository refreshtokenRepository) {
        this.refreshtokenRepository = refreshtokenRepository;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        try { // Authorization에 담긴 액세스 토큰이 유효할 경우
            filterChain.doFilter(request, response);
        } catch (TokenExpiredException e) { // Authorization에 담긴 액세스 토큰이 만료됐을 경우
            String refreshToken = request.getHeader("Refresh-Token").replace("Bearer ", "");

            Refreshtoken isValidInDB = refreshtokenRepository.findByRefreshToken(refreshToken);
            if (isValidInDB != null) { // 정상적으로 로그인 한 경우에만
                DecodedJWT decodedRefreshToken = JWT.require(Algorithm.HMAC512("kimdevRefresh"))
                        .build()
                        .verify(refreshToken);

                Date expirationTime = decodedRefreshToken.getExpiresAt();
                Date now = new Date();
                if (expirationTime != null && now.before(expirationTime)) { // 리프레시 토큰이 유효할 경우
                    String reissuedToken = reissueToken(request.getHeader("Authorization").replace("Bearer ", ""), isValidInDB); // 토큰 재 생성
                    response.addHeader("ReissuedToken", "Bearer "+reissuedToken); // 재생성한 토큰을 다시 클라이언트에게 전송.
                } else { // 리프레시 토큰이 만료되었을 경우
                    throw e; // 오류 발생시키기 (로그인창으로)
                }
            } else { // 정상적으로 로그인하지 않은 경우
                throw e; // 오류 발생시키기 (로그인창으로)
            }
        }
    }

    private String reissueToken(String accessToken, Refreshtoken refreshtoken) {
        // 액세스 토큰 재생성
        String reissuedToken = JWT.create()
                .withSubject("JWT_TOKEN")
                .withExpiresAt(new Date(System.currentTimeMillis() + (60000*10))) // 토큰의 만료 시간 (10분)
                .withClaim("id", refreshtoken.getAccount().getId()) // 비공개 클레임인데 그냥 값 암거나 넣으면 될 듯?
                .withClaim("username", refreshtoken.getAccount().getUsername())
                .sign(Algorithm.HMAC512("kimdevAuth")); // 토큰에 붙일 고유한 Secret 값

        return reissuedToken;
    }
}

굉장히 간단하게 짜긴 했는데... 뭐 로직은 대충 주석만 읽어봐도 파악할 수 있을 듯?!

액세스 토큰이 만료되었다면, 리프레시 토큰을 검사해서 유효하다면 DB에서 유저 정보를 가져와서 새로운 액세스 토큰을 만들어 ReissuedToken이라는 이름으로 헤더에 담아 응답한다.

그럼 클라이언트 측에서도 새로 발급한 토큰을 받아서 자신의 LocalStorage의 Authorization으로 저장해야겠지??
그래서 아까 작성한 checkToken.js를 조금 더 수정했다...

check: function() {
    $.ajax({
        type: "GET",
        url: "/checkToken",
        headers: {'Authorization':localStorage.getItem('Authorization'),
            'Refresh-Token':localStorage.getItem('Refresh-Token')},
        contentType: "application/json; charset=utf-8",
        dataType: "html"
    }).done(function(resp, status, xhr) {
        reissuedtoken = xhr.getResponseHeader('ReissuedToken')
        if (reissuedtoken != null) {
            localStorage.removeItem('Authorization');
            localStorage.setItem('Authorization', reissuedtoken);
        }
    }).fail(function (resp) { // 토큰이 만료된 경우
        alert("세션이 만료되어 로그인 페이지로 이동합니다.");
        localStorage.removeItem('Authorization');
        localStorage.removeItem('Refresh-Token');
        location.href = "/";
    });
}

토큰을 검사한 후 ReissuedToken을 항상 검사해서, 토큰이 재발급 되었다면 Authorization에 담긴 액세스 토큰을 갱신한다.

토큰이 전부 만료되었다면 로그인 페이지("/")로 돌아간다.

 

대략 이런 식으로 작성하면, 리프레시 토큰의 발행 및 적용이 완료된다!


생각보다 굉장히 간단하게 구현해서... 이게 맞나?? 싶다...
사실 나도 누구한테 배운 게 아니라 열심히 구글링해서 개념을 공부한 거라..ㅋㅋㅋ 리프레시 토큰에 대한 개념이 틀렸을 수도...?!

 

그런데 뭐 의도한 대로 작동만 하면 되지~

덕분에 액세스 토큰 유효 기간도 짧게 설정할 수 있게 됐고 리프레시 토큰의 만료 기간 내에서는 액세스 토큰이 계속해서 재발급되도록 제대로 동작하니까 ㅋㅋ 결과적으로는 성공적이었다!

 

 

 

[JWT] Access Token의 한계와 Refresh Token의 필요성

[수정사항] 2023-08-20 : 자바 코드의 TokenProvider 클래스에서 리프레시 토큰이 일치하는지 검사하는 메소드가 누락된 부분 수정 목차 들어가기 전에 이전에 스프링 시큐리티와 JWT를 이용한 사용자

colabear754.tistory.com