PlayGround/마실가실 리팩토링

[1년 후 마실가실] JWT와 로그아웃(2) RefreshToken

HJ0216 2024. 9. 14. 09:19

1년 전 진행했던 마실가실 프로젝트를 🛠️리팩토링하며 정리한 내용입니다.

 

 

이것저것 좋아보이는 코드를 붙여넣다보니, 이도저도 아닌 누구세요 코드가 되어 정리를 좀 했습니다,,

이름하여 리리팩토링,,!

 

로그아웃하기 전 Refresh Token을 통한 Access Token 재발급을 정리하고, 다음은 정말.. 찐.. 최종.. 로그아웃..!

 

지난번에 설정한 Redis와 함께 토큰 재발행.. 도전..!

 

  • UserController.java
@RestController
@RequestMapping("api/v2/users")
@RequiredArgsConstructor
public class UserController {
    private final UserService userService;
	
    @PostMapping("/re-issue")
    @ResponseStatus(HttpStatus.OK)
    public TokenInfo reissue(@RequestBody TokenInfo reIssueDto) {
        return userService.reissue(reIssueDto);
    }
}

 

 

  • UserService.java
public TokenInfo reissue(TokenInfo reIssueDto) {
    try {
        jwtTokenProvider.getExpiration(reIssueDto.getAccessToken());
        throw new BusinessException(VALID_ACCESS_TOKEN);
    } catch (ExpiredJwtException e) {
        // Refresh Token 유효성 검사
        if (!jwtTokenProvider.isValidToken(reIssueDto.getRefreshToken())) {
            throw new BusinessException(INVALID_REFRESH_TOKEN);
        }

        // Redis에 Refresh Token 존재 확인
        boolean hasStoredRefreshToken = redisTemplate.hasKey("RT:" + reIssueDto.getRefreshToken());
        if(!hasStoredRefreshToken) {
            throw new BusinessException(LOGOUT_MEMBER);
        }

        String email = redisTemplate.opsForValue().get("RT:" + reIssueDto.getRefreshToken());
        User user = userRepository.findByEmail(email).orElseThrow(
                () -> new BusinessException(NOT_FOUND_MEMBER));

        // AccessToken 재발급

        // User의 role -> 스프링시큐리티의 GrantedAuthority로 변경
        // 여러개의 role을 가질수 있으므로 Set
        UserDetails userDetails = new org.springframework.security.core.userdetails.User(
                user.getEmail(),
                user.getPassword(),
                Set.of(SecurityUtils.convertToAuthority(user.getRole()))
        );

        return jwtTokenProvider.generateAccessToken(userDetails);
    }
}

재생성.. 이제 자신있습니다..!

 

Refresh Token의 목적이 Access Token 만료 시, 토큰을 재발행 해주는 것이기 때문에, Access Token 만료 전에는 재발행할 수 없도록 하였습니다.

⭐ Access Token이 만료될 경우, Refresh Token의 유효성 검사를 수행합니다.

⭐ Redis 서버를 조회하여, 로그아웃한 User인지 확인합니다.

 

⭐ 토큰을 재발급받을 때,

