JWT 로그인하는 방법2

Park sang woo·2024년 8월 17일

CS스터디

목록 보기
15/25

⭕ 심화 과정 (Refresh 등)

✅ JWT 단일 토큰의 사용처 추적

로그인 성공 시 JWT 발급 : 서버측 -> 클라이언트로 JWT 발급
권한이 필요한 모든 요청 : 클라이언트 -> 서버측 JWT 전송

매시간 수많은 요청을 위해 클라리언트의 JS 코드로 HTTP 통신을 위해 서버로 전달됩니다. 해커는 클라이언트 특에서 XSS를 이용하거나 HTTP 통신을 가로채서 토큰을 훔칠 수 있기 때문에 여러 기술을 도입하여 탈취를 방지하고 탈취 되어도 대비 로직이 존재해야 합니다.






✅ 다중 토큰 - Refresh 토큰과 생명주기

권한이 필요한 모든 요청에 대한 것은 Access Token이 이것의 생명주기를 짧게 해서 다시 발급해주는 것이 Refresh Token입니다.
생명주기가 너무 짧으면 만료 시 매번 로그인을 진행하는 문제가 발생하기 때문에 생명주기가 긴 Refresh도 함께 발급합니다. 이 토큰은 생명주기가 24시간 이상으로 깁니다.

1️⃣ 로그인 성공 시 생명주기와 활용도가 다른 토큰 2개를 발급받습니다.

  • Access 토큰 : 클라이언트가 갖고 있는 실제로 유저의 정보가 담긴 토큰으로 클라이언트에서 요청이 오면 서버에서 해당 토큰에 있는 정보를 활용하여 사용자 정보에 마젝 응답을 진행합니다.
    • 권한이 필요한 모든 요청 헤더에 사용될 JWT로 탈취 위험을 낮추기 위해 약 10분 정도의 짧은 생명주기를 가집니다.
  • Refresh 토큰 : 새로운 Access 토큰을 발급하기 위해 사용하는 토큰으로 짧은 수명을 가지는 Access 토큰에게 새로운 토큰을 발급해주기 위해 사용합니다. (해당 토큰은 보통 DB에 유저 정보와 같이 기록됨)
    • Access 토큰이 만료되었을 때 재발급 받기 위한 용도로만 사용되며 약 24시간 이상의 긴 생명주기를 가집니다.

2️⃣ 권한이 필요한 모든 요청은 Access 토큰을 통해 요청

  • 그러므로 Refresh 토큰은 호출 및 전송 빈도가 낮습니다.

3️⃣ 권한이 맞다는 가정하에 2가지 상황 : 데이터 응답과 토큰 만료 응답

4️⃣ 만료됐다면 프론트 측에서 Refresh 토큰으로 Access 토큰을 재발급받습니다.

  • Access 토큰이 만료되었다는 요청이 프론트 측으로 들어올 경우 프론트 로직에 의해 1️⃣에서 발급 받은 Refresh 토큰을 가지고 서버의 특정 경로(Refresh 토큰을 받는 경로)에 요청을 보내어 Access 토큰을 재발급 받습니다.

5️⃣ 서버측에서는 Refresh 토큰을 검증 후 Access 토큰을 새로 발급받습니다.

예를 들면 처음에 로그인을 했을 때, 서버는 로그인을 성공시키면서 클라이언트에게 Access Token과 Refresh Token을 동시에 발급한다.  서버는 데이터베이스에 Refresh Token을 저장하고, 클라이언트는 Access Token과 Refresh Token을 쿠키, 세션 혹은 웹스토리지에 저장하고 요청이 있을때마다 이 둘을 헤더에 담아서 보낸다.
이 Refresh Token은 긴 유효기간을 가지면서, Access Token이 만료됐을 때 새로 재발급해주는 열쇠가 된다. 따라서 만일 만료된 Access Token을 서버에 보내면, 서버는 같이 보내진 Refresh Token을  DB에 있는 것과 비교해서 일치하면 다시 Access Token을 재발급하는 간단한 원리이다. 그리고 사용자가 로그아웃을 하면 저장소에서 Refresh Token을 삭제하여 사용이 불가능하도록 하고 새로 로그인하면 서버에서 다시 재발급해서 DB에 저장한다.

