Spring 프로젝트 시작하기 - 보안 ) Access Token과 Refresh Token

지난시간에 Jwt Token을 쿠키로 발급해주었다.

 

해당 로직으로 토큰을 발급해주고나면 유효시간 이후에는 만료되어 사용할 수 없다.

그런데 이 토큰의 만료시간이 짧으면, 사용자가 로그인을 너무 자주 해야하는 문제가 있고,

기간이 길면 Token을 갈취해서 악용될 여지가 있다.

 

이런 단점을 보완하기 위해서 Refresh Token이라는 개념이 사용된다.

 

Access Token을 짧게, Refresh Token을 길게 기간을 지정하고,

Access Token이 만료되었을때 Refresh Token이 유효하면 Token을 다시 발급한다.


서비스에 따라서 노란부분(Refresh 유효, Access 만료)에서 로직을 선택할 수 있다.

A. Access 토큰만 재발급한다 : 최초 로그인부터 Refresh 토큰의 유효기간이 되면 무조건 다시 로그인해야한다.

B. Access 토큰과 Refresh 토큰을 같이 재발급한다 : 인증을 할때마다 Refresh 토큰이 갱신되어 API를 무한정으로 호출하면 토큰이 만료되지 않는다. 마지막 Refresh 토큰이 발급일부터 유효기간까지 사용 가능하다.

 

나는 로그인을 최소화했으면 해서 B방법을 선택했다.

Refresh 토큰을 발급하는 로직은 이렇다.

1. 사용자가 페이지를 사용한다.

2. 클라이언트(프론트)에서 API를 호출한다 (with Access 토큰)

3. API에서는 Access 토큰을 확인한다.

  3-1. Access 토큰이 정상이면 호출한 API에 맞는 동작을 리턴한다. [종료]

  3-2. Access 토큰이 이상하면 논의된 Error를 리턴한다. (401)

4. 클라이언트(프론트)에서 토큰 Refresh 요청을 보낸다 (with Refresh 토큰)

5. API에서 Refresh 토큰을 확인한다.

  5-1. Refresh 토큰이 이상하면 논의된 Rrror를 리턴한다. -> Front에서 로그인화면으로 이동시킨다. [종료]

  5-2. Refresh 토큰이 정상이면 Access / Refresh토큰을 발급한다.

6. Front에서 발급된 Access 토큰으로 API를 다시 요청한다. -> 3번으로 이동

 

이제 이걸 코드로 확인해보자.

 

