빙응의 공부 블로그

[Spring REST API]로그인 구현하기 - Refresh Token 보안 강화 본문

Spring/개인공부_실습

[Spring REST API]로그인 구현하기 - Refresh Token 보안 강화

빙응이 2024. 6. 6. 17:30

📝 개요

저번 포스팅까지 Access Token과 Refresh Token 두개를 발급하고 생명주기가 끝난 후 다시 발급하는 것까지 해보았다.

[Spring REST API]로그인 구현하기 - 2개의 토큰 (tistory.com)

 

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

📝 개요 - 단일 토큰을 왜 안쓰는가?바로 전 포스팅에서 Access Token을 이용한 JWT 인증을 성공적으로 사용해보았다.[Spring REST API]로그인 구현하기 - JWT + Security (tistory.com) [Spring REST API]로그인 구현

quddnd.tistory.com

이번에는 보다 안전하게 Refresh Token을 관리하는 법에 대해 알아보자!!
1. Refresh Rotate
2. Refresh Token 서버 주도권
3. 로그아웃
4. 더 강화 기능

📝 Refresh Rotate 

Refresh Rotate란?
저번 시간에 우리는 Refresh 토큰을 받아 Access 토큰을 갱신해 보았다.
Refresh Rotate는 Access 토큰 갱신 대 보안을 위해 Refresh 토큰도 갱신하는 것이다.

🚩 왜 그리 번거롭게 함?

그 이유는 크게 2가지가 있다.

  • Refresh 토큰 교체로 보안성 강화
    • 전에 탈취 당해도 새로 바뀌기 때문에 이것을 해결할 수 있어용
  • 로그인 지속 시간이 길어져 사용감 향상

 

구현 방법은 쉽다 저번에 만들었던 Refresh 토큰을 이용한 Access 토큰 재발급에서 로직만 추가하면 된다.

@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);
    }
수정 후
    @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);
        String newRefresh = jwtTokenProvider.createRefreshToken("refresh", userName);
        //response
        response.setHeader("access", newAccess);
        response.addCookie(createCookie("refresh", newRefresh));
        return new ResponseEntity<>(HttpStatus.OK);
    }

사실 그냥 Access 토큰만 만들어서 보내줬던 걸 Refresh 토큰도 만들어서 보내주면 된다.

        //make new JWT
        String newAccess = jwtTokenProvider.createAccessToken("access", userName);
        String newRefresh = jwtTokenProvider.createRefreshToken("refresh", userName);
        //response
        response.setHeader("access", newAccess);
        response.addCookie(createCookie("refresh", newRefresh));
        return new ResponseEntity<>(HttpStatus.OK);

변한 부분은 이 부분 밖에 없으나 이 행위로 인해 더 확실한 보안을 얻게 되는 것이다.

 

근데 아직 해야할 것이 있다. 

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

이것이 바로 서버측에서 토큰을 관리하기 위해 DB에 저장하는 것이다. 

 

📝 Refresh 토큰 서버 주도권과 서버 측 저장

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

 

JWT를 탈취하여 서버측으로 접근할 경우 JWT가 만료되기까지 서버측에서는 아무것도 막을 수 없으며,

프론트 측에서 토큰을 삭제하는 로그아웃을 구현해도 이미 복제되어 피해를 입을 수 있다.

 

이러한 문제를 해결하기 위해 생명주기가 긴 Refresh 토큰을 발급 시 서버측에 저장하고 저장된 Refresh 토큰만

사용할 수 있도록 서브측에서 주도권을 가질 수 있게 할 수 있다. 

 

1. 저장을 위한 Refresh Entity 정의

간단하게 정의하자

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

    private String username;
    private String refresh;
    private String expiration;
}
2. JPA를 사용하기 위한 RefreshRepository 구현
public interface RefreshRepository extends JpaRepository<RefreshEntity, Long> {

    Boolean existsByRefresh(String refresh); //존재하는지 확인