Refresh 토큰이 필요한 이유
AccessToken는 제 3자에게 탈취당할 경우 보안에 취약합니다. AccessToken 발급된 이후, 서버에 저장되지 않고 토큰 자체로 검증하여 사용자 권한을 인증하기 때문에 탈취되면 토큰이 만료되기 전까지 토큰을 흭득한 사람은 누구나 권한 접근이 가능합니다. (JWT는 삭제가 불가능해서 유효시간으로 함.)
유효기간을 짧게 하면 로그인을 자주 해서 새롭게 토큰을 발급받아야 하는 불편함이 있기 때문에 RefreshToken으로 재발급을 하는 것입니다.


Refresh 토큰 생명주기가 긴 이유

  • 사용자는 자주 로그인하지 않고도 서비스를 계속 이용할 수 있습니다.
  • 사용자는 계속해서 인증된 상태를 유지할 수 있습니다.
  • Access Token이 만료될 때마다 사용자 인증을 다시 수행하는 것은 서버에 부하를 줄 수 있습니다. Refresh Token을 사용하면 사용자의 인증 정보를 일관되게 유지하면서 서버의 인증 요청 수를 줄일 수 있습니다.





✅ 구현해야 하는 포인트

  • 로그인이 완료되면 SucceessHandler에서 Access/Refresh 토큰 2개를 발급해 응답해야 합니다.
    • 각 토큰은 각기 다른 생명주기, payload 정보를 가집니다.
  • Access 토큰 요청을 검증하는 JWTFilter에서 Access 토큰이 만료된 경우는 프론트 개발자와 협의된 상태 코드와 메시지를 응답한다.
  • 프론트측 API 클라이언트 (axios, fetch) 요청시 Access 토큰 만료 요청이 오면 예외문을 통해 Refresh 토큰을 서버측으로 전송하고 Access 토큰을 발급 받는 로직을 수행한다. (기존 Access는 제거하고 새로 받은 Access Token 저장.)
  • 서버 측에서는 Refresh 토큰을 받을 엔드포인트(컨트롤러)를 구성하여 Refresh를 검증하고 Access를 응답합니다.





✅ Refresh 토큰이 탈취된다면?

Refresh 토큰은 생명주기가 길고 탈취될 수 있는 확률이 존재하기 때문에 Refresh 또한 보호 방법이 필요합니다.

  • Access Token과 Refresh Token 저장 위치 고려
    • 로컬/세션 스토리지 및 쿠키에 따라 XSS, CSRF 공격의 여부가 결정되기 때문에 각 토큰 사용처에 알맞은 저장소 설정.
  • Refresh 토큰 Rotate
    • Access 토큰을 갱신하기 위한 Refresh 토큰 요청 시 서버측에서에서 Refresh 토큰도 재발급을 진행하여 한 번 사용한 Refresh 토큰은 재사용하지 못하도록 합니다.





✅ Access Token과 Refresh Token 저장 위치 고려

로컬 스토리지는 XSS 공격에 취약하고 httpOnly 쿠키는 CSRF 공격에 취약합니다.
보통 Access 토큰을 로컬에 저장하고 Refresh 토큰을 쿠키에 저장하지만 주관적인 판단에 따라 달라질 수 있습니다.

  • 보통 JWT 탈취는 XSS 공격으로 로컬에 저장된 JWT가 탈취되지만 쿠키는 또 CSRF에 취약하기 때문에 각 상황에 알맞은 저장소를 선택해야 합니다.

Access 토큰
주로 로컬에 저장되는데 해커가 JWT를 탈취해도 생명 주기가 짧고 에디터 및 업로드에서 XSS를 방어하는 로직이 있기 때문에 최대한 보호가 가능합니다.
하지만 쿠키에 저장해서 CSRF 공격을 받으면 클릭 한번으로 단시간에 요청이 진행되기 때문에 XSS 공격을 받는 것이 나은 선택이라 주로 로컬에 저장됩니다.


Refresh 토큰
쿠키는 CSRF, XSS 공격 둘다 받을 수 있긴 한데 XSS 공격의 경우 JS의 httpOnly를 설장하면 완벽히 벙어가 가능하기 때문에 신경쓰지 않아도 됩니다.

