개발일지: 인증(2)

동준·2024년 6월 22일
0

개발일지

목록 보기
3/7

인증 방식 구체화

인증 방식을 JWT로 정한 이상, 어떻게 토큰을 관리할 것인지가 주요 고민이 됐다. 인증 자체는 JWT의 알고리즘을 통한 서명 해석으로 정보를 확인하므로 개발자인 내 입장에서는 토큰이 공격자에게 누출되지 않게 하는 것과, 토큰이 누출되었을 때의 대책을 생각하는 것이었다.

프론트엔드? 백엔드?

세션 방식과 비교했을 때 JWT의 장점은 서버의 부담을 덜어주는 것이다. 토큰 관리를 클라이언트에게 위임해서 서버는 요청에 실려 들어온 토큰을 그저 해석만 해서 유효한지 판별하는 거라서 토큰의 보관을 브라우저의 웹 스토리지(쿠키, 로컬 스토리지 등)에 보관하는 것이 일반적이다.

1) 보편적인 방식으로 클라이언트에 보관

브라우저에서 보관하는 것은 서버 대비 공격자에게 탈취당하기 쉬운 구조이고, 그렇기 때문에 토큰의 유효시간을 설정하되 보통은 짧게 설정하는 편이다. 다만 이렇게 되면 공격자에게 탈취당해도 토큰이 악용될 염려는 줄어들지만 그만큼 사용자의 사용 경험이 불편해진다. 그래서 통상의 토큰을 엑세스토큰으로, 재인증의 수단으로 리프레쉬토큰으로 분화해서 처리한다.

즉, 실질적인 인증의 책임은 리프레쉬토큰이 가지는 것이기 때문에 이 리프레쉬토큰의 보관 및 처리에 관하여 고민하는 것이 핵심이었다.

나는 여기서 리프레쉬 토큰을 단순히 프론트엔드에게 위임해서 쿠키에 보안 세팅을 가하는 것만으로는 부족하다고 생각했다. JWT에서의 이상적인 보안은, 클라이언트는 엑세스토큰만을 지니고 있고 서버가 리프레쉬토큰을 관리해서 클라이언트는 리프레쉬토큰의 존재를 모르게 하는 것이라고 생각했다. 물론 이 방식은 기존 JWT의 장점인 서버의 부담 완화 측면이 조금 퇴색되지만 내 나름의 생각은 이러했다.

2) 서버 역시 책임을 분담

나의 결론을 정리하자면 다음과 같다.

엑세스 토큰리프레쉬 토큰
보관클라이언트서버
인증 유효시간짧게(1시간)길게(일주일)

서버 역시 책임을 지니는 인증 방식을 결정하고 코드 설계에 착수하면서 발생한 내 나름의 트러블 슈팅들이 꽤 많았는데, 우선은 서버와 관련된 트러블 슈팅부터 정리할 예정

트러블슈팅 1: 생각 이상으로 DB 조회량이 많다.

서버가 리프레쉬토큰 및 엑세스토큰 재발급 과정을 맡자고 결심하고 든 첫 번째 문제는 성능이었다. 현재 내 개발의 RDBMS의 언어는 PostgreSQL이었는데, 기존에 익숙히 사용하던 MySQL에 비해 고성능을 발휘한다고 한들, ORM을 바탕으로 개발을 진행하기 때문에 hibernate가 대신하는 쿼리 작성에 드는 리소스를 트래픽 단위로 생각하면 상상 이상의 리소스 소모가 발생하게 된다.

회원가입과 해당 페이지 접속 외에는 모든 API가 인증을 기본 전제로 두기 때문에 만약 기존의 관계형 DB에 리프레쉬토큰을 저장해서 매 API 호출 때마다 DB 조회가 추가적으로 일어난다면 성능 이슈가 발생할 수 있다고 생각을 했다.

예전 트래픽 제어 프로젝트에서도 활약을 많이 한 redis는 인메모리 데이터 저장소로 활용되며 매모리에 데이터를 저장하기 때문에 빠른 입출력 및 조회 속도를 기반으로 높은 처리량을 발휘할 수 있다. 또한 토큰의 특성상, 만료라는 개념이 필요한데 redis의 TTL(TimeToLive)을 활용해서 쉽게 설정할 수 있다.

향후에 후술하겠지만, 인증 외의 이유로 redis 도입을 결정한 것 또한 존재한다.

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
        redisStandaloneConfiguration.setHostName(host);
        redisStandaloneConfiguration.setPort(port);
        redisStandaloneConfiguration.setPassword(password);
        return new LettuceConnectionFactory(redisStandaloneConfiguration);
    }

    @Bean
    @Primary
    public RedisTemplate<String, String> redisTemplate() {
        RedisTemplate<String, String> template = new RedisTemplate<>();
        template.setConnectionFactory(redisConnectionFactory());
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new StringRedisSerializer());
        return template;
    }

@Primary 어노테이션 역시 트러블 슈팅의 해결책 중 하나였는데, redis와 관련된 개발일지를 작성하면서 전부 정리할 예정.

트러블슈팅 2: 공격자의 공격 시나리오

클라이언트에서 엑세스토큰을 지니고 있고, 이것을 쿠키 등으로 관리한다고 한들 공격자가 탈취할 가능성은 항상 존재한다. 그렇기 때문에 공격자에게 공격을 당한 상황을 가정하고 방어책을 마련해야 했다. 그 방어책과 관련해서 각각의 키워드별로 시나리오를 정리하고 나름의 방어책을 제시했다.

서버는 엑세스토큰을 누가 보냈는지 알 수 없다