Access Token만 재발급할 수도, 기존의 Refresh Token을 삭제하고 Access Token과 Refresh Token을 재발급할`수도 있습니다.

저는 로그인된 상태가 계속 유지되는 것이 한 번 Access Token이 탈취되면 보안 상 위험할 수 있다고 판단하여, Refresh Token까지 만료된 경우에는 다시 로그인을 하여 Access Token과 Refresh Token을 발급받도록 하였습니다.

 

  • JwtTokenProvider.java
@Component // Spring이 이 클래스를 자동으로 스캔하고 빈으로 등록
@RequiredArgsConstructor
public class JwtTokenProvider {
    private static final String AUTHORITIES_KEY  = "roles";
    private static final String BEARER_TYPE = "Bearer";

    @Value("${jwt.secret}")
    private String jwtSecretKey;

    private final RedisUtil redisUtil;
    private final UserRepository userRepository;

    // 유저 정보를 가지고 AccessToken, RefreshToken을 생성하는 메서드
    public TokenInfo generateToken(UserDetails auth) {
        // Access Token 생성
        String accessToken = generateAccessToken(auth).getAccessToken();

        // Refresh Token 생성
        // 아무런 정보도 토큰에 넣지 않고, 단순히 IssuedAt과 Expiration만을 입력한 후 서명
        String refreshToken = generateRefreshToken();

        return TokenInfo.builder()
                .grantType(BEARER_TYPE)
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .build();
    }

    public String generateRefreshToken() {
        Key secretKey = Keys.hmacShaKeyFor(jwtSecretKey.getBytes(StandardCharsets.UTF_8));

        Date now = new Date();
        Date expirationRefreshToken = new Date(now.getTime() + Duration.ofMinutes(2).toMillis());

        return Jwts.builder()
                .setIssuedAt(now)
                .setExpiration(expirationRefreshToken)
                .signWith(secretKey)
                .compact();
    }

    public TokenInfo generateAccessToken(UserDetails auth) {
        Key secretKey = Keys.hmacShaKeyFor(jwtSecretKey.getBytes(StandardCharsets.UTF_8));

        Date now = new Date();
        Date expirationAccessToken = new Date(now.getTime() + Duration.ofMinutes(1).toMillis());

        String authorites = auth.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        String accessToken = Jwts.builder()
                .setSubject(auth.getUsername()) // 토큰 소유자 설정
                .claim(AUTHORITIES_KEY, authorites) // JWT 내부에 추가적인 정보 설정
                .setIssuedAt(now)
                .setExpiration(expirationAccessToken)
                .signWith(secretKey)
                .compact();

        return TokenInfo.builder()
                .grantType(BEARER_TYPE)
                .accessToken(accessToken)
                .build();
    }

    // JWT 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내는 메서드
    public Authentication getAuthentication(String accessToken) {
        //유저 정보 부분만 가져옴
        Claims claims = parseClaims(accessToken);
        if(claims == null)
            throw new BusinessException(NOT_FOUND_MEMBER);

        if (claims.get(AUTHORITIES_KEY) == null)
            throw new BusinessException(NOT_FOUND_AUTHORITY);

        // 클레임에서 권한 정보 가져오기
        Set<GrantedAuthority> authorities =
                Arrays.stream(claims.get(AUTHORITIES_KEY)
                        .toString()
                        .split(","))
                        .map(SecurityUtils::convertToAuthority) // 스트림의 각 요소를 다른 형태로 변환
                        .collect(Collectors.toSet());


        // UserDetails 객체를 만들어서 Authentication 리턴
        UserDetails userDetails = UserPrinciple.builder()
                .email(claims.getSubject())
                .authorities(authorities)
                .build();

        return new UsernamePasswordAuthenticationToken(userDetails, null, authorities);
        // credentials: null, 인증이 완료된 후에는 실제 자격 증명(예: 비밀번호)을 사용할 필요가 없으므로 null로 설정
    }
    /**
     * UsernamePasswordAuthenticationToken
     * userDetails: 사용자의 정보(예: 이메일, 권한)를 담고 있는 객체
     * null: 이 위치에 보통은 비밀번호나 인증 토큰이 들어가지만, 여기서는 이미 인증이 된 상태이므로 필요하지 않아 null로 설정
     * authorities: 사용자가 가지고 있는 권한 목록
     * */

    // 토큰 정보를 검증하는 메서드
    public boolean isValidToken(String token) {
        if(token == null || token.isEmpty())
            return false;

        try {
            return (!parseClaims(token).isEmpty());
        } catch (MalformedJwtException e) {
            throw new BusinessException(MALFORMED_JWT);
        } catch (ExpiredJwtException e) {
            throw new BusinessException(EXPIRED_JWT);
        } catch (UnsupportedJwtException e) {
            throw new BusinessException(UNSUPPORTED_JWT);
        } catch (IllegalStateException e) {
            throw new BusinessException(ILLEGAL_STATE_JWT);
        }
    }

    // 리퀘스트의 토큰에서 암호풀어 Claims 가져오기
    private Claims parseClaims(String token) {
        if(redisUtil.hasKeyBlackList(token)){
            throw new BusinessException(LOGOUT_MEMBER);
        }

        Key secretKey = Keys.hmacShaKeyFor(jwtSecretKey.getBytes(StandardCharsets.UTF_8));

        return Jwts.parserBuilder()
                .setSigningKey(secretKey)
                .build()
                .parseClaimsJws(token)
                .getBody();
    }

    public Long getExpiration(String token) {
        Key secretKey = Keys.hmacShaKeyFor(jwtSecretKey.getBytes(StandardCharsets.UTF_8));

        // Token 남은 유효시간
        Date expiration = Jwts.parserBuilder()
                .setSigningKey(secretKey)
                .build()
                .parseClaimsJws(token)
                .getBody()
                .getExpiration();

        // 현재 시간
        Long now = new Date().getTime();

        return (expiration.getTime() - now); // TimeUnit.MILLISECONDS
    }
}



 

🙋‍♀️

본 포스트는 공부 목적으로 작성하였습니다.
보시는 도중 잘못된 부분이나 개선할 부분이 있다면 댓글로 알려주시면 수정하도록 하겠습니다.

 

📑

참고 자료

https://jjhwang.tistory.com/15

 

[JWT 재발급 01] access token 재발급 구현

먼저 access token과 refresh token이 저장되는 위치를 확인해 보자 Access Token : Header에 저장 Refresh Token : 쿠키와 DB에 저장 | access token 재발급 순서 1. header에 담긴 access token 유효기간 확인 - 유효기간이

jjhwang.tistory.com

https://inkyu-yoon.github.io/docs/Language/SpringBoot/RefreshToken#redis-%EC%82%AC%EC%9A%A9-vs-memcached

 

· Redis를 활용해서 Refresh Token 구현하기

👩🏻‍💻 지식 창고 📚

inkyu-yoon.github.io