CSRF의 경우 위험하다고 생각이 들 수 있지만 Refresh 토큰의 사용처는 단 하나인 토큰 재발급 엔드포인트(경로)입니다. CSRF는 Access 토큰이 접근하는 회원 정보 수정, 게시글 CRUD에 매우 취약하지만 토큰 재발급 경로(검증, 재발급)에서는 크게 피해를 입힐 만한 로직이 없습니다.






✅ Refresh 토큰 Rotate

이렇게 보호해도 탈취당할 수 있기 때문에 Rotate를 설정합니다. Refresh 토큰에 대한 추가적인 방어 조치로 Access 토큰이 만료되어 Refresh 토큰을 가지고 서버 특정 엔드포인트에 재발급을 진행하면 Refresh 토큰 또한 재발급하여 프론트측으로 응답합니다.
한 번만 사용하고 더 이상 사용하지 못하여 보안 요소를 더 강화합니다.






✅ 로그아웃과 Refresh 토큰 주도권

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

문제 발생 이유
JWT를 발급해준 순간부터는 서버측에는 주도권이 없습니다. (STATLESS 하기 때문에)
그래서 JWT가 탈취된다면 피해를 막을 방법은 생명주기가 끝나기를 기다릴 수 밖에 없습니다.


문제 해결 방법
서버측이 주도권을 가지기.
생명주기가 긴 Refresh 토큰을 서버측에서 SuccessHandler를 통해서 발급할 때 클라이언트 측에도 발급해주고 발급해준 토큰에 대해서 서버측 저장소에도 저장합니다. Refresh 토큰으로 재발급 요청이 올 때마다 저장소에 존재하는지 확인해서 서버측에서 주도권을 가질 수 있습니다.

그래서 만약 탈취 공격이 온다면 서버측 저장소에서 해당 JWT를 삭제하여 피해를 방어할 수 있습니다.

Refresh 토큰 블랙리스팅이라고도 부릅니다.






✅ 로그인 시 메일 알림 (추가 방법)

네이버 서비스를 사용하다 보면 평소에 사용하지 않던 IP나 브라우저에서 접근할 경우 사용자의 계정으로 메일 알림이 발생합니다.

이때 내가 아닐 경우 “아니요”를 클릭하게되면 서버측 토큰 저장소에서 해당 유저에 대한 Refresh 토큰을 모두 제거하여 앞으로의 인증을 막을 수 있습니다.

토큰에 대한 명령법, 발급 방법, OAuth에서 주로 사용되는 PKCE 등 여러 방법이 있습니다.






✅ 다중 토큰 발급 (Refresh)

successfulAuthentication() 메소드 또는 ÀuthenticationSuccessHandler에 2개의 토큰을 발급합니다.

각각의 토큰은 생명주기와 사용자가 다르기 때문에 다른 저장소에 발급합니다.
Access 토큰은 헤더에 발급 후 프론트에서 로컬 스토리지에 저장하고 Refresh 토큰은 쿠키에 발급했습니다.


JWTUtil.class 수정

/**
 * 내부 카테고리 값을 꺼내기 위한 메서드
 */
public String getCategory(String token) {
	return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("category", String.class);
}


/**
 * 토큰 생성 메서드
 * category는 Access인지 Refresh 토큰인지 구분하기 위한 파라미터
 * Refresh를 가지고 접근하려고 하면 사용할 수 없도록 만들기
 */
public String createJwt(String category, String username, String role, Long expiredMs) {

	return Jwts.builder()
    	.claim("category", category)
        .claim("username", username) // claim으로 특정한 키에 대한 데이터를 넣어줌.
        .claim("role", role)
        .issuedAt(new Date(System.currentTimeMillis())) // 토큰이 언제 발생
        .expiration(new Date(System.currentTimeMillis() + expiredMs)) // 만료 기간
        .signWith(secretKey) // 토큰 시그니쳐를 만들어서 암호화 진행.
        .compact(); // 토큰 생성.
}

LoginFilter.class 수정