공격 시나리오를 고민하게 된 가장 큰 이유였다. 사용자임을 증명하는 내용은 어차피 토큰에 담겨있기 때문에 토큰을 탈취만 하면 사용자로 가장하고 서버에 요청을 보낼 수 있다는 점이었다. 그래서 나는, 정상적인 사용자의 행동 양상과 공격자의 행동 양상을 고민했다.

사용자의 로그아웃 후에 '만료되지 않은' 엑세스토큰 요청은 공격자의 요청이다

위의 키워드에 따라 나는 로그아웃을 증명할 수 있는 로직을 구상했다. 앞서 언급했듯 리프레쉬토큰을 redis에 저장했고 그 조회의 키 값을 이메일로 뒀다. 그 이메일은 엑세스토큰을 해석해서 나온 값이므로 우선 전제 조건은 유효한 엑세스토큰이어야 한다.

로그아웃 후에도 엑세스토큰의 유효시간이 지나지 않아 잠복했던 공격자가 엑세스토큰을 요청을 보낼 때도 로그아웃을 증명할 수 있는 방법은 redis를 조회했을 때 리프레쉬토큰이 존재하지 않으면 된다. 즉, 로그아웃을 하면 redis의 리프레쉬토큰을 삭제함에 따라, 인가 과정에서 redis를 조회해서 리프레쉬토큰이 존재하는지 로직을 추가한다.

// 이메일로 기존의 리프레쉬토큰 조회
// redis에 저장된 리프레쉬토큰 갖고오기
String refreshToken = redisRefreshToken.opsForValue().get(email);

// 리프레쉬 토큰 만료 확인
if (jwtUtil.isTokenExpired(refreshToken)) {
    throw new ResourceNotFoundException("리프레쉬 토큰 만료, 로그아웃 요망.");
}

// 데이터베이스에 저장되어있는지 확인하기
if (refreshToken == null) {
    throw new ResourceNotFoundException("저장되지 않은 토큰 정보. 공격자 확인 바람.");
}

로그아웃 후, 곧바로 로그인한 경우 유효한 엑세스토큰은 2개가 된다

이 때, 전자(로그아웃 이전의 여전히 유효함이 남은 토큰)를 무효화시키는 방법을 고민해야 했다. 왜냐면 전자는 공격자의 요청임이 자명했기 때문이다. 기존의 엑세스토큰 유효성 검증만으로는 둘을 분간할 수 없었기 때문에 추가적인 검증 로직이 필요했다.

나는 엑세스토큰의 발급 및 재발급 과정에 리프레쉬토큰의 재발급을 일치시켰다. 즉, 정상적인 사용자의 엑세스토큰 발급시간과 리프레쉬토큰의 발급시간은 항상 똑같아야 한다. 이렇게 되면 전자의 상황에서 공격자가 지닌 엑세스토큰의 발급시간과, 다시 로그인을 해서 재발급된 리프레쉬토큰의 발급시간이 다르게 된다.

이 방법은 엑세스토큰을 재발급할 때 리프레쉬토큰도 재발급시킴으로써 항상 발급시간을 일치시키게 된다. 이것을 고안한 이유는 지금은 단일 서버로 개발이 이뤄지지만 분산 서버 환경에서의 토큰 동기화를 위해서였다. 정합성이 일치하지 않아 생기는 만료된 엑세스토큰 블랙리스트 처리의 그 시간 틈새 내에 이뤄진 공격자의 공격을 방어하기 위해서 리프레쉬토큰의 재발급을 통해 발급시간을 강제로 일치시킨다. 물론 이것은 리프레쉬토큰 관리는 메인 서버에서 단일적으로 관리한다고 전제해야 할 것이다.

public List<TokenPayload> createTokenPayloads(String email, UserRoleEnum role) {
    List<TokenPayload> tokenPayloads = new ArrayList<>();
    Date date = new Date();

    TokenPayload accessTokenPayload = new TokenPayload(
            email,
            UUID.randomUUID().toString(),
            date,
            new Date(date.getTime() + ACCESS_TOKEN_TIME),
            role,
            TokenType.ACCESS
    );

    TokenPayload refreshTokenPayload = new TokenPayload(
            email,
            UUID.randomUUID().toString(),
            date,
            new Date(date.getTime() + REFRESH_TOKEN_TIME),
            role,
            TokenType.REFRESH
    );

        tokenPayloads.add(accessTokenPayload);
        tokenPayloads.add(refreshTokenPayload);

        return tokenPayloads;
    }

Date iatAccessToken = jwtUtil.getTokenIat(accessToken);
Date iatRefreshToken = jwtUtil.getTokenIat(refreshToken);

if (!iatRefreshToken.equals(iatAccessToken)) {
    throw new ResourceNotFoundException("블랙리스트 처리된 리프레쉬 토큰 요구 확인.");
}

다만 정상적인 사용자의 만료된 엑세스토큰이어도 발급 시간은 받아야 하므로 예외가 발생해도 시간을 추출할 수 있도록 메소드를 다듬어서 시간을 추출할 수 있도록 커스터마이징하였다.

public Date getTokenIat(String token) {
    try {
        // 만료된 토큰에서 클레임을 파싱하되 서명 검증은 생략
        return Jwts.parser()
                .verifyWith(secretKey)
                .build()
                .parseSignedClaims(token)
                .getPayload()
                .getIssuedAt(); // username이나 email을 subject로 저장했다고 가정
    } catch (ExpiredJwtException e) {
        // 토큰이 만료되었을 경우 ExpiredJwtException에서 클레임을 추출
        return e.getClaims().getIssuedAt();
    }
}

요약하자면, 서버의 확장성까지 고려한 내 나름의 결론이라고 할 것이다.

profile
scientia est potentia / 벨로그 이사 예정...

0개의 댓글