    @Transactional
    void deleteByRefresh(String refresh);
}
3. LoginFilter의 successfulAuthentication() 수정하기

 

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) {
        String userName = authentication.getName();
        //토큰 생성
        String AccessToken = jwtTokenProvider.createAccessToken("access",userName);
        String RefreshToken = jwtTokenProvider.createRefreshToken("refresh",userName);
        
        //Refresh 토큰 저장
        addRefresh(userName, RefreshToken, RefreshTokenRemiteTime);
        
        //응답 설정
        response.setHeader("access", AccessToken);
        response.addCookie(createCookie("refresh", RefreshToken));
        response.setStatus(HttpStatus.OK.value());
    }

기존 코드에 Refresh 토큰 저장만 해주면 된다.

    private void addRefresh(String userName, String refresh, Long expired){
        Date date = new Date(System.currentTimeMillis() + expired);
        Refresh refreshEntity = Refresh.builder()
            .username(userName)
            .refresh(refresh)
            .expiration(date.toString())
            .build();
        refreshRepository.save(refreshEntity);
    }
4. Refresh 토큰으로 Access 토큰 재발급 시 Refresh도 다시 만들게 수정하기 

여기도 2개의 기능만 넣어주면 된다.

  • DB에 Refresh 토큰이 있는지 확인하는 것
  • Refresh 토큰 재발급 후 저장 
    @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);
        }
        //DB에 저장되어 있는지 확인=====================================================
        Boolean isExist = refreshService.existsByRefresh(refresh);
        if (!isExist) {

            throw new AppException(AuthErrorCode.INVALID_REFRESH_TOKEN);
        }
        
        String userName = jwtTokenProvider.getUsername(refresh);
        //=============================================================================
        //make new JWT
        String newAccess = jwtTokenProvider.createAccessToken("access", userName);
        String newRefresh = jwtTokenProvider.createRefreshToken("refresh", userName);
        
        //Refresh 토큰 저장 DB에 기존의 Refresh 토큰 삭제 후 새 Refresh 토큰 저장=========
        refreshService.deleteByRefresh(refresh);
        refreshService.addRefresh(userName, newRefresh);
        //=============================================================================
        
        //response
        response.setHeader("access", newAccess);
        response.addCookie(cookieStore.createCookie("refresh", newRefresh));
        return new ResponseEntity<>(HttpStatus.OK);
    }

추가된 부분은 =======으로 줄쳐진 부분이다. 서비스는 간단하게 만들었다.

@Service
@RequiredArgsConstructor
public class RefreshService {
    private final RefreshRepository refreshRepository;
    private final long RefreshTokenRemiteTime = 1000L * 60 * 60 * 24; // 1일
    
    //새로운 Refresh 토큰 생성해서 저장 
    public void addRefresh(String userName, String refresh){
        Date date = new Date(System.currentTimeMillis() + RefreshTokenRemiteTime);
        Refresh refreshEntity = Refresh.builder()
            .username(userName)
            .refresh(refresh)
            .expiration(date.toString())
            .build();
        refreshRepository.save(refreshEntity);
    }
    //저장소에서 Refresh 토큰이 있는지 검사
    public Boolean existsByRefresh(String refresh) {
        return refreshRepository.existsByRefresh(refresh);
    }
    public void deleteByRefresh(String refresh) {
        refreshRepository.deleteByRefresh(refresh);
    }
}

 

🚩 토큰 관리에 좋은 저장소 Redis 

JWT 관리의 대표격인 Redis는 생명주기가 지나면 자동으로 삭제해주는 로직이 있어 사용하기 좋다!

현업에서 Redis로 많이 구현하니 공부하도록 하자 

 

 

📝 로그아웃 시 Refresh 토큰 처리 