@Slf4j
@RequiredArgsConstructor
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
    private final AuthenticationManager authenticationManager;
    private final JWTUtil jwtUtil;

    @Override
    public Authentication attemptAuthentication(HttpServletRequest req, HttpServletResponse res) throws AuthenticationException {
        // 클라이언트 요청에서 username, password 추출
        String username = obtainUsername(req);
        String password = obtainPassword(req);


        // username과 password 검증하기 위해서는 token에 담아야 함.
        UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(username, password, null);
        //token에 담은 검증을 위한 AuthenticationManager로 전달 (검증 진행)
        return authenticationManager.authenticate(authToken);
    }


    /**
     * 검증 성공하면 진행할 메서드
     * 2개의 토큰 발급받기
     */
    @Override
    protected void successfulAuthentication(HttpServletRequest req, HttpServletResponse res, FilterChain chain,
                                            Authentication authentication) throws IOException, ServletException {

        // 유저 정보
        String username = authentication.getName();
        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
        Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
        GrantedAuthority auth = iterator.next();
        String role = auth.getAuthority();

        // 토큰 생성
        String access = jwtUtil.createJwt("Access", username, role, 600000L);
        String refresh = jwtUtil.createJwt("refresh", username, role, 86400000L);

        // 응답할 때 response에 넣어서 응답
        res.addHeader("access", access); // 응답 헤더에 access 토큰을 넣어줌. key가 access
        res.addCookie(createCookie("refresh", refresh)); // 응답 쿠키에 refresh 토큰 넣어주기
        res.setStatus(HttpStatus.OK.value()); // 200 응답

    }

    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) {
        response.setStatus(401);
    }

    /**
     * 쿠키 생성 메서드
     * value는 JWT가 들어갈 값
     *
     */
    private Cookie createCookie(String key, String value) {

        Cookie cookie = new Cookie(key, value);
        cookie.setMaxAge(24*60*60); // 쿠키 생명주기
        //cookie.setSecure(true); // https를 진행할 경우 true로 설정
        //cookie.setPath("/"); // 쿠키가 적용도리 범위 설정.
        cookie.setHttpOnly(true); // 클라이언트 단에서 JS로 해당 쿠키로 접근이 불가능하게 방어.

        return cookie;
    }
}

access와 refresh를 프론트단에서 가져와서 로컬 스토리지에 저장해두고 작업을 진행합니다.






✅ Access 토큰 필터

프론트 측에서 발급받은 토큰 중에 Access 토큰을 가지고 요청 헤더에 넣으면 서버 측에서 토큰을 다시 검증해야 합니다.

그 검증할 필터를 JWtFilter에서 수정했습니다.

요청이 오면 백에서는 Access와 Refresh를 응답하고 특정한 데이터를 요청할 때는 항상 Access 토큰을 헤더에 담아서 서브측으로 전송합니다.
서버측에서는 토큰이 만료되었는지 위조되었는지 검증을 해야 합니다.
Access 토큰이 만료된 경우 특정한 상태 코드 및 메시지를 응답해야 합니다.


@Slf4j
@RequiredArgsConstructor
public class JWTFilter extends OncePerRequestFilter {
    private final JWTUtil jwtUtil;

    /**
     * JWT Access Token 검증
     */
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // 헤더에서 access키에 담긴 토큰을 꺼냄
        String accessToken = request.getHeader("access"); // key를 access로 저장했었음.

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

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

            //response body
            PrintWriter writer = response.getWriter();
            writer.print("access token expired");

            //response status code
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401
            return;
        }

        // 토큰이 Access인지 Refresh인지 확인 (발급 시 Payload에 명시)
        String category = jwtUtil.getCategory(accessToken);

        if (!category.equals("access")) { // Access 토큰이 아니면
            //response body
            PrintWriter writer = response.getWriter();
            writer.print("invalid access token");

            //response status code
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            return;
        }

        // username, role 값을 획득
        String username = jwtUtil.getUsername(accessToken);
        String role = jwtUtil.getRole(accessToken);

        Member member = new Member();
        member.setUsername(username);
        member.setRole(role);
        CustomUserDetails customUserDetails = new CustomUserDetails(member);

        // 로그인 진행
        Authentication authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities());
        // 해당 유저를 SecurityContextHolder에 등록하면 일시적인 세션 생성.
        // 요청에 대해서 로그인된 상태로 변경.
        SecurityContextHolder.getContext().setAuthentication(authToken);

        filterChain.doFilter(request, response);
    }
}

