JWT 보안에 관한 고찰

wish17·2023년 5월 3일
0
post-thumbnail

고민의 시작

아래와 같이 로그인 요청 시 클라이언트로 부터 받은 이메일과 비밀번호가 DB에 저장된 사용자 정보와 일치하는지 비교하고 올바른 사용자면 Access Token(이하 ATK)과 Refresh Token(이하 RTK)을 생성해 클라이언트에 전달해주고 토큰 노출에 따른 위험부담을 모두 사용자가 갖도록 인증처리과정을 만들었다.

너무나도 무책임한 방식이 아닐까? 아무리 서버의 부담을 줄일 수 있다고는 하지만 최소한은 안전고리를 만들어보고 싶다는 생각이 들었다.
(카드 잃어버린건 고객 잘못이라도 카드사가 최소한 카드정지는 도와줘야지...)

// 로그인 인증 요청을 처리하는 Custom Security Filter 구현
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {  // 디폴트 Security Filter인 UsernamePasswordAuthenticationFilter를 확장해서 구현
    private final AuthenticationManager authenticationManager;
    // DI 받은 AuthenticationManager는 로그인 인증 정보(Username/Password)를 전달받아 UserDetailsService와 인터랙션 한 뒤 인증 여부를 판단
    private final JwtTokenizer jwtTokenizer;
    // DI 받은 JwtTokenizer는 클라이언트가 인증에 성공할 경우, JWT를 생성 및 발급하는 역할

    public JwtAuthenticationFilter(AuthenticationManager authenticationManager, JwtTokenizer jwtTokenizer) {
        this.authenticationManager = authenticationManager;
        this.jwtTokenizer = jwtTokenizer;
    }

    @SneakyThrows // 예외처리 무시
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) { // 인증을 시도하는 로직을 구현
        ObjectMapper objectMapper = new ObjectMapper();
        LoginDto loginDto = objectMapper.readValue(request.getInputStream(), LoginDto.class); // 역직렬화(Deserialization)

        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(loginDto.getEmail(), loginDto.getPassword()); // Username과 Password 정보를 포함한 UsernamePasswordAuthenticationToken 생성

        return authenticationManager.authenticate(authenticationToken);  // UsernamePasswordAuthenticationToken을 AuthenticationManager에게 전달하면서 인증 처리를 위임
    }

    // 인증에 성공할 경우 (Spring Security에서 자동으로) 호출되는 메서드
    @Override
    protected void successfulAuthentication(HttpServletRequest request,
                                            HttpServletResponse response,
                                            FilterChain chain,
                                            Authentication authResult) throws ServletException, IOException {
        Member member = (Member) authResult.getPrincipal();  // 인증 정보로 Member 엔티티 객체 만들기

        String accessToken = delegateAccessToken(member);   // Access Token 생성
        String refreshToken = delegateRefreshToken(member); // Refresh Token 생성

        response.setHeader("Authorization", "WishJWT " + accessToken);  // 클리이언트한테 Access Token 보내주기 (이후에 클라이언트 측에서 백엔드 애플리케이션 측에 요청을 보낼 때마다 request header에 추가해서 클라이언트 측의 자격을 증명하는데 사용)
        response.setHeader("Refresh", "WishJWT " + refreshToken);                   // 클리이언트한테 Refresh Token 보내주기

        this.getSuccessHandler().onAuthenticationSuccess(request, response, authResult);  // MemberAuthenticationSuccessHandler의 onAuthenticationSuccess() 메서드 호출
        // 인증 성공 후에 할 동작을 설정해둔걸 불러와서 수행
        // 인증 실패 할 경우 MemberAuthenticationFailureHandler클래스의 onAuthenticationFailure() 메서드는 코드 추가 없이도 알아서 호출된다.
    }

    // Access Token을 생성하는 구체적인 로직
    private String delegateAccessToken(Member member) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("email", member.getEmail());
        claims.put("roles", member.getRoles());
        claims.put("nickName", member.getNickName());

        String subject = member.getEmail();
        Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getAccessTokenExpirationMinutes());

        String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());

        String accessToken = jwtTokenizer.generateAccessToken(claims, subject, expiration, base64EncodedSecretKey);

        return accessToken;
    }

    // Refresh Token을 생성하는 구체적인 로직
    private String delegateRefreshToken(Member member) {
        String subject = member.getEmail();
        Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getRefreshTokenExpirationMinutes());
        String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());

        String refreshToken = jwtTokenizer.generateRefreshToken(subject, expiration, base64EncodedSecretKey);

        return refreshToken;
    }
}
@RestController
@RequestMapping("/refresh")
@AllArgsConstructor
public class RefreshController {
    private final JwtTokenizer jwtTokenizer;
    private final MemberRepository memberRepository;
    @PostMapping
    public ResponseEntity<String> refreshAccessToken(HttpServletRequest request) { // 리프레쉬 토큰 받으면 엑세스 토큰 재발급
        String refreshTokenHeader = request.getHeader("Refresh");
        if (refreshTokenHeader != null && refreshTokenHeader.startsWith("Bearer ")) {
            String refreshToken = refreshTokenHeader.substring(7);
            try {
                Jws<Claims> claims = jwtTokenizer.getClaims(refreshToken, jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey()));

                String email = claims.getBody().getSubject();
                Optional<Member> optionalMember = memberRepository.findByEmail(email);

                if (optionalMember.isPresent()) {
                    Member member = optionalMember.get();
                    String accessToken = delegateAccessToken(member);

                    return ResponseEntity.ok().header("Authorization", "Bearer " + accessToken).body("Access token refreshed");
                } else {
                    return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid member email");
                }
            } catch (JwtException e) {
                return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid refresh token");
            }
        } else {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Missing refresh token");
        }
    }

    private String delegateAccessToken(Member member) { // 코드의 중복...찝찝하다.
        Map<String, Object> claims = new HashMap<>();
        claims.put("email", member.getEmail());
        claims.put("roles", member.getRoles());
        claims.put("nickName", member.getNickName());

        String subject = member.getEmail();
        Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getAccessTokenExpirationMinutes());

        String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());

        String accessToken = jwtTokenizer.generateAccessToken(claims, subject, expiration, base64EncodedSecretKey);

        return accessToken;
    }
}

