[Spring] JWT RefreshToken 적용하기(로그아웃 구현)

늘보·2025년 2월 27일

Spring

목록 보기
14/24
post-thumbnail

기존 auth 도메인에는 회원가입과 로그인 기능만 구현되어 있으며, accessToken만 발급하여 사용하기 때문에 accessToken 만료 시 재로그인이 필요했다.

➡︎ 이를 개선하기 위해 JWT RefreshToken 기능을 추가사용자의 불편을 해소하고, 로그아웃 기능을 추가하여 보다 견고한 API를 만들고자 하였다.


JWT 방식

🚀 JWT는 Stateless하다.

JWT는 클라이언트(프론트엔드, 브라우저 or 앱)에 로그인 정보가 저장된 상태이다.

즉, 서버별도의 로그인 정보를 유지하지 않고 클라이언트보유한 토큰을 통해 인증을 처리한다.


JWT의 구조

https://jwt.io 에서 확인할 수 있다.

[인코딩 된 JWT의 모습]

[디코딩 된 JWT의 모습]

헤더, 페이로드, 서명키로 구성되어 있고 JWT에 포함된 데이터는 기본적으로 Base64URL 인코딩[❌암호화❌]하여 만든다.

암호화된 것이 아니기 때문에 해당 정보는 누구나 쉽게 디코딩하여 확인 할 수 있다.
따라서, 🚨민감한 정보를 담으면 안된다.🚨

💡 데이터가 늘어날 수록 인코딩된 값이 늘어난다.



💭 로그아웃을 하기 위해서는 주어진 토큰을 삭제하면 된다. 그런데, 저장되지도 않은 토큰을 서버에서 어떻게 삭제할까 . . . ?

➡︎ 서버회원가입이나 로그인 시 토큰을 발급하고 토큰에 대한 제어를 완전히 잃는다. 따라서, DB에 토큰을 저장하지 않는 이상 서버에서 안전한 로그아웃 기능을 구현할 수 없다.

🟢 로그아웃은 아래와 같이 2가지 방법을 통해 구현할 수 있다.

  • 클라이언트 측에서 삭제
  • 서버에 JWT 토큰 정보 저장

다만, 클라이언트측에서 토큰을 삭제하는 경우악성 해커가 토큰을 탈취했을 때, 서버는 만료 시간이 될 때까지 기다리는 방법 밖에 존재하지 않으며 Refresh 토큰마저 탈취 당할 경우에는 해커가 만료시간을 계속해서 갱신하며 서버에 요청을 할 수 있는 문제가 발생하게 된다.


⚠️ 서버는 토큰에 저장되어 있는 만료 시간과 사용자 정보로 인증을 처리하기 때문에 외부에서 무슨짓을 해도 확인할 방도가 없다.

➡︎ 따라서, 이러한 문제를 해결하기 위해 약간의 Stateful 특성을 추가하여 토큰 정보를 저장하면 사용자의 경험 개선 뿐만아니라 보안도 강화할 수 있게된다.



🟢 내가 이해한 JWT 흐름

1. 회원가입 or 로그인 시 accessToken과 RefreshToken 생성

💡회원가입에 토큰을 생성하는 이유: 회원가입 후 별도의 로그인 과정 없이 바로 사용할 수 있도록 하여 사용자의 불편을 줄인다.

2. 생성된 RefreshToken을 DB에 저장


3.만료 시간으로 accessToken이 유효하지 않을 경우 저장되어 있던 RefreshToken 정보를 통해 accessToken 재발급 여부 확인

  • RefreshToken의 만료시간이 남아있는 경우
    ➡︎ accessToken과 RefreshToken[선택 사항] 재발급

  • RefreshToken의 만료시간이 남아있지 않거나 로그아웃을 한 경우
    ➡︎ 로그인 하라는 메세지를 클라이언트측에 보내고 재로그인을 하도록 한다.

🟢 구현 코드

🟢RefreshToken을 저장할 테이블 생성 (RefreshToken)

@Getter
@Entity
@Table(name = "refresh_tokens")
public class RefreshToken extends Timestamped {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    //유저 아이디
    private Long userId;

    //Refresh Token 저장
    private String token;

    //Refresh Token 상태
    @Enumerated(EnumType.STRING)
    private TokenStatus status;

    public RefreshToken () {

    }

    public RefreshToken (Long userId) {
        this.userId = userId;
        this.token = UUID.randomUUID().toString(); //jwt Token은 긴 문자열이기 때문에 성능을 높이기 위해 UUID를 사용
        this.status = VALID; //Token 상태 저장
    }

    public void updateStatus(TokenStatus status) {
        this.status = status;
    }
}

🟢RefreshToken의 상태 (TokenStatus)

public enum TokenStatus {
    VALID, //로그인 및 회원가입 시
    INVALIDATED //로그아웃, 만료시간
}

🔵 상태 저장을 통해 사용자의 활동을 추적할 수 있다.


🟢AuthContoller

@RestController
@RequiredArgsConstructor
public class AuthController {

    private final AuthService authService;

    @PostMapping("/auths/signup")
    public SignupResponse signup(@Valid @RequestBody SignupRequest signupRequest) {
        return authService.signup(signupRequest);
    }

    @PostMapping("/auths/login")
    public SigninResponse login(@Valid @RequestBody SigninRequest signinRequest) {
        return authService.login(signinRequest);
    }

    //로그아웃 추가 구현
    @PostMapping("/logout/{userId}")
    public void logout(@PathVariable Long userId) {
        authService.logout(userId);
    }

    //Refresh 토큰을 통해 Access 토큰 재발급 (refresh token rotation)
    @PostMapping("/auths/refresh")
    public TokenResponse reissueToken(
            @Valid @RequestBody RefreshTokenRequest request
            ) {
        return authService.reissueToken(request);
    }
}

