빙응의 공부 블로그

[Spring REST API]로그인 구현하기 - 2개의 토큰 본문

Spring/개인공부_실습

[Spring REST API]로그인 구현하기 - 2개의 토큰

빙응이 2024. 6. 4. 21:27

📝 개요 - 단일 토큰을 왜 안쓰는가?

바로 전 포스팅에서 Access Token을 이용한 JWT 인증을 성공적으로 사용해보았다.

[Spring REST API]로그인 구현하기 - JWT + Security (tistory.com)

 

[Spring REST API]로그인 구현하기 - JWT + Security

📝JWT 기본 동작 알아보기 JWT의 기본 동작을 알아보자 간단히 알아보자면 시큐리티처럼 Access Token(시큐리티에서 세션)으로 검증 로직이 이루어진다.접속을 하면 Access Token을 검사하여 없으면

quddnd.tistory.com

 

1. 단일 토큰 사용 추적을 해보자
  1. 로그인 성공 시 JWT Access Token 발행 : 서버측 -> 클라이언트로 JWT 발급
  2. 권한이 필요한 모든 요청 : 클라이언트 -> 서버측에 JWT 전송

권한이 필요한 요청은 서비스에서 많이 발생한다. (회원 CRUD, 게시글/댓글 CRUD, 주문 등등)

따라서 JWT는 매시간 수많은 요청을 위해 클라이언트의 JS 코드로 HTTP 통을 통해 서버로 전달된다.

 

여기서 문제가 발생한다. 해커는 클라이언트 측에서 XSS(크로스 사이드 스크립팅)을 이용하거나 HTTP 통신을 가로채서 토큰을 훔칠 수 있기 때문에 여러 기술을 도입하여 탈취를 방지해야한다.

 

2. 다중 토큰의 등장

위와 같은 탈취 문제로 인해 Access/Refresh 토큰 개념이 등장하였다.

 

자주 사용되는 토큰의 생명주기는 짧게(약 10분), 이 토큰이 만료되었을 때 함께 받은 Refresh 토큰으로 토큰을 재발급한다.

 

1. 로그인 성공시 생명주기와 활용도가 다른 토큰 2개 발급 : Access/Refresh

  • Access 토큰 : 권한이 필요한 모든 요청 헤더에 사용될 JWT로 탈취되어도 생명주기가 짧기 때문에 보안적으로 좋다.
  • Refresh 토큰 : Access 토큰이 만료되었을 때 재발급 받기 위한 용도로만 사용되며 약 24시간 이상의 긴 생명주기를 가진다.

2. 권한이 필요한 모든 요청 : Access 토큰을 통해 요청한다.

  • Access 토큰은 노출도가 크지만 생명주기가 짧아 탈취되어도 위험도가 낮다.

3. 토큰이 만료된 경우 Refresh 토큰으로 Access 토큰 발급

 

3. Access/Refresh 토큰 저장 위치

Refresh 토큰이 탈취되는 경우 매우 큰 피해를 입게 된다.

Refresh 토큰은 사용빈도가 적긴 하지만 탈취 위험은 존재한다. 따라서 Refresh 토큰도 보호 방법이 필요하다.

 

  • Access/Refresh 토큰의 저장 위치 고려
    • 로컬/세션 스토리지 및 쿠키에 따라 공격 대비 여부가 달라 알맞은 저장소를 설정해야한다.
  • Refresh 토큰 Rotate
    • Access 토큰을 갱신하기 위한 Refresh 토큰 요청 시 서버측에서 Refresh 토큰도 재발급을 진행하여 한 번 사용한 Refresh 토큰은 재사용하지 못하도록 한다.
  • 일반적으로 Access Token은 클라이언트의 로컬 스토리지나 세션 스토리지에 저장하고, Refresh Token은 보안이 강화된 쿠키(HTTP-Only, Secure 플래그 설정)나 Redis 같은 서버 측 저장소에 저장합니다.
  • 쿠키 저장이 가능한 이유는 쿠키 저장 시 XSS 공격을 받을 수 있지만 httpOnly를 설정하면 완벽히 방어가 가능하기 때문이다.

 

 

4. 로그아웃과 Refresh 토큰 주도권 

이것은 로그아웃과 Refresh 토큰의 서버 주도권 문제이다.

  • 문제

로그아웃을 구현하면 프론트측에 존재하는 Access/Refresh 토큰을 제거한다. 그럼 프론트측에서 요청을 보낼 JWT가 없기 ㄸ때문에 로그아웃이 되었다고 생각하지만 이미 해커가 JWT를 복제했다면 요청을 수행할 수 있다.

 

