ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Spring Security] AccessToken가 탈취되면 어떻게 하나요?
    Server/Spring Security 2023. 3. 18. 22:49

    이 글은 Spring Security에서 JWT 인증 방식(with Redis)으로 구현하면서 있었던 일을 정리한 글입니다.

    프로젝트에서 인증인가를 JWT로 AccessToken을 구현해야 했다. Http 통신 테스트로 인증인가가 잘 되는 것을 확인하고 안도의 한숨을 내쉬기 전에 팀원이 AccessToken이 탈취되면 어떻게 하냐고 질문해 왔다.

    그렇다. 해커가 탈취한 AccessToken으로 요청을 보냈을 때 서버에서 정상적인 유저인지 해커인지 판별할 수 없었다. 그렇다고 Stateless 한 방식을 버리고 다시 Session, Cookie로 인증인가를 해야 하는가에 대한 고민에 빠졌다.

    AccessToken이 탈취된다는 것은 네트워크 레이어 어딘가에서 해커가 HTTP 문을 탈취한다는 것인데, AccessToken가 탈취당했다는 것은 Session 인증방식에서 아이디, 비밀번호가 노출되는 것과 같다고 생각한다.
    그렇기 때문에 다음과 같이 해결했다.

    1. AccessToken 만료기간을 짧게 주고 RefreshToken을 서버에 저장한다.   
    2.AccessToken 만료기간이 짧아 매번 로그인 요청을 해야하는데 RefreshToken 만료기간이 유효하다면 로그인(인증) 없이 AccessToken을 발행 해준다.
    3.RefreshToken은 만료시간이 지나면 데이터 베이스에서 지워져야함.
    3-1.서비스에서 인증이 필요한 서비스를 사용할 때 마다 RefreshToken을 조회해야한다.
    4. 3,3-1번과 같은 작업 때문이라도 Redis로 대체 하자.

    Login

    로그인 결과로 RefreshToken도 리턴해준다.

    public TokenDto login(String studentId, String password) {
    
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(studentId, password);
    
            Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
    
            TokenDto accessToken = jwtTokenProvider.issueToken(authentication);
    
            TokenDto token = TokenDto.builder()
                    .grantType(accessToken.getGrantType())
                    .accessToken(accessToken.getAccessToken())
                    .refreshToken(issueRefreshToken(studentId))
                    .build();
            return token;
        }

    RefreshToken 발행

    RefreshToken DB에 저장할 때

       public String issueRefreshToken(String studentId) {
            RefreshToken token = refreshtokenRepository.save(
                    RefreshToken.builder()
                            .id(studentId)
                            .refreshToken(UUID.randomUUID().toString())
                            .expiration(2)
                            .build()
            );
            return token.getRefreshToken();
        }

    RefreshToken Entity

    @RedisHash("refreshToken")
    @Builder
    @AllArgsConstructor
    @NoArgsConstructor
    public class RefreshToken {
        @Id
        @JsonIgnore
        private String id;
        private String refreshToken;
    
        @TimeToLive(unit = TimeUnit.DAYS)
        private Integer expiration;
    
        public void setExpiration(Integer expiration) {
            this.expiration = expiration;
        }
    }

    TokenDto

    @Builder
    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    @ToString
    public class TokenDto {
        private String grantType;
        private String accessToken;
        private String refreshToken;
    }

    이제 로그인을 하면
    Login return에서 issueRefreshToken -> Redis에 {"id" : "refreshtoken"}으로 저장된다.


    이제 Refreshtoken을 잘 활용해 보자

    AccessToken이 만료되어을 때 RefreshToken이 유효하다면 로그인 없이 AccessToken을 돌려준다.
    그러면 AccessToken이 만료되어 RefreshToken을 확인할 때 RefreshToken을 갱신해줘야 한다. RefreshToken을 확인할 때마다 갱신을 한다면 서버의 부하가 더해진다. 그래서 RefreshToken을 갱신할 때 만료시간이 일정 시간보다 적을 때 갱신을 해주면 부하를 덜어줄 수 있다. 코드를 참고해 보자.

    AccessToken 재발급

        public TokenDto refreshAccessToken(TokenDto token) throws Exception {
            Claims claims = jwtTokenProvider.parseClaims(token.getAccessToken());
    
            Member member = memberRepository.findById(claims.getSubject()).orElseThrow(() ->
                    new RifCustomException(ErrorCode.ENTITY_INSTANCE_NOT_FOUND));
    
            RefreshToken refreshToken = validRefreshToken(member.getId(), token.getRefreshToken());
    
    
            if (refreshToken != null) {
                return TokenDto.builder()
                        .grantType("Bearer ")
                   .accessToken(jwtTokenProvider.issueToken(jwtTokenProvider.getAuthentication(token.getAccessToken())).getAccessToken())
                        .refreshToken(refreshToken.getRefreshToken())
                        .build();
            } else {
                throw new RifCustomException(ErrorCode.REFRESHTOKEN_EXPIRED);
            }
        }

    RefreshToken 유효성 체크

    일정 시간보더 적게 남으면 만료시간을 갱신해줘야 한다. 만료 시간만 바꿔주면 된다.

    public RefreshToken validRefreshToken(String studentId, String refreshToken) {
    
            RefreshToken token = refreshtokenRepository.findById(studentId).orElseThrow(() -> new RifCustomException(ErrorCode.REFRESHTOKEN_EXPIRED));
    
            log.info("ValidRefreshtoken info={}", token);
            if (token.getRefreshToken() == null) {
                return null;
            } else {
                // refreshtoken 1일 미만 남았을 때 요청하면 2일으로 초기화
                if (token.getExpiration() <= REISSUE_LIMIT_TIME) {
                    log.info("RefreshToken re-issue info={}", REISSUE_LIMIT_TIME);
                    token.setExpiration(2);
                    refreshtokenRepository.save(token);
                }
                //  Req토큰이 DB토큰과 같은지 비교
                if (!token.getRefreshToken().equals(refreshToken)) return null;
                else return token;
            }
        }

    이로써 AccessToken이 탈취되었을 때 RefreshToken으로 대응할 수 있었다. 인증을 공부하면서 느낀 점은 보안을 구현할 때 정답은 없고 서비스에 맞게 적절한 보안 수준을 유지하면 된다.

Designed by Tistory.