nameless1004 2024. 9. 29. 15:34

구현

현재 프로젝트는 Spring Security를 사용하고 jwt 토큰으로 인증/인가를 구현했다. 기존의 방식은 액세스 토큰을 헤더에 넣고, 리프레쉬 토큰을 쿠키에 넣은 후 mysql에 refresh 토큰 정보를 넣어서 사용했다. mysql에 넣을 시 안사용하는 토큰들을 주기적으로 정리해줘야 하는 불편함이 있어서 이 참에 Redis로 변경하기로 했다.

인증이 성공했을 때 위와 같이 로그인 정보를 프론트에 보내주고, 레디스에는 리프레쉬 토큰을 저장해 주고 TTL을 리프레쉬 토큰 유효시간 동안 걸어주었다.

    public ResponseDto<?> reissue(String refreshToken) {

        if(refreshToken == null) {
            return ResponseDto.of(HttpStatus.BAD_REQUEST, "재발급하려면 리프레쉬 토큰이 필요합니다.", null);
        }

        // 프론트에서 붙여준 Bearer prefix 제거
        try{
            refreshToken = jwtUtil.substringToken(refreshToken);
        } catch (NullPointerException e) {
            return ResponseDto.of(HttpStatus.BAD_REQUEST, "잘못된 토큰 형식 입니다.", null);
        }

        // 리프레쉬 토큰인지 검사
        String category = jwtUtil.getCategory(refreshToken);
        if (!category.equals(TokenType.REFRESH.name())) {
            return ResponseDto.of(HttpStatus.BAD_REQUEST, "리프레쉬 토큰이 아닙니다.");
        }

        // 토큰 만료 검사
        try{
            jwtUtil.isExpired(refreshToken);
        } catch (ExpiredJwtException e) {
            return ResponseDto.of(HttpStatus.UNAUTHORIZED, "만료된 리프레쉬 토큰입니다.", null);
        }


        String username = jwtUtil.getUsername(refreshToken);
        // 레디스에서 리프레쉬 토큰을 가져온다.
        refreshToken = (String) redisTemplate.opsForValue().get(username);

        if (refreshToken == null) {
            return ResponseDto.of(HttpStatus.UNAUTHORIZED, "만료된 리프레쉬 토큰입니다.", null);
        }

        // 검증이 통과되었다면 refresh 토큰으로 액세스 토큰을 발행해준다.
        String role = jwtUtil.getRole(refreshToken);

        // 새 토큰 발급
        String newAccessToken = jwtUtil.createAccessToken(username, UserRole.valueOf(role), false);
        String newRefreshToken = jwtUtil.createRefreshToken(username, UserRole.valueOf(role), false);

        // TTL 새로해서
        Long ttl = redisTemplate.getExpire(username);
        redisTemplate.opsForValue().set(username, newAccessToken);
        if(ttl != null && ttl > 0) {
            redisTemplate.expire(username, ttl, TimeUnit.MILLISECONDS);
        } else {
            return ResponseDto.of(HttpStatus.UNAUTHORIZED, "만료된 리프레쉬 토큰입니다.", null);
        }

        Reissue reissue = new Reissue(newAccessToken, newRefreshToken);

        return  ResponseDto.of(HttpStatus.OK, "", reissue);
    }

재발행해주는 부분은 refresh토큰을 검증해 주고 기존 리프레쉬 토큰을 삭제 후 다시 발급해서 redis에 저장 후 프런트에게 보내주었다.

액세스 토큰이나 리프레쉬 토큰을 탈취당했을 때 처리를 위해 블랙리스트 처리도 나중에 한 번 고민해봐야겠다. 그리고 리프레쉬 토큰을 프런트엔드에게 보내는 게 맞는 것인지 다시 한번 생각해 봐야겠다.

트러블 슈팅

테스트를 위해 requestMatchers("/**"). permitAll()을 설정했는데, 예상과 달리 인가 필터에서 accessToken이 없다는 이유로 요청이 차단되는 문제가 발생했다. 이는 requestMatchers("/**"). permitAll()의 의미를 제대로 이해하지 못해 생긴 문제였다. 스프링에서는 permitAll()을 사용하더라도 필터는 그대로 동작하며, 해당 매처에 포함된 URL로 요청을 보낼 때 인증 처리를 생략하는 것이지 필터 자체를 건너뛰는 것은 아니었다.

이 문제를 해결하기 위해 찾아본 결과, 필터 부분에 shouldNotFilter라는 오버라이드 가능한 메서드가 있었다. 이 부분에서 해당 요청으로 오면 필터를 건너뛸 수 있도록 바꿔주었다.