보안을 향상시키는 방법

1. ATK의 유효시간을 짧게 설정한다.

아주 기본적인 방법이다. 이부분은 기본이고 ATK를 재발급할 수 있는 RTK에 대한 문제점이 남는 아쉬운 방법이다. RTK의 유효시간을 지나치게 짧게 만들면 본래 목적(ATK를 쉽게 재발급)을 잃어버리기 때문에 적절하지 못한 방법이다. 따라서 여전히 RTK를 탈취당하면 너무 위험하다.

2. ATK 블랙리스트

발급하는 ATK를 서버의 메모리나 DB에 저장해 관리하며 ATK를 검증하거나 특정상황(로그아웃 등)에 강제로 만료시켜버려서 보안을 더 향상시킬 수 있을 것 같다.
하지만 이 방법은 결국 서버의 부담을 증가시켜 차라리 세션인증 방식을 사용하는게 더 좋아보인다.

3. Refresh Token을 서버에서 관리한다.

서버의 메모리나 DB에 RTK를 저장해 관리한다.
이렇게 하면 매번 요청마다 검증하는게 아니라 ATK가 만료될 때만 RTK 검증과정을 진행하기 때문에 세션인증 방식보다 서버의 부담도 덜하며 토큰인증 방식의 장점인 사용자 편의성도 그대로 챙길 수 있을 것 같다.

  • 특정 상황에 RTK를 만료시켜버리거나 추가 검증을 하는 등 서버에서 RTK에 관한 로직을 사용할 수 있게 된다.

Refresh Token 저장위치

그렇다면 어디에 저장하는게 좋을까?

1. 서버 메모리