토큰이 만료되었을 때 다음 필터로 넘기지 않습니다. 그냥 만료되었다고 응답합니다.






✅ Refresh로 Access 토큰 재발급


Access 토큰 만료로 이한 특정한 상태 코드가 응답되면 프론트측 Axios Interceptor와 같은 예외 핸들러에서 Access 토큰 재발급을 위한 Refresh를 서버측으로 전송합니다.

이때 서버에서는 Refresh 토큰을 받아 새로운 Access 토큰을 응답하는 코드를 작성하면 됩니다.


@RestController
@RequiredArgsConstructor
public class ReissueController {
    private final JWTUtil jwtUtil;

    @PostMapping("/reissue")
    public ResponseEntity<?> reissue(HttpServletRequest request, HttpServletResponse response) {

        //get refresh token
        String refresh = null;
        Cookie[] cookies = request.getCookies();
        for (Cookie cookie : cookies) {
            if (cookie.getName().equals("refresh")) { // 쿠키 키값중에 refresh가 있는지 확인
                refresh = cookie.getValue();
            }
        }

        if (refresh == null) {
            return new ResponseEntity<>("refresh token null", HttpStatus.BAD_REQUEST);
        }

        // Refresh가 만료되었는지 체크
        try {
            jwtUtil.isExpired(refresh);
        } catch (ExpiredJwtException e) {

            //response status code
            return new ResponseEntity<>("refresh token expired", HttpStatus.BAD_REQUEST);
        }

        // 토큰이 refresh인지 확인 (발급 시 Payload에 명시)
        String category = jwtUtil.getCategory(refresh);

        if (!category.equals("refresh")) {
            return new ResponseEntity<>("invalid refresh token", HttpStatus.BAD_REQUEST);
        }

        // 모든 검증이 끝난 상태.
        String username = jwtUtil.getUsername(refresh);
        String role = jwtUtil.getRole(refresh);

        // 새로운 Access 토큰 생성
        String newAccess = jwtUtil.createJwt("access", username, role, 600000L);

        // 헤더에 새로운 Access 토큰 넣어서 상태 코드 응답.
        response.setHeader("access", newAccess);

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

여기 로직들은 Service단으로 빼서 만드는 것이 좋습니다.
이후 "reissue" 경로를 모든 사용자가 사용할 수 있도록 permitAll() 해주면 됩니다.
Access 토큰이 만료된 상태로 접근하기 때문에 로그인 자체가 불가능한 상태이기 때문입니다.

.requestMatchers("/login", "/", "/signup", "/reissue").permitAll() // 모든 권한 허용

"reissue" 컨트롤러가 Refresh 토큰을 보내면 Access 토큰을 응답해주는지 확인하면 됩니다.
PostMan으로 로그인을 한 후 POST 요청하면 확인할 수 있습니다.

로그인을 먼저 해봐야 합니다. reissue를 할 때는 accessToken이 필요하기 때문에 로그인 시 쿠키가 명확히 들어왔는지 확인해야 합니다.
쿠키가 없는데 "/reissue"를 실행하면 계속 Cannot read the array length because "<local5>" is null 에러가 발생합니다.

로그인 시 생성된 쿠키에서 JWT를 복사하여 "/reissue"에 넣고 돌려야 합니다. Refresh할 때 쿠키만 보냅니다. 토큰까지 보내면 spring에 가기도 전에 토큰 없다고 에러 발생
로그인일 때는 아무것도 안보내고 그 외는 access 토큰과 cookie 같이 보내기






✅ Refresh Rotate

Reissue 엔드포인트에서 Refresh 토큰을 받아 Access 토큰을 갱신 시 Refresh 토큰도 함께 갱신하는 방법입니다.

  • 장점

    • Refresh 토큰 교체로 보안성 강화
    • 로그인 지속시간 길어짐
      • 계속해서 24시간에 해당하는 Refresh 토큰을 가져올 때마다 새로 갱신해주기 때문에 길어집니다.
  • 추가 구현 작업

    • 발급했던 Refresh 토큰은 사라지지 않습니다.
    • 프론트측에서 해킹해서 Refresh Rotate 했다 하더라도 이전 토큰을 가지고 Reissue 엔드포인트에 오면 승인이 되기 때문에 Rotate 이전의 토큰은 사용하지 못하도록 해야 합니다.

ReissueController

// 모든 검증이 끝난 상태.
String username = jwtUtil.getUsername(refresh);
String role = jwtUtil.getRole(refresh);

// 새로운 Access 토큰 생성
String newAccess = jwtUtil.createJwt("access", username, role, 600000L);
String newRefresh = jwtUtil.createJwt("refresh", username, role, 86400000L); // Refresh Rotate

// 헤더에 새로운 Access 토큰 넣어서 상태 코드 응답.
response.setHeader("access", newAccess);
response.addCookie(createCookie("refresh", newRefresh));

return new ResponseEntity<>(HttpStatus.OK);

Rotate 되기 이전의 토큰을 가지고 서버측으로 가도 인증이 되기 때문에 서버측에서 발급했던 Refresh들을 기억한 뒤 블랙리스트 처리를 진행하는 로직을 작성해야합니다.


"/login" 했을 때 쿠키에 있는 Refresh 토큰 값과 "reissue" 시 쿠키에 있는 Refresh 토큰 값이 다른 것을 확인할 수 있습니다.






✅ Refresh 토큰 서버측 저장

단순하게 JWT를 발급하여 클라이언트측으로 전송하면 인증/인가에 대한 주도권 자체가 클라이언트측에 맡겨집니다.

JWT를 탈취하여 서버측으로 접근할 경우 JWT가 만료되기 까지 서버측에서는 그것을 막을 수 없으며, 프론트측에서 토큰을 삭제하는 로그아웃을 구현해도 이미 복제가 되었다면 막을 수 없습니다.

이런 문제를 해결하기 위해 생명주기가 긴 Refresh 토큰은 발급시 서버측 저장소에 기억 후 기억되어 있는 Refresh 토큰만 사용할 수 있도록 서버측에서 주도권을 가질 수 있습니다.


구현 작업

  • 발급 시
    • Refresh 토큰을 서버측 저장소(Redis나 MySQL 같은 DB)에 저장
  • 갱신 시 (Refresh Rotate)
    • 기존 Refresh 토큰을 삭제하고 새로 발급한 Refresh 토큰을 저장

Refresh 토큰이 서버측 저장소에 있는지 확인하는 로직과 로그아웃 진행하여 서버측 저장소에서 해당 토큰을 지워주는 로직도 필요합니다.


@Entity
@Getter
@Setter
public class Refresh {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String username; // 어떤 유저에 대한 토큰인지
    private String refresh;
    private String expiration;
}

하나의 유저가 여러개의 토큰 발급 가능합니다. (UNIQUE 설정X)


Repository

Boolean existsByRefresh(String refresh);

// DB 내부에 해당 Refresh 토큰을 지우기 위함
@Transactional
void deleteByRefresh(String refresh);

RDB 또는 Redis와 같은 데이터베이스를 통해 Refresh 토큰을 저장한다. 이때 Redis의 경우 TTL 설정을 통해 생명주기가 끝이난 토큰은 자동으로 삭제할 수 있는 장점이 있다.



"LoginFilter"에 추가

// Refresh 토큰을 저장소에 저장
addRefreshEntity(username, refresh, 86400000L);

/**
 * Refresh 토큰을 저장소에 저장하는 로직
 */
private void addRefreshEntity(String username, String refresh, Long expiredMs) {

	Date date = new Date(System.currentTimeMillis() + expiredMs);

    Refresh refreshEntity = new Refresh();
    refreshEntity.setUsername(username);
    refreshEntity.setRefresh(refresh);
    refreshEntity.setExpiration(date.toString());

    refreshRepository.save(refreshEntity);
}

"SecurityConfig"에 추가

.addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil, refreshRepository), UsernamePasswordAuthenticationFilter.class);

