개발 프로젝트/빅데이터마케팅랩 Server

[SpringBoot/JWT] 로그인/회원가입에 Refresh Token 도입하기

iinana 2025. 4. 20. 16:46

0. JWT 기반 로그인/회원가입 구현

 기존에 구현되어 있던 JWT 기반 로그인/회원가입에서 보안과 안정성 향상을 위해 refresh token을 추가해 줬다. 이미 구현된 JWT 기반 로그인/회원가입과 왜 refresh token이 필요한지에 대한 내용은 아래 글에서 확인할 수 있다. 

https://programming-diary-ina.tistory.com/126

 

[Spring Boot/JWT] JWT로 로그인/로그아웃 구현하기

토큰 기반 인증 1. 토큰 기반 인증이란 토큰을 사용하여 인증하는 방식이다. 서버가 토큰을 생성해서 클라이언트에게 제공하면, 클라이언트는 이 토큰을 가지고 있다가 여러 요청을 토큰과 함

programming-diary-ina.tistory.com

 

 refresh token의 추가로, 기존 코드에서는 access를 위한 한 종류의 token만 존재했지만, 이제는 refresh token과 access token, 두 가지 종류의 토큰이 존재하게 된다. refresh token은 쉽게 말하면 access token 재발급을 위한 token이다.


 

1. RefreshToken entity 및 repository 생성

 refresh token을 생성 및 저장하기 위해 entity와 repository를 만들어준다. entity 코드는 아래와 같다. 

/** domain/RefreshToken.java **/
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
public class RefreshToken {
    // refresh token 자체의 id
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // 사용자 id
    @Column(name = "user_id", nullable = false, unique = true)
    private Long userId;

    // refresh token 값
    @Column(name = "refresh_token", nullable = false)
    private String refreshToken;

    public RefreshToken(Long userId, String refreshToken) {
        this.userId = userId;
        this.refreshToken = refreshToken;
    }

    public RefreshToken update(String newRefreshToken) {
        this.refreshToken = newRefreshToken;
        return this;
    }
}

 

 다음은 생성된 refresh token entity를 저장할 repository이다. JPA 기반으로 생성해준다. 

/** repository/RefreshTokenRepository.java **/
import com.survey.server.domain.RefreshToken;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;

public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
    Optional<RefreshToken> findByUserId(Long userId);
    Optional<RefreshToken> findByRefreshToken(String refreshToken);

    void deleteByUserId(Long userId);
}

 

2. token service와 refresh token service

 다음으로 앞서 설계해둔 refresh token 로직을 실질적으로 사용하기 위해 refresh token service와 token serivce를 만들어준다. refresh token service는 refresh token을 생성하고 삭제하기 위한 service이고, token service는 refresh token을 기반으로 access token을 생성하기 위한 service이다. 

/** service/RefreshTokenService.java **/
import com.survey.server.config.jwt.TokenProvider;
import com.survey.server.domain.RefreshToken;
import com.survey.server.repository.RefreshTokenRepository;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import java.time.Duration;

@RequiredArgsConstructor
@Service
public class RefreshTokenService {
    private final TokenProvider tokenProvider;
    private final RefreshTokenRepository refreshTokenRepository;
    private static final Long REFRESH_EXPIRATION = Duration.ofDays(14).toMillis();

    // refresh token 찾기
    public RefreshToken findByRefreshToken(String refreshToken) {
        return refreshTokenRepository.findByRefreshToken(refreshToken)
                .orElseThrow(() -> new IllegalArgumentException("Unexpected Refresh Token"));
    }
    
    // 새로운 refresh token 생성
    public String createNewRefreshToken(Long userId) {
        // refresh token 값 생성
        String refreshToken = tokenProvider.makeToken(userId, null, REFRESH_EXPIRATION);
        // roles = null로 설정 (refresh token은 null 값 필요하지 않음)

        // refresh token entity 생성 및 저장
        RefreshToken refreshTokenEntity = new RefreshToken(userId, refreshToken);
        refreshTokenRepository.save(refreshTokenEntity);

        return refreshToken;
    }

    // 이미 사용한 refresh token 업데이트
    public RefreshToken updateRefreshToken(Long userId, String refreshToken) {
        RefreshToken refreshTokenEntity = findByRefreshToken(refreshToken);
        if (refreshTokenEntity == null) {
            System.err.println("Refresh Token not found");
            throw new IllegalArgumentException("Unexpected Refresh Token");
        }
        refreshTokenEntity.update(tokenProvider.makeToken(userId, null, REFRESH_EXPIRATION));
        return refreshTokenRepository.save(refreshTokenEntity);
    }

    // 로그아웃 시 refresh token 삭제
    @Transactional
    public void delete() {
        // 현재 로그인 되어있는 유저 찾기
        String token = SecurityContextHolder.getContext().getAuthentication().getCredentials().toString();
        Long userId = tokenProvider.getUserId(token);
        
        // 해당 유저의 refresh token 삭제
        refreshTokenRepository.deleteByUserId(userId);
    }