이는 단순히 JWT를 발급하는 서버 측의 주도권이 없기 때문이다. 

 

  • 해결방법

쉽게 Refresh 토큰을 발급과 함께 서버측 저장소에 저장하여 요청이 올때마다 저장소에 존재하는지 검증하는 방법으로 주도권을 가질 수 있다.

만약 로그아웃을 진행하면 서버측 저장소에서 해당 JWT를 제거하면 된다. 

이를 Refresh 토큰 블랙리스팅이라고도 부른다.

 

📝 다중 토큰 발급

Refresh 토큰을 발급해보자!

private final long RefreshTokenRemiteTime = 1000L * 60 * 60 * 24; // 1일

일단 RefreshToken의 생명주기를 1일로 설정했다.

 

이번 포스팅에서는 RefreshToken을 보안 쿠키에 저장할 것이다.

 

1. LoginFilter의 로그인 성공 메소드 수정하기

토큰을 둘 다 발행하기 위해 로그인 성공 메소드를 수정하여 2개를 생성하게 해보자

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) {

        CustomUserDetails customUserDetails = (CustomUserDetails) authentication.getPrincipal();

        String userName = customUserDetails.getUsername();


        String token = jwtTokenProvider.createToken(userName);

        response.addHeader("Authorization", "Bearer " + token);
    }

기존 코드이다.

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) {
        //로그인 성공 시 유저 정보 조회
        CustomUserDetails customUserDetails = (CustomUserDetails) authentication.getPrincipal();

        String userName = customUserDetails.getUsername();

        //토큰 생성
        String AccessToken = jwtTokenProvider.createAccessToken("access",userName);
        String RefreshToken = jwtTokenProvider.createRefreshToken("refresh",userName);
        //응답 설정
        response.setHeader("access", AccessToken);
        response.addCookie(createCookie("refresh", RefreshToken));
        response.setStatus(HttpStatus.OK.value());
    }
    //쿠키 생성 메소드 
    private Cookie createCookie(String key, String value) {

        Cookie cookie = new Cookie(key, value);
        cookie.setMaxAge(24*60*60); // 쿠키의 생명 주기
        //cookie.setSecure(true); //https 통신 시 진행
        //cookie.setPath("/"); //쿠키의 적용 범위 설정 가능
        cookie.setHttpOnly(true); //XSS 방어를 위한 설정 

        return cookie;
    }

변경점은 RefreshToken을 생성하고 각각 세션, 쿠키에 저장하는 것이다. 

또한 jwtTokenProvider에 인자가 추가되었는데 이것은 토큰을 카테고리로 구분하기 위한 것이다. 

 

2. Provider 수정 
  public String getCategory(String token) {

        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("category", String.class);
    }
    public String createToken(String category,String username) {

        return Jwts.builder()
            .claim("category", category)
            .claim("username", username)
            .issuedAt(new Date(System.currentTimeMillis()))
            .expiration(new Date(System.currentTimeMillis() + tokenRemiteTime))
            .signWith(secretKey)
            .compact();
    }
    public String createRefreshToken(String category,String username) {

        return Jwts.builder()
            .claim("category", category)
            .claim("username", username)
            .issuedAt(new Date(System.currentTimeMillis()))
            .expiration(new Date(System.currentTimeMillis() + RefreshTokenRemiteTime))
            .signWith(secretKey)
            .compact();
    }

위처럼 각 토큰을 만들고 카테고리를 통해 구별하게 수정하였다.

 

테스트 시 위처럼 Access Token과 Refresh Token이 발급된걸 볼 수 있다. 

 

📝 토큰 검증 필터 JwtFilter 수정하기 

프론트엔드에서 위 테스트처럼 토큰을 받으면 저장하고 서버측에 요청때마다 전송한다.

이 상태에서 토큰의 만료와 위조를 검사하면 된다. 

이것을 수정해보자

 

 수정 전
@Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        //request에서 Authorization 헤더를 찾음
        String authorization= request.getHeader("Authorization");

        //Authorization 헤더 검증
        if (authorization == null || !authorization.startsWith("Bearer ")) {

            System.out.println("token null");
            //필터 다음으로 넘겨줌(JWT 검증 -> 로그인)
            filterChain.doFilter(request, response);

            //조건이 해당되면 메소드 종료
            return;
        }

        //Bearer 부분 제거 후 순수 토큰만 획득
        String token = authorization.split(" ")[1];

        //토큰 소멸 시간 검증
        if (jwtTokenProvider.isExpired(token)) {

            System.out.println("token expired");
            filterChain.doFilter(request, response);

            //조건이 해당되면 메소드 종료
            return;
        }


        //스프링 시큐리티 인증 토큰 생성
        Authentication authToken = jwtTokenProvider.getAuthentication(token);
        //세션에 사용자 등록
        SecurityContextHolder.getContext().setAuthentication(authToken);

        filterChain.doFilter(request, response);
    }

