
액세스 토큰 하나만 사용했을 때, 만료 시간이 작으면 잦은 재로그인으로 사용자 경험의 수준을 낮추고, 만료 시간이 길면 탈취의 위험성을 높인다.
그래서, 유효 기간이 더 긴 리프레시 토큰을 추가로 사용한다.
클라이언트-서버 통신은 액세스 토큰으로 이루어지고 액세스 토큰이 만료됐을 경우 클라이언트는 리프레시 토큰을 전송해 새로운 액세스 토큰을 받는다.
리프레시 토큰까지 만료됐다면 그 때 재로그인을 실행한다.
"토큰이 다 만료됐을 때 재로그인 시켜주는 코드는 어떻게 짜는거지?"
리프레쉬 토큰이 만료가 됐을 때 서버가 직접 재로그인을 시켜주는 것이 아니다. 서버는 만료 메시지를 전달하고, 그 메시지를 받은 클라이언트 단에서 로그인 페이지로 이동시킨다. 서버는 만료 여부만을 반환한다.
프론트엔드가 만료된 토큰으로 api 요청을 했을 때 서버랑 프론트엔드랑 핑퐁핑퐁 하면서
BE: 이 토큰 만료됐다
FE: 그럼 리프레쉬 줄게 액세스 새거 줘
BE: 그래 새로 발급해줄게 / 리프레쉬도 만료됐다
FE: 그래 이제 이걸로 쓸게 / 아 그럼 재로그인 시켜야겠다
가 되는거다!

인증(auth)과 인가(jwt)에 사용되는 파일들이다.
인증이란?
: 유저가 누구인지 확인하는 절차, 회원가입하고 로그인 하는 것.인가란?
: 유저에 대한 권한을 허락하는 것.
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();
}
}
userInfoEndpoint() : 소셜 로그인 후 가져온 유저 정보를 처리한다..userInfoEndpoint(userInfoEndpoint -> userInfoEndpoint
.userService(OAuth2UserService)
)
사용자가 로그인을 한 후 가져온 유저 정보가 들어간다. 해당 프로젝트에서는 소셜 로그인만 다룬다.
successHandler() : 유저 정보를 가져오는 것을 성공하면 신규/기존 회원인지를 검사하고 적당한 처리를 한다..successHandler(oAuth2SuccessHandler)
failureHandler() : 유저 정보를 가져오는 것을 실패한 경우를 처리한다..failureHandler(oAuth2FailureHandler)
jwt:
secret-key: ${JWT_SECRET_KEY}
token:
access-expire-length: 43200000 # 12시간
refresh-expire-length: 604800000 # 7일
jwt 토큰의 비밀키와 유효기간을 설정한다.
@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로 불러와진다.
@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);
}
}


@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
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);
}@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");
}
}
인증 실패를 알리는 페이지로 리다이렉트한다.
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으로 정확히 명시했다.


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

