spring security jwt 사용하기(access token, refresh token)

박도영·2022년 3월 5일
7

security+jwt+redis

목록 보기
1/3
post-custom-banner

jwt
로그인시 jwt를 사용해서 인증과정을 구현해보려고 합니다. jwt 발급 및 검증 과정을 spring의 filter 레벨에서 구현하기 위해 spring security를 사용했습니다. 그리고 jwt의 access token과 더블어 refresh token까지 사용해서 구현해보겠습니다.

refresh token을 사용한 이유

  • jwt access token은 기본적으로 stateless하다. -> 누군가가 token을 탈취해서 사용한다면 알 수 있는 방법이 없다.
  • 이 문제를 access token의 만료기간을 짧게 하는 방식으로 해결
    • 그 만큼 사용자는 access token을 재발급 받아야 함 -> 사용자 편의성 감소
  • 이를 해결하고자 refresh token을 만들고 그것으로 access token을 재발급 해준다.
    • refresh token은 access token이 만료되었을때만 사용하기 때문에 만료기간이 길고 탈취당할 위험이 적다.

물론 refresh token도 완벽한 것은 아닙니다. 탈취 당할 위험이 있고 탈취 당했을 경우에 공격자가 access token을 (만료 기간 내에) 계속해서 발급할 수 있습니다. 이러한 문제는 jwt의 stateless한 특성에서 시작된다고 생각합니다. 서버는 jwt를 자신이 만들것인지, 만료 기간이 안지났는지 밖에 판단할 수 없기 때문입니다. 따라서 refresh token을 발급 한 경우 그것을 저장하고, 그것으로 access token을 재발급할 경우 저장된 내용과 비교해서 검증하는 로직을 추가할 수 있습니다. 또한 refresh token을 사용할 때마다 새로운 token을 만들어준다면 공격자가 훔친 refresh token이 무의미해질 수 있습니다.

즉, refresh token을 발급해주고 그것을 저장합니다. refresh token으로 access token을 재발급할 경우 저장된 것과 비교하고 맞다면 refresh token과 access token 모두를 재발급해줍니다. 재발급한 refresh token은 다시 갱신되서 저장됩니다.

(refresh token 이하 ref)
이렇게 한다면 클라이언트->서버 요청에 담긴 ref는 공격자가 훔쳐가도 그것을 사용할 수 없습니다. 서버->클라이언트에 담긴 ref는 여전히 훔쳐가도 유효하겠지만 공격자가 그것을 사용한다면 저장된 ref가 변하므로 사용자가 ref를 사용했을때 ref가 인터셉트 당했음을 알 수 있습니다. 이로서 다른 조치를 취할 수 있습니다.(ref 삭제, 재로그인 등..)

토큰 검증

// JwtTokenProvider 


    public JwtCode validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return JwtCode.ACCESS;
        } catch (ExpiredJwtException e){
            // 만료된 경우에는 refresh token을 확인하기 위해
            return JwtCode.EXPIRED; // 따로 만들어준 enum
        } catch (JwtException | IllegalArgumentException e) {
            log.info("jwtException : {}", e);
        }
        return JwtCode.DENIED;
    }

jwt를 생성하고 검증하는 클래스의 일부입니다. 전체 코드는 깃헙을 참고해주세요. 먼저 validateToken은 token이 유효한지 검사합니다. 만약 기간이 만료되어 ExpiredJwtException이 나온다면 따로 만료 표시를 해줍니다

refresh token 발행 및 디비에 저장

//JwtTokenProvider

     @Transactional
    public String reissueRefreshToken(String refreshToken) throws RuntimeException{
        // refresh token을 디비의 그것과 비교해보기
        Authentication authentication = getAuthentication(refreshToken);
        RefreshToken findRefreshToken = refreshTokenRepository.findByUserId(authentication.getName())
                .orElseThrow(() -> new UsernameNotFoundException("userId : " + authentication.getName() + " was not found"));

        if(findRefreshToken.getToken().equals(refreshToken)){
            // 새로운거 생성
            String newRefreshToken = createRefreshToken(authentication);
            findRefreshToken.changeToken(newRefreshToken);
            return newRefreshToken;
        }
        else {
            log.info("refresh 토큰이 일치하지 않습니다. ");
            return null;
        }
    }

    @Transactional
    public String issueRefreshToken(Authentication authentication){
        String newRefreshToken = createRefreshToken(authentication);

        // 기존것이 있다면 바꿔주고, 없다면 만들어줌
        refreshTokenRepository.findByUserId(authentication.getName())
                .ifPresentOrElse(
                        r-> {r.changeToken(newRefreshToken);
        log.info("issueRefreshToken method | change token ");
                                            },
                        () -> {
                            RefreshToken token = RefreshToken.createToken(authentication.getName(), newRefreshToken);
                            log.info(" issueRefreshToken method | save tokenID : {}, token : {}", token.getUserId(), token.getToken());
                            refreshTokenRepository.save(token);
                        });

        return newRefreshToken;
    }
    
    

refresh 토큰을 디비에서 확인하고 발행해주는 로직입니다. 제 생각에 문제점은 트랜잭션이 필터에서 열린다는 것입니다. 인증처리를 security를 사용해서 필터에서 처리하고 비지니스 로직은 service레이어에서 처리하는게 깔끔한 설계라고 생각했습니다. 하지만 트랜잭션을 너무 두번 사용하게 되는 문제가 발생합니다. ( 물론, 현재는 관계없이 잘 돌아갑니다. ) OSIV문제도 트랜잭션을 너무 길게 잡아서 문제가 되는데 이처럼 트랜잭션을 필터에서 사용하는 것도 좋은 설계라고 생각되지는 않습니다.

이 문제는 디비 접근을 service 레이어에서 해주거나, 레디스와 같은 메모리 기반의 데이터베이스를 사용하는 것도 방법이 될 것 같습니다.
따라서 다음에는 레디스를 사용하는 쪽으로 리펙토링해 볼 생각입니다.

전체 코드

참고자료 :
[SpringBoot] Spring Security 처리 과정 및 구현 예제
https://github.com/murraco/spring-boot-jwt
https://github.com/szerhusenBC/jwt-spring-security-demo
https://kukekyakya.tistory.com/entry/Spring-boot-access-token-refresh-token-발급받기jwt

profile
좋은 개발자란?
post-custom-banner

1개의 댓글

comment-user-thumbnail
2023년 1월 21일

정말 유용합니다, 감사합니다^^

답글 달기