ReissueController 추가

@RestController
@RequiredArgsConstructor
public class ReissueController {
    private final JWTUtil jwtUtil;
    private final RefreshRepository refreshRepository;

    @PostMapping("/reissue")
    public ResponseEntity<?> reissue(HttpServletRequest request, HttpServletResponse response) {

        //get refresh token
        String refresh = null;
        Cookie[] cookies = request.getCookies();
        for (Cookie cookie : cookies) {
            if (cookie.getName().equals("refresh")) { // 쿠키 키값중에 refresh가 있는지 확인
                refresh = cookie.getValue();
            }
        }

        if (refresh == null) {
            return new ResponseEntity<>("refresh token null", HttpStatus.BAD_REQUEST);
        }

        // Refresh가 만료되었는지 체크
        try {
            jwtUtil.isExpired(refresh);
        } catch (ExpiredJwtException e) {

            //response status code
            return new ResponseEntity<>("refresh token expired", HttpStatus.BAD_REQUEST);
        }

        // 토큰이 refresh인지 확인 (발급 시 Payload에 명시)
        String category = jwtUtil.getCategory(refresh);

        if (!category.equals("refresh")) {
            return new ResponseEntity<>("invalid refresh token", HttpStatus.BAD_REQUEST);
        }

        // 저장소에 Refresh가 있는지 확인
        Boolean isExist = refreshRepository.existsByRefresh(refresh);
        if (!isExist) {
            return new ResponseEntity<>("invalid refresh token", HttpStatus.BAD_REQUEST);
        }

        // 모든 검증이 끝난 상태.
        String username = jwtUtil.getUsername(refresh);
        String role = jwtUtil.getRole(refresh);

        // 새로운 Access 토큰 생성
        String newAccess = jwtUtil.createJwt("access", username, role, 600000L);
        String newRefresh = jwtUtil.createJwt("refresh", username, role, 86400000L); // Refresh Rotate

        // 기존에 있던 Refresh 토큰 삭제하고 새 Refresh 토큰을 저장.
        refreshRepository.deleteByRefresh(refresh);
        addRefreshEntity(username, newRefresh, 86400000L);

        // 헤더에 새로운 Access 토큰 넣어서 상태 코드 응답.
        response.setHeader("access", newAccess);
        response.addCookie(createCookie("refresh", newRefresh));

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

    /**
     * Refresh 토큰을 저장소에 저장하는 로직
     */
    private void addRefreshEntity(String username, String refresh, Long expiredMs) {

        Date date = new Date(System.currentTimeMillis() + expiredMs);

        Refresh refreshEntity = new Refresh();
        refreshEntity.setUsername(username);
        refreshEntity.setRefresh(refresh);
        refreshEntity.setExpiration(date.toString());

        refreshRepository.save(refreshEntity);
    }

    private Cookie createCookie(String key, String value) {

        Cookie cookie = new Cookie(key, value);
        cookie.setMaxAge(24*60*60); // 쿠키 생명주기
        //cookie.setSecure(true); // https를 진행할 경우 true로 설정
        //cookie.setPath("/"); // 쿠키가 적용도리 범위 설정.
        cookie.setHttpOnly(true); // 클라이언트 단에서 JS로 해당 쿠키로 접근이 불가능하게 방어.

        return cookie;
    }
}

검증이 끝난 이후에 저장소에 RefreshToken이 있는지 확인해야 합니다.
Refresh Rotate를 통해서 새로운 Refresh 토큰을 만들어서 클라이언트 측에 전달합니다. 이때 새로운 Refresh 토큰을 저장소에 저장하고 기존에 저장되어 있던 Refresh 토큰을 삭제해야 합니다.


Refresh 토큰 저장소에서 기한이 지난 토큰 삭제
Refresh 토큰 저장소에서 기한이 지난 토큰이 쌓입니다. Redis와 같은 저장소는 TTL 설정을 통해 자동으로 삭제하지만 토큰이 쌓일 경우 용량 문제가 발생할 수 있습니다.
그래서 주기적으로 삭제가 필요합니다.






✅ 로그아웃

활성화되어 있는 JWT를 비활성화하여 탈취될 수 있는 시간을 조금이라도 줄이기 위해 필요합니다.