🟢AuthService

@Service
@RequiredArgsConstructor
public class AuthService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final TokenService tokenService;

    @Transactional
    public SignupResponse signup(SignupRequest signupRequest) {

        if (userRepository.existsByEmail(signupRequest.getEmail())) {
            throw new InvalidRequestException("이미 존재하는 이메일입니다.");
        }

        /*유효하지 않는 UserRole 검증*/
        UserRole userRole = UserRole.of(signupRequest.getUserRole());

        String encodedPassword = passwordEncoder.encode(signupRequest.getPassword());

        User newUser = new User(
                signupRequest.getEmail(),
                encodedPassword,
                userRole
        );

        User savedUser = userRepository.save(newUser);

        String accessToken = tokenService.createAccessToken(savedUser);
        String refreshToken = tokenService.createRefreshToken(savedUser);

        return new SignupResponse(accessToken, refreshToken);
    }

    @Transactional(readOnly = true)
    public SigninResponse login(SigninRequest signinRequest) {
        User user = userRepository.findByEmail(signinRequest.getEmail()).orElseThrow(
                () -> new InvalidRequestException("가입되지 않은 유저입니다."));

        // 로그인 시 이메일과 비밀번호가 일치하지 않을 경우 401을 반환합니다.
        if (!passwordEncoder.matches(signinRequest.getPassword(), user.getPassword())) {
            throw new AuthException("잘못된 비밀번호입니다.");
        }

        String accessToken = tokenService.createAccessToken(user);
        String refreshToken = tokenService.createRefreshToken(user);

        return new SigninResponse(accessToken, refreshToken);
    }

    //로그아웃 추가 구현
    @Transactional
    public void logout(Long userId) {
        tokenService.revokeRefreshToken(userId);
    }

 	//토큰 재발급 
    @Transactional
    public TokenResponse reissueToken(@RequestBody RefreshTokenRequest request) {
        User user = tokenService.refresh(request);

        String accessToken = tokenService.createAccessToken(user);
        String refreshToken = tokenService.createRefreshToken(user);

        return new TokenResponse(accessToken,refreshToken);
    }
}

🟢TokenService

@Service
@RequiredArgsConstructor
public class TokenService {

    private final RefreshTokenRepository refreshTokenRepository;
    private final UserService userService;
    private final JwtUtil jwtUtil;

    public String createAccessToken(User user) {
        return jwtUtil.createAccessToken(user.getId(), user.getEmail(), user.getUserRole());
    }

    public String createRefreshToken(User user) {

        //RefreshToken 저장
        RefreshToken savedToken = refreshTokenRepository.save(new RefreshToken(user.getId()));

        return savedToken.getToken();
    }

    public void revokeRefreshToken(Long userId) {
        RefreshToken refreshToken = refreshTokenRepository.findById(userId).orElseThrow(
                () -> new InvalidRequestException("해당 유저의 Token이 존재하지 않음."));

        //Token 무효화
        refreshToken.updateStatus(INVALIDATED);
    }

    public User refresh(RefreshTokenRequest request) {
        String token = request.getRefreshToken();

        RefreshToken refreshToken = refreshTokenRepository.findByToken(token).orElseThrow(
                () -> new InvalidRequestException("유저 찾을 수 없음"));

        if (INVALIDATED == refreshToken.getStatus()) {
            throw new InvalidRequestException("다시 로그인 해주세요");
        }

        return userService.findByIdElseThrow(refreshToken.getUserId());
    }
}

🟢 결과

  • 회원가입 후 accessToken과 RefreshToken을 응답한다.

  • DB에 VALID라는 상태와 함께 RefreshToken 저장

  • 그 후 재발급 시도 시 새로운 accessToken과 refreshToken 발급 ➡︎ 현재 상태가 VALID이기 때문에 가능하다.
  • 사용자가 로그아웃을 한 경우

  • DB에서 토큰의 상태가 INVALIDATED로 저장됨

    ➡︎ 토큰의 상태와 modified정보를 통해 사용자의 활동을 알 수 있다.

  • 로그아웃하고 다시 시도할 경우

💭 A 사용자가 회원가입 후 다른 페이지에서 다시 로그인하면 2개 이상의 토큰을 가지게 될 수 있다. 현재처럼 상태를 기반으로 사용자의 활동을 추적하면 DB에 토큰 데이터가 계속해서 누적된다. 만약 많은 사용자가 이런 방식으로 서비스를 이용한다면, DB에 데이터가 과도하게 저장되지 않을까? 하는 의문이 들었다.

해결법? ➡︎ 배치를 이용해서 기간 단위로 삭제 or 테이블 따로 만들어서 만료된 것만 모으기


🟢 추후 더 추가할 내용

  • 현재는 0.11.5 버전을 사용하여 JWT를 구현하였지만, 최신 버전인 0.12.6으로 구현해볼 예정이다.

  • Spring Security를 추가하여 JWT를 구현해보고 싶다.

  • 지금까지 배우고 활용한 Cookie, Session, JWT의 개념을 통해 각 차이점을 정확히 비교하고 분석하여 다음 프로젝트에서 활용해 보고싶다.


📌 JWT 방식에서 로그아웃, Refresh Token 만들기(1): JWT의 Stateless한 특징을 최대한 살리려면?

📌 [Spring Security + JWT] 로그아웃 구현하기

📌[Spring] DB를 사용한 JWT 인증에서 로그아웃을 구현해보자

📌Access Token과 Refresh Token이란 무엇이고 왜 필요할까?

📌JWT 토큰 기반의 상태 관리시 로그아웃 처리 문제와 간단한 해결 방법

profile
누워만 있지 말고 제발 뭐라도 하자.

0개의 댓글