Spring Security 액세스 토큰과 리프레시 토큰, 로그인 과정

ㅎㅎ·2024년 4월 11일

Spring

목록 보기
3/3
post-thumbnail

액세스 토큰과 리프레시 토큰을 같이 사용하는 이유

액세스 토큰 하나만 사용했을 때, 만료 시간이 작으면 잦은 재로그인으로 사용자 경험의 수준을 낮추고, 만료 시간이 길면 탈취의 위험성을 높인다.

그래서, 유효 기간이 더 긴 리프레시 토큰을 추가로 사용한다.

  • Access Token : 유저 정보, 인증 정보를 가지고 있고 유효 기간이 짧다. (마이크로소프트 - 60일)
  • Refresh Token : 유저, 인증 정보는 가지고 있지 않으며 유효 기간이 길다. (마이크로소프트 - 1년)

클라이언트-서버 통신은 액세스 토큰으로 이루어지고 액세스 토큰이 만료됐을 경우 클라이언트는 리프레시 토큰을 전송해 새로운 액세스 토큰을 받는다.

리프레시 토큰까지 만료됐다면 그 때 재로그인을 실행한다.

헷갈렸던 로직 FE - BE

"토큰이 다 만료됐을 때 재로그인 시켜주는 코드는 어떻게 짜는거지?"

리프레쉬 토큰이 만료가 됐을 때 서버가 직접 재로그인을 시켜주는 것이 아니다. 서버는 만료 메시지를 전달하고, 그 메시지를 받은 클라이언트 단에서 로그인 페이지로 이동시킨다. 서버는 만료 여부만을 반환한다.

프론트엔드가 만료된 토큰으로 api 요청을 했을 때 서버랑 프론트엔드랑 핑퐁핑퐁 하면서

BE: 이 토큰 만료됐다
FE: 그럼 리프레쉬 줄게 액세스 새거 줘
BE: 그래 새로 발급해줄게 / 리프레쉬도 만료됐다
FE: 그래 이제 이걸로 쓸게 / 아 그럼 재로그인 시켜야겠다

가 되는거다!

로그인의 로직

인증(auth)과 인가(jwt)에 사용되는 파일들이다.

인증이란?
: 유저가 누구인지 확인하는 절차, 회원가입하고 로그인 하는 것.

인가란?
: 유저에 대한 권한을 허락하는 것.

SecurityConfig

SecurityConfig의 .oauth2Login() 에서 흐름을 파악할 수 있다.

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity // spring security 설정들을 활성화
public class SecurityConfig {

    private final OAuth2UserService OAuth2UserService;
    private final TokenService tokenService;
    private final OAuth2SuccessHandler oAuth2SuccessHandler;
    private final OAuth2FailureHandler oAuth2FailureHandler;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .csrf(
                        AbstractHttpConfigurer::disable
                )
                .headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin))	// H2 콘솔 사용을 위한 설정
                .authorizeHttpRequests(requests ->
                        requests.anyRequest().permitAll() // 모든 요청을 모든 사용자에게 허용
                )
                .sessionManagement(sessionManagement ->
                        sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                )	// 세션을 사용하지 않으므로 STATELESS 설정
                .logout( // 로그아웃 성공 시 / 주소로 이동
                        (logoutConfig) -> logoutConfig.logoutSuccessUrl("/")
                )
                .oauth2Login(oauth2Login -> oauth2Login
                        .userInfoEndpoint(userInfoEndpoint -> userInfoEndpoint
                                .userService(OAuth2UserService)
                        )
                        .successHandler(oAuth2SuccessHandler)
                        .failureHandler(oAuth2FailureHandler)
                );

        return http.build();
    }
}

1. userInfoEndpoint() : 소셜 로그인 후 가져온 유저 정보를 처리한다.

.userInfoEndpoint(userInfoEndpoint -> userInfoEndpoint
        .userService(OAuth2UserService)
)

사용자가 로그인을 한 후 가져온 유저 정보가 들어간다. 해당 프로젝트에서는 소셜 로그인만 다룬다.

2. successHandler() : 유저 정보를 가져오는 것을 성공하면 신규/기존 회원인지를 검사하고 적당한 처리를 한다.

.successHandler(oAuth2SuccessHandler)

3. failureHandler() : 유저 정보를 가져오는 것을 실패한 경우를 처리한다.

.failureHandler(oAuth2FailureHandler)

클래스 설명

0. application-jwt.yml

jwt:
  secret-key: ${JWT_SECRET_KEY}
  token:
    access-expire-length: 43200000 # 12시간
    refresh-expire-length: 604800000 # 7

jwt 토큰의 비밀키와 유효기간을 설정한다.