하나의 토큰을 사용할 때의 로직이다. 

 

수정 후 

 

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        // 헤더에서 Access Token을 꺼냄
        String accessToken = request.getHeader("access");

        // 토큰이 없다면 다음 필터로 넘김
        if (accessToken == null) {
            filterChain.doFilter(request, response);
            return;
        }

        // 토큰 만료 여부 확인, 만료시 다음 필터로 넘기지 않음
        try {
            jwtTokenProvider.isExpired(accessToken);
        } catch (ExpiredJwtException e) {
            throw new AppException(UserErrorCode.ACCESS_TOKEN_EXPIRED);
        }

        // 토큰이 access인지 확인 (발급시 페이로드에 명시)
        String category = jwtTokenProvider.getCategory(accessToken);

        if (!category.equals("access")) {
            throw new AppException(UserErrorCode.INVALID_ACCESS_TOKEN);
        }

        // 스프링 시큐리티 인증 토큰 생성
        Authentication authToken = jwtTokenProvider.getAuthentication(accessToken);

        // 세션에 사용자 등록
        SecurityContextHolder.getContext().setAuthentication(authToken);

        filterChain.doFilter(request, response);
    }

1. 헤더에서 Access Token을 가져온다.

2. 토큰이 없다면 다음 필터로 넘긴다.

3. 토큰 만료 유무를 검사한다. 

4. 토큰이 Access Token인지 검증

 

이제 Access Token에 대한 검증 로직이 완료되었다.

다음은 토큰이 만료한 경우나 찾을 수 없는 경우에 대한 새로운 Access Token 발급을 해보자 

📝Access Token 만료 시 Refresh로 재발급 

이제 자동로그인을 위한 JwtFilter를 수정해보았다.

이제 재발급하는 메소드를 만들어보자

보기 전 주의점!

해당 재발급은 프론트엔드에서 호출하는 것이다.

즉 프론트엔드에서 AccessToken과 Refresh Token으로 자동 검증을 진행했을때 

응답으로 오류가 오면 해당 URI에 접근하는 것이다. 

@RestController
public class ReissueController {
    private final JwtTokenProvider jwtTokenProvider;

    public ReissueController(JwtTokenProvider jwtTokenProvider) {

        this.jwtTokenProvider = jwtTokenProvider;
    }
    @PostMapping("/reissue")
    public ResponseEntity<?> reissue(HttpServletRequest request, HttpServletResponse response) {

        //쿠키에서 Refresh 토큰 얻기
        String refresh = null;
        Cookie[] cookies = request.getCookies();
        for (Cookie cookie : cookies) {

            if (cookie.getName().equals("refresh")) {

                refresh = cookie.getValue();
            }
        }

        if (refresh == null) {

            throw new AppException(AuthErrorCode.REFRESH_TOKEN_NULL);
        }

        //만료 시간 체크
        try {
            jwtTokenProvider.isExpired(refresh);
        } catch (ExpiredJwtException e) {
            throw new AppException(AuthErrorCode.REFRESH_TOKEN_EXPIRED);
        }

        // 토큰이 refresh인지 확인 (발급시 페이로드에 명시)
        String category = jwtTokenProvider.getCategory(refresh);

        if (!category.equals("refresh")) {

            //response status code
            throw new AppException(AuthErrorCode.INVALID_REFRESH_TOKEN);
        }

        String userName = jwtTokenProvider.getUsername(refresh);

        //make new JWT
        String newAccess = jwtTokenProvider.createAccessToken("access", userName);

        //response
        response.setHeader("access", newAccess);

        return new ResponseEntity<>(HttpStatus.OK);
    }
}

JwtFilter와 크게 다르지 않다. 

쿠키에서 토큰을 얻고 해당 토큰이 맞는지 검증하고 만료 시간을 확인 후 Access Token을 만들어 주면 된다.

 

 

📝 테스트 

일단 2개의 토큰이 생성되는지부터 보자

로그인했을 때 Access Token과 Refresh Token이 생성된 것을 알 수 있다.

 

두개를 지정하고 권한이 필요한 페이지에 접근도 성공하였다.

Access Token 재발급도 성공하였다.

 

 

 

✔ 필요한 것

다음 시간에는 Refresh Token을 안전하게 저장하고 관리하는 법에 대해 알아보자!