    // refresh token 만료 시 DB에서 삭제
    public void expire(String refreshToken) {
        RefreshToken refreshTokenEntity = findByRefreshToken(refreshToken);
        if (refreshTokenEntity != null) {
            refreshTokenRepository.deleteByUserId(refreshTokenEntity.getUserId());
        }
    }
}

 

 다음은 token service이다. 여기서는 refresh token이 유효한지 검증하고, 유효하다면 access token을 새로 발급하면서, 이미 사용된 refresh token도 폐기하고 다시 생성한다. 

/** service/TokenService.java **/
import com.survey.server.config.jwt.TokenProvider;
import com.survey.server.domain.RefreshToken;
import com.survey.server.model.User;
import com.survey.server.repository.RefreshTokenRepository;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Set;
import java.util.stream.Collectors;

@RequiredArgsConstructor
@Service
public class TokenService {
    private final TokenProvider tokenProvider;
    private final RefreshTokenService refreshTokenService;

    // 새로운 access token 생성
    public String createNewAccessToken(String refreshToken, Set<String> roles) {
        // refresh token 유효성 검증
        if (!tokenProvider.isValidToken(refreshToken)) {
            refreshTokenService.expire(refreshToken); // 만료된 refresh token 삭제
            throw new IllegalArgumentException("Invalid refresh token");
        }

        Long userId = refreshTokenService.findByRefreshToken(refreshToken).getUserId();
        
        // refresh token update
        refreshTokenService.deleteByUserId(userId);
        refreshTokenService.createNewRefreshToken(userId);

        // access token 재발급
        return tokenProvider.makeToken(userId, roles);
    }
}

 

3. 로그인에 refresh token 생성 로직 추가

 로그인 시에 바로 access token을 생성하던 것에서 refresh token을 생성한 후 이를 기반으로 access token을 생성하는 것으로 로직을 수정한다.

/** controller/UserController.java **/
@PostMapping("/login")
public ResponseEntity<LoginResponseDTO> login(@RequestBody @Validated LoginRequestDTO request, HttpServletResponse response) {
    try {
        LoginResponseDTO result = userService.login(request, response);
        return ResponseEntity.ok(result);
    } catch (IllegalStateException e) {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
    } catch (Exception e) {
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
    }
}
/** service/UserService.java **/
public LoginResponseDTO login(LoginRequestDTO request, HttpServletResponse response) {
    Optional<User> optionalUser = userRepository.findByUserIdent(request.getUserIdent());
    if (optionalUser.isEmpty() ||
            !bCryptPasswordEncoder.matches(request.getUserPassword(), optionalUser.get().getPasswordHash())) {
        throw new IllegalStateException("로그인 실패: invalid_credentials");
    }

    // refresh token 생성
    User user = optionalUser.get();
    String refreshToken = refreshTokenService.createNewRefreshToken(user.getUserId());
    CookieUtil.addCookie(response, "refresh_token", refreshToken, 7 * 24 * 60 * 60); //7일

    // access token 생성
    Set<String> roles = user.getAuthorities().stream()
            .map(GrantedAuthority::getAuthority)
            .collect(Collectors.toSet());
    String accessToken = tokenService.createNewAccessToken(refreshToken, roles);

    return new LoginResponseDTO(
            accessToken,
            user.getUserIdent(),
            roles
    );
}

 

6. TokenController 작성으로 클라이언트로부터 access token 발급 요청받기

 마지막으로 access token이 만료되었을 때, 클라이언트가 refresh token을 가지고 재발급받을 수 있도록 TokenController를 작성한다. 

/** controller/TokenController.java **/
import com.survey.server.dto.CreateAccessTokenRequest;
import com.survey.server.dto.CreateAccessTokenResponse;
import com.survey.server.service.RefreshTokenService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
import com.survey.server.service.TokenService;

import java.util.Set;

@Controller
@RequestMapping("/api")
@RequiredArgsConstructor
public class TokenController {
    private final TokenService tokenService;
    private final RefreshTokenService refreshTokenService;

    /**
     * access token 발급 endpoint
     * @param request access token 발급 요청
     * @return token 발급 응답 DTO를 담은 Response Entity
     */
    @PostMapping("/token")
    public ResponseEntity<CreateAccessTokenResponse> createNewAccessToken(@RequestBody CreateAccessTokenRequest request) {
        try {
            String newAccessToken = tokenService.createNewAccessToken(request.getRefreshToken(), Set.of("ROLE_USER"));
            return ResponseEntity.status(HttpStatus.CREATED)
                    .body(new CreateAccessTokenResponse(newAccessToken));
        } catch (IllegalArgumentException e) {
            // 유효하지 않은 refresh token
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(null);
        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(null);
        }
    }
}
728x90
반응형