장점

  • 속도

    • 서버 메모리에 직접 저장하면 빠른 액세스가 가능하며, 읽기 및 쓰기 작업이 매우 빠르다.
  • 간단한 구현

    • 외부 데이터베이스나 캐시 시스템을 사용하지 않기 때문에, 구현 및 관리가 간단하다.

단점

  • 영속성

    • 서버 메모리에 저장된 데이터는 서버가 다운되거나 재시작되면 손실된다. 이로 인해 RTK가 사라질 수 있으며 사용자는 다시 로그인해야 한다.
  • 확장성

    • 서버 메모리에 저장하는 방식은 클러스터링이나 로드 밸런싱과 같은 확장성 있는 구조를 구현하기 어렵다.
      • 여러 서버 인스턴스가 동일한 RTK를 공유할 수 없기 때문
  • 메모리 관리

    • 서버 메모리에 데이터를 저장하면, 메모리 사용량이 증가하고 가용 메모리에 제한이 생긴다. 이로 인해 메모리 부족 문제가 발생할 수 있다.
    • 사용자가 많아지면 서버의 부담이 심해지며 높은 스펙의 서버를 요구하게 된다.

이 방법은 위와 같은 단점들 때문에 적절한 방법은 아니다.
(이럴거면 세션인증방식을 쓰는게 더 좋아보인다.)

2. Redis

장점

  • 속도
    • Redis는 인메모리 데이터베이스로서, 읽기 및 쓰기 작업이 빠르다. 이를 통해 RTK에 대한 빠른 액세스가 가능하다.
  • 확장성
    • Redis는 클러스터를 통해 쉽게 확장할 수 있어 대규모 애플리케이션에서 효율적으로 작동한다.
  • 만료기능
    • Redis는 키-값에 대해 TTL(Time to Live) 설정이 가능해 RTK의 만료 시간을 자동으로 관리할 수 있다.

단점

  • 영속성
    • Redis는 인메모리 데이터베이스이므로, 시스템이 다운될 경우 데이터 손실이 발생할 수 있다.
      • 그러나 RDB나 AOF 옵션을 통해 일정 주기로 디스크에 저장하는 방식으로 문제를 완화할 수 있다.
      • 그리고 최악의 경우 데이터 손실이 발생한다고 해도 로그아웃되는 정도의 일 밖에 일어나지 않는다.
  • 비용
    • 인메모리 데이터베이스는 일반적으로 디스크 기반 데이터베이스보다 비용이 높다.

3. DB(MySQL)

장점

  • 영속성
    • MySQL은 디스크 기반 데이터베이스로서, 데이터의 영속성이 뛰어나다.
    • 시스템 다운 시 데이터 손실 위험이 적다.
  • 관계형 데이터베이스
    • MySQL은 관계형 데이터베이스로서, 데이터 구조화와 무결성 관리가 용이하다.

단점

  • 속도
    • MySQL은 디스크 기반 데이터베이스로서, Redis와 비교했을 때 상대적으로 읽기 및 쓰기 작업이 느리다.
  • 확장성
    • MySQL은 클러스터링이나 샤딩을 통한 수평 확장이 가능하지만, Redis에 비해 복잡하고 관리가 어려울 수 있다.

결론

영속성과 무결성에 더 중점을 두는 경우 Redis보다 MySQL이 더 좋은 성택지가 될 수 있지만 Redis에 저장할 경우 최악의 경우의 수를 고려해봐도 전체 로그아웃이 될 뿐이다. 때문에 Redis의 장점(속도와 확장성)으로 얻을 수 있는 이득이 더 많다고 판단된다.

기존(기본) 방식에서는 발급시에 설정해둔 만료기간에 따른 만료 외에는 서버측에서 RTK에 대한 추가 검증이나 강제 만료를 못하기 때문에 RTK에 대한 드리블을 서버에서 할 수 있도록 하기 위해 RTK를 Redis에 저장해봐야겠다.

0개의 댓글