1. OAuth2UserService

@RequiredArgsConstructor
@Service
public class OAuth2UserService implements org.springframework.security.oauth2.client.userinfo.OAuth2UserService<OAuth2UserRequest, OAuth2User> {
    // 소셜 로그인 이후 가져온 사용자의 정보 기반으로 가입 및 정보 수정 등의 기능 수행

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        org.springframework.security.oauth2.client.userinfo.OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
        OAuth2User oAuth2User = delegate.loadUser(userRequest);

        // 로그인 진행 중인 서비스(구글, 네이버, ...)를 구분
        String registrationId = userRequest.getClientRegistration().getRegistrationId();

        // OAuth2 로그인 진행 시 키가 되는 필드 값(Primary Key와 같은 의미)
        // 구글의 경우 기본적으로 해당 값("sub")을 지원하지만 네이버, 카카오 등은 기본적으로 지원 X
        String userNameAttributeName = userRequest.getClientRegistration()
                .getProviderDetails()
                .getUserInfoEndpoint()
                .getUserNameAttributeName();

        // OAuth2UserService를 통해 가져온 OAuth2User의 attribute 등을 담을 클래스
        OAuth2Attributes attributes = OAuth2Attributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());

        return new DefaultOAuth2User(
                Collections.emptyList(), // 역할(관리자, 회원)이 들어가는 위치인데 우리는 역할이 없으니까 빈리스트를 넣어줌
                attributes.getAttributes(),
                attributes.getNameAttributeKey());
    }
}

OAuthAttributes(인증 과정에서 사용하는 객체)에 담는다. 이후 핸들러에서 OAuth2User로 불러와진다.

2. OAuth2SuccessHandler

@Slf4j
@Component
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
    private final TokenService tokenService;
    private final UserRepository userRepository;

    public OAuth2SuccessHandler(TokenService tokenService, UserRepository userRepository) {
        this.tokenService = tokenService;
        this.userRepository = userRepository;
    }

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response
            , Authentication authentication) throws IOException {

        OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
        String email = oAuth2User.getAttribute("email");

        Optional<User> user = userRepository.findByEmail(email);
        Long userId = null;
        String targetUrl;

        if (user.isPresent()) { // 기존 회원인 경우 액세스, 리프레시 토큰 생성 후 전달
            userId = user.get().getId();
            String accessToken = tokenService.generateAccessToken(userId);
            String refreshToken = tokenService.generateRefreshToken();

            targetUrl = UriComponentsBuilder.fromUriString("http://localhost:3000/oauth2/redirect")
                    .queryParam("a", accessToken).queryParam("r", refreshToken)
                    .build().toUriString();
        } else { // 신규 회원인 경우 회원가입 페이지로 이동
            targetUrl = UriComponentsBuilder.fromUriString("http://localhost:3000/login")
                    .queryParam("e", email)
                    .build().toUriString();
        }

        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }
}
  • url의 길이를 줄이기 위해서 쿼리변수는 알파벳 하나씩만 사용했다.
  • 기존 회원인 경우: access, refresh 토큰을 넘겨준다.
  • 신규 회원인 경우: username을 작성한 후 최종 회원가입을 해야하므로 쿼리로 이메일을 담아 회원가입 페이지로 리다이렉트해준다.

3. TokenService

@Configuration
@Service
public class TokenService {
    private final Logger log = LoggerFactory.getLogger(getClass());
    private Key secretKey;

    @Value("${jwt.secret-key}")
    private String SECRET_KEY;

    @Value("${jwt.token.access-expire-length}")
    private Long ACCESS_EXPIRE_LENGTH; // 액세스 토큰의 만료 시간

    @Value("${jwt.token.refresh-expire-length}")
    private Long REFRESH_EXPIRE_LENGTH; // 리프레시 토큰의 만료 시간