  • 로그아웃 버튼 클릭 시
    • 프론트 : 로컬 스토리지에 존재하는 Access 토큰 삭제 및 서버 측 로그아웃 경로로 Refresh 토큰 전송
    • 백엔드 : 로그아웃 로직을 구현하여 Refresh 토큰을 받아 초기화 후 Refresh DB에서 해당 Refresh 토큰 삭제 (여러 디바이스, 모든 계정에서 로그아웃 구현시 username 기반으로 모든 Refresh 토큰 삭제)

백엔드에서 로그아웃은 DB에 저장하고 있는 Refresh 토큰 삭제하고 Refresh 토큰 쿠키를 null로 변경합니다.

@RequiredArgsConstructor
public class CustomLogoutFilter extends GenericFilterBean {
    private final JWTUtil jwtUtil;
    private final RefreshRepository refreshRepository;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        doFilter((HttpServletRequest) request, (HttpServletResponse) response, chain);
    }

    private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {

        // 로그아웃인지 아닌지 여부
        String requestUri = request.getRequestURI();
        if (!requestUri.matches("^\\/logout$")) {
            filterChain.doFilter(request, response); // 로그아웃 경로가 아니라면 다음 필터로
            return;
        }

        // POST로 요청이 오지 않아도 다음 요청으로
        String requestMethod = request.getMethod();
        if (!requestMethod.equals("POST")) {
            filterChain.doFilter(request, response);
            return;
        }

        //get refresh token
        String refresh = null;
        Cookie[] cookies = request.getCookies();
        for (Cookie cookie : cookies) {
            if (cookie.getName().equals("refresh")) {
                refresh = cookie.getValue();
            }
        }

        //refresh null check
        if (refresh == null) {
            response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            return;
        }

        // 만료가 되었으면 이미 로그인 되지 않은 상태이기 때문에 추가적으로 로그아웃 작업을 진행하지 않고 BAD_REQUEST나 상태 메시지 남김.
        try {
            jwtUtil.isExpired(refresh);
        } catch (ExpiredJwtException e) {
            response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            return;
        }

        // 토큰이 refresh인지 확인 (발급시 페이로드에 명시)
        String category = jwtUtil.getCategory(refresh);
        if (!category.equals("refresh")) {
            response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            return;
        }

        //DB에 저장되어 있는지 확인
        Boolean isExist = refreshRepository.existsByRefresh(refresh);
        if (!isExist) {
            response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            return;
        }

        //로그아웃 진행
        //Refresh 토큰 DB에서 제거 -> 제거해야 refresh가 rotate되지 않는다.
        refreshRepository.deleteByRefresh(refresh);

        //Refresh 토큰 Cookie 값 0 -> 로그아웃
        Cookie cookie = new Cookie("refresh", null);
        cookie.setMaxAge(0);
        cookie.setPath("/");

        response.addCookie(cookie);
        response.setStatus(HttpServletResponse.SC_OK);
    }
}