JwtProvider

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtProvider {
    private final MemberRepository memberRepository;
    private JwtParser jwtParser;
    private JwtParser jwtRefreshParser;

    /**
     * jwt token info - name
     */
    @Value("${jwt.access.name}")
    private String TOKEN_COOKIENAME;
    @Value("${jwt.refresh.name}")
    private String TOKEN_REFRESH_COOKIENAME;

    /**
     * jwt token info - secret key
     */
    @Value("${jwt.access.secret-key}")
    private String accessTokenSecret;
    @Value("${jwt.refresh.secret-key}")
    private String refreshTokenSecret;

    /**
     * jwt token info - expire info
     */
    @Value("${jwt.access.expiration-time}")
    private long accessExpirationTime;
    @Value("${jwt.refresh.expiration-time}")
    private long refreshExpirationTime;

    /**
     * JWT Signature 유효성 검증을 위한 Parser 설정
     * 시크릿 키 : config
     */
    @PostConstruct
    public void setJwtParser() {
        jwtParser = Jwts.parser().setSigningKey(accessTokenSecret.getBytes(StandardCharsets.UTF_8));
    }

    /**
     * JWT Refresh Signature 유효성 검증을 위한 Parser 설정
     * 시크릿 키 : config
     */
    @PostConstruct
    public void setJwtRefreshParser() {
        jwtRefreshParser = Jwts.parser()
                .setSigningKey(refreshTokenSecret.getBytes(StandardCharsets.UTF_8));
    }


    /**
     * jwt access token 을 secret key 로 복호화를 진행하고 유저에 대한
     * 유효성을 검증한다.
     *
     * @param request
     */
    public JwtTokenDto checkAccessToken(HttpServletRequest request) {
        // 쿠키 정보를 가져온다.
        Cookie[] cookies = request.getCookies();
        if (cookies == null) {
            // 쿠키가 존재하지 않을시 오류 리턴
            throw new BadRequestException(ServerResponse.NO_ACCESS_TOKEN);
        }
        try {
            // TOKEN_COOKIENAME으로 저장된 Token을 가져온다.
            String accessToken = CookieParser.getCookieValue(cookies, TOKEN_COOKIENAME);
            Claims body = jwtParser.parseClaimsJws(accessToken).getBody();

            // PayLoad 정보로 사용자 유효성 검증
            JwtTokenDto tokenDto = new JwtTokenDto(body);

            MemberEntity user = memberRepository.findMemberEntityByUserIdx(tokenDto.getUIdx());
            if (user == null) {
                throw new BadRequestException(ServerResponse.INVALID_TOKEN);
            }
            return tokenDto;
        } catch (Exception e) {
            throw new BadRequestException(ServerResponse.INVALID_TOKEN);
        }
    }

    /**
     * jwt refresh token 을 secret key 로 복호화를 진행하고 유저에 대한
     * 유효성을 검증한다.
     *
     * @param request
     */
    public JwtTokenDto checkRefreshToken(HttpServletRequest request) {
        try {
            Cookie[] cookies = request.getCookies();
            String refreshToken = CookieParser.getCookieValue(cookies, TOKEN_REFRESH_COOKIENAME);
            Claims body = jwtRefreshParser.parseClaimsJws(refreshToken).getBody();

            // PayLoad 정보로 사용자 유효성 검증
            JwtTokenDto tokenDto = new JwtTokenDto(body);

            MemberEntity user = memberRepository.findMemberEntityByUserIdx(tokenDto.getUIdx());
            if (user == null) {
                throw new BadRequestException(ServerResponse.INVALID_TOKEN);
            }
            return tokenDto;
        } catch (Exception e) {
            throw new BadRequestException(ServerResponse.INVALID_TOKEN);
        }
    }

    /**
     * response에 jwt access token을 포함한 Cookie를 추가한다.
     *
     * @param user
     * @return
     */
    public void createAccessTokenString(JwtTokenDto user, HttpServletResponse response) {
        String token = Jwts.builder()
                .setHeaderParam("type", "JWT")
                .claim("cIdx",user.getCIdx())
                .claim("uId", user.getUId())
                .claim("uIdx", user.getUIdx())
                .setIssuedAt(new Date(CommonUtil.getUnixMSNow()))
                .setExpiration(new Date(CommonUtil.getUnixMSNow() + refreshExpirationTime))
                .signWith(SignatureAlgorithm.HS256, accessTokenSecret.getBytes(StandardCharsets.UTF_8))
                .compact();
        log.info("createAccessToken:{}", token);
        // 토큰쿠키 생성
        Cookie accessTokenCookie = new Cookie(TOKEN_COOKIENAME, token);
        accessTokenCookie.setPath("/");
        accessTokenCookie.setMaxAge((int) accessExpirationTime);
        response.addCookie(accessTokenCookie);
    }

    /**
     * response에 jwt refresh token을 포함한 Cookie를 추가한다.
     *
     * @param user
     * @return
     */
    public void createRefreshTokenString(JwtTokenDto user, HttpServletResponse response) {
        String token = Jwts.builder()
                .setHeaderParam("type", "JWT")
                .claim("cIdx",user.getCIdx())
                .claim("uId", user.getUId())
                .claim("uIdx", user.getUIdx())
                .setIssuedAt(new Date(CommonUtil.getUnixMSNow()))
                .setExpiration(new Date(CommonUtil.getUnixMSNow() + refreshExpirationTime))
                .signWith(SignatureAlgorithm.HS256, refreshTokenSecret.getBytes(StandardCharsets.UTF_8))
                .compact();
        log.info("refreshTokenString:{}", token);
        Cookie refreshTokenCookie = new Cookie(TOKEN_REFRESH_COOKIENAME, token);
        refreshTokenCookie.setPath("/");
        refreshTokenCookie.setMaxAge((int) refreshExpirationTime);
        response.addCookie(refreshTokenCookie);
    }
}

Access 토큰 검사/발급을 복제한 Refresh 토큰 검사/발급 로직을 만들었다.

프로퍼티 빼고는 똑같아서 설명은 생략한다. 이전글을 보시길. (발급검사)

 

RefreshToken

/* Refresh 토큰 어노테이션 생성 (AuthIntercepter에서 예외처리) */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RefreshToken {
}

색다른 인증로직을 위해서 RefreshToken 어노테이션을 만들어주었다.

 

TokenAuthInterceptor