    @PostConstruct
    protected void init() {
        secretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(SECRET_KEY));
    }

    public String generateAccessToken(Long userId) { // todo: 액세스, 리프레시 토큰 생성 로직 구현
        Claims claims = Jwts.claims().setSubject(String.valueOf(userId));

        return Jwts.builder().setClaims(claims)
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + ACCESS_EXPIRE_LENGTH))
                .signWith(secretKey, SignatureAlgorithm.HS256)
                .compact();
    }

    public String generateRefreshToken() {
        // refresh에는 별다른 유저 정보가 들어가지 않는다. claims 세팅 하지 않음
        return Jwts.builder()
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + REFRESH_EXPIRE_LENGTH))
                .signWith(secretKey, SignatureAlgorithm.HS256)
                .compact();
    }

    public String reGenereteAccessToken(HttpServletRequest request) { // 액세스 토큰 재발급
        String accessToken;
        String refreshToken = resolveRefreshToken(request);

        validateRefreshToken(refreshToken); // 만료 검사
        Claims claims = Jwts.claims().setSubject(String.valueOf(getUserIdFromToken(request))); // id 정보 가져오기
        accessToken = Jwts.builder().setClaims(claims)
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + ACCESS_EXPIRE_LENGTH))
                .signWith(secretKey, SignatureAlgorithm.HS256)
                .compact();

        return accessToken;
    }

    public String resolveAccessToken(HttpServletRequest request) {
        return request.getHeader("ACCESS-TOKEN");
    }

    public String resolveRefreshToken(HttpServletRequest request) {
        return request.getHeader("REFRESH-TOKEN");
    }

    public void validateAccessToken(String token) { // 만료 여부 반환
        try {
            Jws<Claims> claims = Jwts.parserBuilder().setSigningKey(secretKey)
                    .build().parseClaimsJws(token);
            claims.getBody().getExpiration().after(new Date(System.currentTimeMillis()));
        } catch (ExpiredJwtException ex) {
            log.error("토큰이 만료되었습니다.");
            throw new RuntimeException("토큰이 만료되었습니다.");
        }
    }

    public void validateRefreshToken(String token) {
        try {
            Jws<Claims> claims = Jwts.parserBuilder().setSigningKey(secretKey)
                    .build().parseClaimsJws(token);
            claims.getBody().getExpiration().after(new Date(System.currentTimeMillis()));
        } catch (ExpiredJwtException ex) {
            log.error("refresh 토큰이 만료되었습니다.");
            throw new RuntimeException("refresh 토큰이 만료되었습니다.");
        }
    }

    public Long getUserIdFromToken(HttpServletRequest request) { // 토큰에서 userId 정보 꺼내기
        String token = resolveAccessToken(request);
        Claims claims = Jwts.parserBuilder()
                .setSigningKey(secretKey)
                .build()
                .parseClaimsJws(token)
                .getBody();

        return Long.parseLong(claims.getSubject());
    }
}
  • 인증이 필요한 api 통신에서는 가장 먼저 토큰을 검사한다.
    // 예시 api
    public UserResponseDTO getUserInfo(HttpServletRequest request){
            tokenService.validateAccessToken(tokenService.resolveAccessToken(request)); // 만료 검사
            Long userId = tokenService.getUserIdFromToken(request);
            User user = userRepository.findById(userId)
                    .orElseThrow(() -> new RuntimeException("존재하지 않는 유저입니다."));
            return new UserResponseDTO(user);
        }
    • 토큰이 만료됐다면 각 서비스들은 데이터 처리 없이 바로 만료 됐다는 에러 메시지를 반환한다.
  • 에러 메시지를 받은 프론트엔드에서 액세스 토큰 재발급을 신청한다.
    • 액세스 토큰을 재발급할 땐 만료된 액세스 토큰에서 userId 정보를 꺼내야하기 때문에 액세스와 리프레시 토큰 두 개가 다 필요하다.
  • 리프레쉬 토큰까지 만료됐다면 에러 메시지를 보내 프론트엔드에서 클라이언트를 재로그인하도록 한다.

4. OAuth2FailureHandler

@Slf4j
@Component
public class OAuth2FailureHandler implements AuthenticationFailureHandler {
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        // 인증 실패 시 아래 주소로 리다이렉트
        response.sendRedirect("http://localhost:3000/login/fail");
    }
}

인증 실패를 알리는 페이지로 리다이렉트한다.

TEST

임시 수정 사항 및 참고 사항

  • application-jwt.yml 임시 수정

    jwt:
      secret-key: ${JWT_SECRET_KEY}
      token:
        access-expire-length: 180000 # 3분
        refresh-expire-length: 600000 # 10

    기간을 전부 기다려서 만료 테스트를 할 수는 없으므로 테스트를 위해서 임시로 시간을 짧게 설정해놓고 진행했다.

  • 헤더명
    기존에 X-AUTH-TOKEN으로 사용되던 것을 ACCESS-TOKEN과 REFRESH-TOKEN으로 정확히 명시했다.

토큰을 사용한 API 통신

  • 정상

  • 액세스 토큰 만료
    에러 처리 형식을 정하는 중이라 로그만 찍어두었다. 프론트엔드에게 토큰이 만료되었다는 메시지와 상태코드를 넘겨주어야한다.

액세스 토큰이 만료되어 재발급 요청을 받았을 때

  • 정상

  • refresh 토큰도 만료됨
profile
Backend

0개의 댓글