본문 바로가기
트러블 슈팅 및 도입기

삐삐 프로젝트 - jwt를 더 안전하게! RTR도입기

by ernest45 2023. 10. 10.

 

https://215-coding.tistory.com/42

 

[JWT] 토큰 인가 및 토큰 재발급 관련 트러블슈팅

📝 배경 본 프로젝트 (삐삐:Best Interior)에서는 서버가 클라이언트를 인증하는 방식 중 하나인 JWT를 이용하여 로그인 기능을 구현하기로 함 또한, 토큰은 로컬스토리지에 저장하는 방식을 택했으

215-coding.tistory.com

=먼저 프론트 분 담당자가 쓰신 글-

 

 

Refresh Token Access Token을 재발급할 때 사용하는 키이다

 

Access Token이 긴 만료 시간을 가지게 되면 탈취당했을 때 악의적인 공격속수무책일 수가 있다

 

그래서 Refresh Token을 많이들 도입한다. 

 

그러면 Access Token의 만료 기간을 짧게 유지하고,

상대적으로 긴 만료 시간을 가지는 Refresh Token을 통해서만

Access Token을 재발급 받음으로서 탈취 위험도 줄이고

사용자가 로그아웃 없이 긴 기간 로그인 상태를 유지할 수 있게 한다.

 

(창 닫고 다시가도 유지되는 사이트들은 refresh token을 쿠키로 구현해서이다!)

 

그렇다고

Refresh Token을 도입한다고 jwt가 무슨 무적이 되는 건 아니다.

 

 

마찬가지로 Refresh Token도 탈취에서 자유롭지 않다....

 

어기서 나온 개념이 바로 Refesh Token Rotation!

 

 

 

 

Refresh Token Rotation이란?

 

 

Refresh Token Rotation(RTR)은 Access Token이 만료될 때마다 Refresh Token도 함께 교체를 해주는 것이다.

쉽게 말하면 Refresh Token재사용 불가능하게 만들어 보안을 보장하는 방법이다! 

실제 코드로 흐름을 알아보자

 

 

 

 

일단 코드의 흐름은 이렇다.

 

  • 일반적으로는 인증 시 Access Token는 무조건! 보내게 함
  • 단, AccessToken이 만료된 경우에만 RefreshToken을 같이 담아서 보낸다.

 

 

 

if) 1. RefreshToken 없음

 

 

1-1. AccessToken이 유효한 경우

  • 인증 성공
  • RefreshToken은 재발급하지 않음

1-2. AccessToken이 없거나, 만료된 경우

  • 인증 실패
  • 403 Forbidden 응답 반환

 

 

if) 2. RefreshToken 있음 (RTR 방식)

  • DB에 저장된 RefreshToken과 일치하는 경우
    • AccessToken + RefreshToken 모두 재발급
    • 인증 처리까지는 하지 않고, 응답만 내려줌
      • (클라이언트는 새 토큰으로 다시 요청해야 함)

확인 후 재발급

  • DB에 저장된 RefreshToken과 다를 경우
    • 인증 실패
    • 403 Forbidden 응답

 

 

 

이렇게 구현할 경우

 

토큰 탈취 위험토큰 무효화 어려움을 보완할 수 있다
즉!
Refresh Token이 탈취되더라도 한 번 사용 후 무효화되니 공격자가 다시 사용할 수 없음

 

 

 

 

 

아쉬운 점 -

 

1. HttpOnly, Secure 쿠키 추가 

XSS(크로스 사이트 스크립팅): HttpOnly 쿠키는 자바스크립트 접근을 막아줘야 refresh Token의 탈취를 방지할 수 있는데

우리는 이런 대비를 생각하지 못하고 local storage에 저장했다..

 

당시에는 왜 쿠키에 저장 해야는지 둘 다 몰라서

 local storagecookie 중 프론트 분이 더 편한 곳으로 저장하셨다..

보안적으로 굉장히 큰 이슈였다

 

 

 

2. refresh Token 저장 위치

 

 

현재 refresh Token의 경우 

Member의 컬럼으로 넣어놓고 있다.

 

이때는 단순하고 직관적인 생각으로 하나의 쿼리만 발생하니까 별 이상 없다고 판단했다.

Member 테이블은 RDBMS 저장되며, Refresh Token 검증/업데이트 매번 DB 쿼리 실행하게 된다.

 

 DB는 특히 느린 저장소이고 사용자 수가 많아지면 요청도 훨씬 빈번해질 것!

JPA의 saveAndFlush는 트랜잭션 커밋을 강제하는 것도 추가 오버헤드다..

 

무엇보다 토큰 갱신에 관한 건

모든 요청에서 필수적으로 포함되기에 게시물 조회 등 다른  필수 기능도 느려질 수 있음

 

 

 

 

 

 

3. 프론트 분과 소통 오류

사실 담당자 분이 모종의 이유로 바뀌게 되었다.

 

기존에 설명했던 Refresh Token Rotation을 이해할 거라는 생각 하에 너무 두괄식으로 설명하다 보니

 

프론트 분께서 잘 이해하지 못하는 이슈로 시간을 꽤나 잡아먹었다.

 

사실 담당자가 바뀌면 더 자세히 소통 후 문서화해서 알려드렸어야 했는데...

 

이 부분이 너무 아쉬웠다

 

 

 

4. 403 에러

 

현재 acess Token의 만료거나 없을 경우에도 403이고,

또한 refresh Token의 정보가 만료 됐을 때도403이라 프론트 측에서는 헷갈릴 만할 거 같다.

 

좀 더 자세한 분기로 에러를 핸들링했다면 프론트 분이 error가 났을 때 디버깅하기 훨씬 편했을 거 같네 ㅠㅠ