로그아웃 기능을 통해 추가적인 JWT 탈취 시간을 줄일 수 있다.

  • 로그아웃 버튼 클릭시
    • 프론트엔드 : 로컬 스토리지에 존재하는 Access 토큰 삭제 및 서버 측 로그아웃 경로로 Refresh 토큰 전송
    • 백엔드 : 로그아웃 로직을 추가하여 Refresh 토큰을 받아 쿠키 초기화 후 Refresh DB에서 해당 Refresh 삭제

 

백엔드에서 구현해야하는 작업
  1.  DB에 저장하고 있는 Refresh 토큰 삭제
  2. Refresh 토큰 쿠기 null로 변경
1. 스프링 시큐리티 로그아웃 필터 구현
public class CustomLogoutFilter extends GenericFilterBean

해당 구문처럼 extend를 받으면 필터를 구현할 수 있다. 

public class CustomLogoutFilter extends GenericFilterBean {
    private final JwtTokenProvider jwtTokenProvider;
    private final RefreshService refreshService;
    public CustomLogoutFilter(JwtTokenProvider jwtTokenProvider, RefreshService refreshService) {
        this.jwtTokenProvider = jwtTokenProvider;
        this.refreshService = refreshService;
    }
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;

        //logout 요청인지 확인
        String requestUri = request.getRequestURI();
        if (!requestUri.matches("^\\/logout$")) {

            filterChain.doFilter(request, response);
            return;
        }
        String requestMethod = request.getMethod();
        if (!requestMethod.equals("POST")) {

            filterChain.doFilter(request, response);
            return;
        }

        //Refresh 토큰 받기
        String refresh = null;
        Cookie[] cookies = request.getCookies();
        for (Cookie cookie : cookies) {

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

                refresh = cookie.getValue();
            }
        }

        //Refresh 토큰 null 체크
        if (refresh == null) {
            throw new AppException(AuthErrorCode.INVALID_REFRESH_TOKEN);
        }

        //유효 기간 확인
        try {
            jwtTokenProvider.isExpired(refresh);
        } catch (ExpiredJwtException e) {

            throw new AppException(AuthErrorCode.INVALID_REFRESH_TOKEN);
        }

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

            throw new AppException(AuthErrorCode.INVALID_REFRESH_TOKEN);
        }

        //DB에 저장되어 있는지 확인
        Boolean isExist = refreshService.existsByRefresh(refresh);
        if (!isExist) {

            throw new AppException(AuthErrorCode.INVALID_REFRESH_TOKEN);
        }

        //로그아웃 진행
        //Refresh 토큰 DB에서 제거
        refreshService.deleteByRefresh(refresh);

        //Refresh 토큰 Cookie 값 0
        Cookie cookie = new Cookie("refresh", null);
        cookie.setMaxAge(0);
        cookie.setPath("/");

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

    }
}

해당 doFilter는 다음 과정을 진행한다.

  1.  Filter이기 때문에 모든 요청에 대해 Uri가 /logout 인지 검사
  2.  HTTP 메소드가 POST인지 검사
  3.  해당 요청에서 쿠키로  Refresh 토큰을 얻음
  4.  Refresh 토큰의 null과 유효기간 체크
  5.  토큰이 Refresh인지 확인
  6.  Refresh 토큰이 DB에 저장되었는지 확인
  7.  로그아웃 진행(Refresh 토큰 DB에서 제거 -> 쿠키 Null 값으로 만듦)

 

마지막으로 만든 필터를 SecurityConfig에 올리면 끝이다.

    http
        .addFilterBefore(new CustomLogoutFilter(jwtTokenProvider, refreshService), LogoutFilter.class);

📝 마무리

이번에는 Refresh 토큰의 보안을 위한 과정을 알아보았다.

Refresh Rotate를 통해 보안을 강화하고 이것을 활용하기 위해 DB에 저장, 또한 logout 시 보안도 강화해보았다.

 

이정도도 충분한 보안이지만 네이버 같은 사이트처럼 IP와 Refresh를 같이 저장하여 IP가 바뀌면 재로그인하게 만드는 것도 괜찮은 방법이라고 한다. 

 

이제 끝