@Slf4j
@Component
public class TokenAuthInterceptor implements HandlerInterceptor {
    public static final String USER_ID = "X-USER-ID";
    private final JwtProvider jwtProvider;

    public TokenAuthInterceptor(JwtProvider jwtProvider) {
        this.jwtProvider = jwtProvider;
    }

    /**
     * 컨트롤러에 도착하기 직전 정보를 반환한다.
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        //어노테이션 체크 - Controller에 @Refresh 어노테이션이 있는지 확인
        boolean hasRefreshToken = hasAnnotation(handler, RefreshToken.class);

        //어노테이션 체크 - Controller에 @ExcludeAuth 어노테이션이 있는지 확인
        boolean hasAnnotation = hasAnnotation(handler, ExcludeAuth.class);
        try {
            if (hasRefreshToken) { // !!! 추가된부분 !!!
                log.debug("Refresh Token 인증");
                JwtTokenDto tokenUser = jwtProvider.checkRefreshToken(request);
                request.setAttribute("cIdx", tokenUser.getCIdx());
                request.setAttribute("uIdx", tokenUser.getUIdx());
                request.setAttribute("uId", tokenUser.getUId());
                MDC.put(USER_ID, tokenUser.getUId()); // 로그 출력시에 USER_ID값을 사용할 수 있도록 저장한다.
            } else if (!hasAnnotation) {
                JwtTokenDto tokenUser = jwtProvider.checkAccessToken(request);
                request.setAttribute("cIdx", tokenUser.getCIdx());
                request.setAttribute("uIdx", tokenUser.getUIdx());
                request.setAttribute("uId", tokenUser.getUId());
                MDC.put(USER_ID, tokenUser.getUId()); // 로그 출력시에 USER_ID값을 사용할 수 있도록 저장한다.
            } else {
                log.debug("Access Token 인증 제외");
            }
        } catch (Exception e) {
            throw new BadRequestException(ServerResponse.EXPIRE_TOKEN);
        }
        return true;
    }

    public <A extends Annotation> boolean hasAnnotation(Object handler, Class<A> annotationClass) {
        if (handler instanceof HandlerMethod handlerMethod) {

            // 메서드에 어노테이션이 있는지 확인
            return handlerMethod.getMethodAnnotation(annotationClass) != null;
        }
        return false; // 어노테이션이 없을 경우
    }
}

!!! 추가된부분 !!! 을 잘 보면

1. hasRefreshToken 어노테이션이 있는지 확인하고,

2. Refresh Token 인증 함수를 태우고 (여기서 유효하지 않으면 Exception처리됨)

3. 토큰이 유효하면 request에 넣어 사용가능한 값으로 만든다.

 

이제 @RefreshToken 어노테이션을 사용해보자.

 

AuthController

    /**
     * refresh Token을 기준으로 access Token을 발급
     *
     * @return response
     * POST /refreshToken
     */
    @RefreshToken
    @PostMapping("/refreshToken")
    public ResponseEntity<ServerResponse> refreshToken(HttpServletRequest request, HttpServletResponse response) {
        JwtTokenDto tokenUSer = new JwtTokenDto(request);

        // 로그인 토큰 생성
        jwtProvider.createAccessTokenString(tokenUSer, response);
        jwtProvider.createRefreshTokenString(tokenUSer, response);

        return ResponseEntity.ok(ServerResponse.SUCCESS);
    }

TokenAuthInterceptor에서 Refresh 토큰을 판별하고 request에 넣어줬기 때문에,

Request에서 값을 뽑아서 Jwt Token 을 만드는데 사용한다.

값을 뽑는로직은 JwtTokenDto 안에 넣어버리면 Request 만 넣어 간편하게 뽑아쓸수 있다.

중복코드도 줄이고 일석이조

JwtTokenDto

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class JwtTokenDto {
    long cIdx;
    long uIdx;
    String uId;

    public JwtTokenDto(HttpServletRequest request) {
        if (request.getAttribute("cIdx") != null) {
            this.cIdx = Long.parseLong(request.getAttribute("cIdx").toString());
            this.uIdx = Long.parseLong(request.getAttribute("uIdx").toString());
            this.uId = request.getAttribute("uId").toString();
        }
    }
}

 

테스트

Refresh 토큰값이 이상하다면 401 에러를 리턴한다.

Refresh 토큰값이 정상이면 OK가 떨어진다.

cookie에도 RefreshToken값이 확인된다.

Token.pptx
0.04MB