커스텀 할만한 건 많이 했다고 생각했는데 엄청 부족했다..

 

-- 관련 내용에 대한 후회를 기록--

https://ernest45.tistory.com/42


 

 

결과적으로

 

이 때는 jwt라는 걸 쓴다는 자체에 심취해 있었고, 한창 개발에 새로운 것들을 배워나갈 때이다..

어떤 기술을 도입할 땐 단점과 장점을 제대로 알아보고, 단순히 "그냥" 이라기 보다

 

자세히 도입과정의 장단을 제대로 비교해보고 도입하자!

 

어디에도 완벽한 건 없다.. 얻는 게 있음 잃을 게 있는 법! 

 

 

 

 

 

 

 

 

@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationProcessingFilter extends OncePerRequestFilter {

    private static final String NO_CHECK_URL = "auth/login"; // "/login"으로 들어오는 요청은 Filter 작동 X

    private final JwtService jwtService;
    private final MemberRepository memberRepository;

    private GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper();

@Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        if (request.getRequestURI().equals(NO_CHECK_URL)) {
            filterChain.doFilter(request, response); // "/login" 요청이 들어오면, 다음 필터 호출
            return; // return으로 이후 현재 필터 진행 막기 (안해주면 아래로 내려가서 계속 필터 진행시킴)
            // 즉 로그인으로 들어오면 필터에서 막아버려서 진행 더 안되게 Filter 작동 X
        }

        // 사용자 요청 헤더에서 RefreshToken 추출
        // -> RefreshToken이 없거나 유효하지 않다면(DB에 저장된 RefreshToken과 다르다면) null을 반환
        // 사용자의 요청 헤더에 RefreshToken이 있는 경우는, AccessToken이 만료되어 요청한 경우밖에 없다.
        // 따라서, 위의 경우를 제외하면 추출한 refreshToken은 모두 null
        String refreshToken = jwtService.extractRefreshToken(request)
                .filter(jwtService::isTokenValid)
                .orElse(null);

        // 리프레시 토큰이 요청 헤더에 존재했다면, 사용자가 AccessToken이 만료되어서
        // RefreshToken까지 보낸 것이므로 리프레시 토큰이 DB의 리프레시 토큰과 일치하는지 판단 후,
        // 일치한다면 AccessToken을 재발급해준다.
        if (refreshToken != null) {
            checkRefreshTokenAndReIssueAccessToken(response, refreshToken);
            return; // RefreshToken을 보낸 경우에는 AccessToken을 재발급 하고 인증 처리는 하지 않게 하기위해 바로 return으로 필터 진행 막기
        }

        // RefreshToken이 없거나 유효하지 않다면, AccessToken을 검사하고 인증을 처리하는 로직 수행
        // AccessToken이 없거나 유효하지 않다면, 인증 객체가 담기지 않은 상태로 다음 필터로 넘어가기 때문에 403 에러 발생
        // AccessToken이 유효하다면, 인증 객체가 담긴 상태로 다음 필터로 넘어가기 때문에 인증 성공
        if (refreshToken == null) {
            checkAccessTokenAndAuthentication(request, response, filterChain);
        }
    }

   
    public void checkRefreshTokenAndReIssueAccessToken(HttpServletResponse response, String refreshToken)  {
        memberRepository.findByRefreshToken(refreshToken)
                .ifPresent(member -> {
                    String reIssuedRefreshToken = reIssueRefreshToken(member);
                    try {
                        jwtService.sendAccessAndRefreshToken(response, jwtService.createAccessToken(member.getEmail()),
                                reIssuedRefreshToken, member.getMemberId(),member.getProfileImg(),member.getNickname());
                    } catch (IOException e) {
                        throw new RuntimeException("입출력 오류입니다", e);
                    }
                });
    }

   
    private String reIssueRefreshToken(Member member) {
        String reIssuedRefreshToken = jwtService.createRefreshToken();
        member.updateRefreshToken(reIssuedRefreshToken);
        memberRepository.saveAndFlush(member);
        return reIssuedRefreshToken;
    }

    
    public void checkAccessTokenAndAuthentication(HttpServletRequest request, HttpServletResponse response,
                                                  FilterChain filterChain) throws ServletException, IOException {
        log.info("checkAccessTokenAndAuthentication() 호출");
        jwtService.extractAccessToken(request)
                .filter(jwtService::isTokenValid)
                .ifPresent(accessToken -> jwtService.extractEmail(accessToken)
                        .ifPresent(email -> memberRepository.findByEmail(email)
                                .ifPresent(this::saveAuthentication)));

        filterChain.doFilter(request, response);
    }

    
    public void saveAuthentication(Member member) {
        String password = member.getPassword();
        if (password == null) { // 소셜 로그인 유저는 비밀번호 임의로 설정 하여 소셜 로그인 유저도 인증 되도록 설정
            password = PasswordUtil.generateRandomPassword();

        }

//        UserDetails userDetailsUser = org.springframework.security.core.userdetails.User.builder()
//                .username(member.getEmail())
//                .password(password)
//                .roles(member.getRole().name())
//                .build();

        CustomJwtUserDetails userDetailsUser = new CustomJwtUserDetails( //되면 수정
                member.getMemberId(),
                member.getEmail(),
                member.getPassword(), // 비밀번호는 null
                member.getRole(),
                true, // checkUser 값은 true
                member.getProfileImg(),
                member.getNickname()
        );

        Authentication authentication =
                new UsernamePasswordAuthenticationToken(userDetailsUser, null,
                        authoritiesMapper.mapAuthorities(userDetailsUser.getAuthorities()));

        SecurityContextHolder.getContext().setAuthentication(authentication);
    }