이후 로그아웃을 SecurityConfig에 등록.

.addFilterBefore(new CustomLogoutFilter(jwtUtil, refreshRepository), LogoutFilter.class);

등록되어 있던 쿠키가 로그아웃 시에 사라짐을 볼 수 있습니다. DB에서도 Refresh 토큰이 제거가 된 것을 확인할 수 있습니다.






✅ 요청 IP PC 기반

PC의 경우 IP 주소가 변경될 일이 거의 없습니다. IP 주소가 변경되는 경우 요청이 거부되도록 진행할 수 있습니다.

  • 로그인 시 JWT 발급과 함께 JWT와 IP를 묶어서 하나의 DB 내부에 행으로 저장합니다.
  • Access 토큰으로 요청이 올때마다 기존 IP와 요청 IP를 대조합니다.
  • 다르다면 새로 로그인을 하라는 메시지를 보냅니다.
  • Access 토큰 재발급 시 새로운 Access 토큰과 IP를 DB 테이블에 저장합니다.

네이버의 경우 PC 환경에서 로그인을 진행 후 다른 IP 주소로 변경하면 재 로그인을 진행하라는 알림이 옵니다.






✅ 이해를 위한 추가 이미지

JWT 인증 과정


Refresh Token 인증 과정






Reference

🖇️ 개발자 유미

profile
일상의 인연에 감사하라. 기적은 의외로 가까운 곳에 있을지도 모른다.

0개의 댓글