Skip to content

Jwt 토큰 방식의 로그인

Jay_ edited this page Jun 29, 2022 · 18 revisions

1. 도입 이유

일반적으로 Stateless한 http 환경에서 로그인 기능을 구현할때 유저 정보가 담긴 Jwt토큰을 클라이언트단에 저장하는 토큰 방식이나 세션을 서버에 저장하는 세션 방식을 사용하여 구현합니다. 여기서 세션방식은 모든 유저의 정보를 서버에 저장하기 때문에 서비스의 규모가 커지면 세션을 관리하기도 어렵고 서버에 부하도 많이 갑니다. 그래서 서버에 부하를 주지도 않고 별도의 인증 저장소를 필요로 하지 않는 토큰 방식을 채택하기로 하였으며 추가로 사용자 입장에서 서비스마다 회원가입을 해야하는 부담을 줄이기 위해 네이버나 카카오 같은 대형서비스에서 관리하는 유저 정보를 이용하여 로그인하는 Oauth기술을 적용하여 구현하였습니다.

2. 문제 상황, 해결 방안

Jwt 토큰 방식으로 구현하던 중 한번 생성된 토큰은 더이상 제어할 수 없어 만약 유효기간이 남은 토큰이 탈취되더라도 해킹을 막을 방법이 없다는 사실을 알게 되었습니다. 그렇다고 토큰의 유효기간을 짧게 설정하면 그만큼 자주 로그인을 다시 해야하기 때문에 유저입장에서 서비스를 사용하는데 불편해질 것입니다. 그래서 토큰이 탈취 당했을때를 대비해 액세스 토큰의 유효기간을 짧게 설정하여 클라이언트의 로컬 스토리지에 저장하고 또 다른 리프레쉬 토큰을 유효기간을 길게 설정한 후 클라이언트와 서버 양쪽에 저장하여 액세스 토큰이 만료 됐을때 새 토큰을 발급해주는 수단으로 사용하기로 했습니다.

3. 의견 조율, 의견 결정

Refresh Token을 사용하기로 하고 Refresh Token을 서버에 어떻게 저장할지가 관건이었습니다.

  • 데이터베이스에 Refresh Token을 저장
  • 인메모리 저장방식인 Redis에 Refresh Token을 저장

쿼리를 보내 상대적으로 속도가 느린 데이터베이스에서 토큰을 가져오기 보다는 Redis 서버에 토큰을 캐시로 저장하여 빠르게 가져올수 있고 토큰의 유효시간이 지나면 자동으로 삭제되는 인메모리 저장방식을 사용하기로 결정하였습니다.

@Service
@RequiredArgsConstructor
public class RedisService {

    private final RedisTemplate redisTemplate;

    // 키-벨류값으로 토큰 저장
    public void setValues(String token, Long accountId){
        ValueOperations<String, String> values = redisTemplate.opsForValue();
        values.set(accountId.toString(), token, Duration.ofMinutes(14 * 24 * 60));  // 2주 뒤 메모리에서 삭제된다.
    }

    // 키값으로 토큰 가져오기
    public String getValues(Long accountId){
        ValueOperations<String, String> values = redisTemplate.opsForValue();
        return values.get(accountId.toString());
    }

    // 키값으로 토큰 삭제
    public void delValues(Long accountId) {
        redisTemplate.delete(accountId.toString());
    }
}
@Transactional
    public TokenResponseDto refresh(String accessToken, String refreshToken) {

        // 리프레쉬 토큰 기간 만료 에러
        if (!jwtTokenProvider.validateRefreshToken(refreshToken)) {
            throw new ErrorCustomException(ErrorCode.TOKEN_EXPIRATION_ERROR);
        }

        Long userPk = Long.parseLong(jwtTokenProvider.getUserPk(refreshToken));
        String getRefreshToken = redisService.getValues(userPk);
        Account account = accountRepository.findById(userPk)
                .orElseThrow(() -> new ErrorCustomException(ErrorCode.NO_USER_ERROR));

        // 액세스 토큰이 만료되지 않았을 때 새 토큰 발급 요청이 들어온 경우 해킹으로 간주, 토큰 만료 에러
        if (jwtTokenProvider.validateRefreshToken(accessToken)) {
            redisService.delValues(userPk);
            throw new ErrorCustomException(ErrorCode.TOKEN_EXPIRATION_ERROR);
        }
        
        // 클라이언트 로컬 스토리지에 저장된 리프레쉬 토큰과 레디스 서버에 저장된 리프레쉬 토큰이 일치하지 않는 경우 해킹으로 간주, 토큰 불일치 에러
        if (!refreshToken.equals(getRefreshToken)) {
            throw new ErrorCustomException(ErrorCode.NO_MATCH_ITEM_ERROR);
        }

        String updateToken = jwtTokenProvider.createToken(Long.toString(account.getId()), account.getEmail());
        String updateRefreshToken = jwtTokenProvider.createRefreshToken(Long.toString(account.getId()));
        redisService.delValues(userPk); // 기존의 레디스 서버에 있던 리프레쉬 토큰 삭제
        redisService.setValues(updateRefreshToken, userPk); // 새로 발급된 리프레쉬 토큰 레디스 서버에 저장

        // 새로 발급된 액세스 토큰과 리프레쉬 토큰을 클라이언트로 리턴
        return TokenResponseDto.builder()
                .accessToken(updateToken)
                .refreshToken(updateRefreshToken)
                .build();
    }

4. 레퍼런스

JWT와 SESSION 고찰 https://github.com/boojongmin/memo/issues/7

Clone this wiki locally