진행하는 프로젝트에 로그인 기능을 구현하기 위해서 공부를 했다. 이전글들 참고 참고..
프로젝트에 적용한 내용을 정리하려 한다. (좀 많이 늦긴 했지만..)
1. Access Token과 Refresh Token
인증 방식은 크게 세션과 토큰으로 나뉜다.
1. 세션 기반
사용자 인증 정보가 서버 메모리에 저장되는 방식 (DB가 아닌 서버 자체 메모리에 저장된다)
2. 토큰 기반
인증 정보는 클라이언트가 직접 들고 있다. 서버에선 해당 정보를 관리하지 않는다.
이 중 토큰 기반의 인증 방식을 채택했다. 서버리스 인증이 가능해 확장성의 이점을 가지기 때문이다.
토큰 하나만으로 서버는 client가 유효한 사용자인지 알 수 있다. 그럼에도 왜 종류가 나뉘는 걸까?
일단 각각 알아보자.
Access Token
사용자의 인증 정보를 담는다. 토큰의 경우 별도의 암호화가 되어 있지 않아, 누구든 사용자 정보를 읽을 수 있다. 또한 서버는 토큰이 탈취되어도 해당 사실을 알 수 없다.
이런 이유로 access token은 유효 시간을 짧게 설정한다. 탈취되어도, 유효하지 않도록.
그러면 사용자들은 access token이 만료될 때 마다 매번 로그인을 해야 한다. (귀찮)
Refresh Token
이 문제에 대한 해결책이 refresh token이다.
refresh token은 access token 만료 시 재발급 과정에 사용된다.
즉, refresh token은 access token의 재발급을 위해서 사용되는 중간 장치다.
그래서 별 다른 정보를 담고 있지 않고, 식별의 용도로만 사용된다. (access token에 비해 유효기간이 길기 때문에 당연히 정보를 담고 있으면 보안 상 문제 있음) 나의 경우 랜덤 값을 저장했다.
그러면 어떻게 refresh token을 이용해서 access token 발급이 가능할까?

이런 과정을 거친다.
여기까진 쉬운데..
AT와 RT가 모두 탈취된 상황을 가정하면, RT를 통해서 AT를 무한 재발급 가능하다. 서버는 stateless기반이기에, 탈취된지도 모른 채 계속 인증을 허가해준다.
이를 방지하고자 Refresh Token Rotation을 사용한다.
2. Refresh Token Rotation
AT뿐만 아니라 RT도 같이 재발급한다. 이러면 탈취당해도 서버측에서 이를 알 수 있다. 이를 refresh token rotation이라 한다.
3. Filter를 통한 구현 방법

대략적인 설계는 다음과 같이 했다. 이제 하나하나 설명해볼게염..
client에게 온 요청에 대해서 RefreshRotationFilter 수행 (OneperRequestFilter implements)
OneperRequestFilter는 http Request에 대해서 무조건 한 번 지나는 필터이다.
@RequiredArgsConstructor
public class RefreshRotationFilter extends OncePerRequestFilter {
private final TokenRotationService tokenRotationService;
private static final String BEARER_PREFIX = "Bearer ";
private boolean skip(String path) {
// 인증이 필요하지 않은 url 적기
return path.startsWith("/auth/")
|| path.startsWith("/oauth2")
|| path.startsWith("/login/oauth2")
|| path.startsWith("/s3/upload")
|| path.startsWith("/explanation/")
|| path.startsWith("/problem-set/")
|| path.startsWith("/specific-explanation/")
|| path.startsWith("/generation");
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
if (skip(request.getRequestURI())) {
filterChain.doFilter(request, response);
return;
}
String auth = request.getHeader(HttpHeaders.AUTHORIZATION);
if (auth != null && auth.startsWith(BEARER_PREFIX)) {
String at = auth.substring(BEARER_PREFIX.length());
try {
JWT.require(Algorithm.HMAC512(JwtProperties.SECRET)).build().verify(at);
filterChain.doFilter(request, response);
return;
} catch (Exception e) { // refresh 진행
}
}
var rtCookie = CookieUtils.getCookie(request, "refresh_token").orElse(null);
if (rtCookie == null) {
filterChain.doFilter(request, response);
return;
}
try {
String newAt = tokenRotationService.rotateTokens(rtCookie.getValue(), response);
CustomHttpServletRequest customRequest = new CustomHttpServletRequest(request);
customRequest.putHeader(HttpHeaders.AUTHORIZATION, BEARER_PREFIX + newAt);
filterChain.doFilter(customRequest, response);
} catch (Exception e) {
filterChain.doFilter(request, response);
}
}
}
일단 skip 함수를 통해서 인증이 필요하지 않은 url에 대해선 수행하지 않는다. 인증이 필요한 url이 더 많기에 notSkip을 쓸까 했지만, 인증이 필요한 함수를 등록하지 않는 실수보단 인증이 필요하지 않은 함수를 등록하지 않는 실수가 덜 치명적이기에 이렇게 했다.
이후 doFilterInternal을 지난다. 해당 함수는 필터에서 request와 response를 대상으로 수행되는 함수이다.
1. 만일 AT가 유효하면 그냥 필터를 지나간다.
2. AT가 유효하지 않다면 AT를 생성한다. 이는 tokenRotationService를 통해서 생성된다.
RefreshRotationFilter이후 JwtTokenAuthenticationFilter를 통해서 유효성 검증 (BasicAuthentication extends)
이전 필터를 통해서 만들어진 OR Client에게서 온 AT가 유효한지 검증하는 필터이다. JwtTokenAuthenticationFilter는 권한, 인증이 필요한 경우 수행된다.
그럼 이 필터는 뭐가 권한, 인증이 필요한지 어떻게 아는가?
이는 SecurityConfig에서 등록한다. 이건 나중에 다시 설명...
public class JwtTokenAuthenticationFilter extends BasicAuthenticationFilter {
private final UserRepository userRepository;
private static final String BEARER_PREFIX = "Bearer ";
public JwtTokenAuthenticationFilter(AuthenticationManager authManager,
UserRepository userRepository) {
super(authManager);
this.userRepository = userRepository;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws IOException, ServletException {
String authorizationHeader = request.getHeader("Authorization");
if (authorizationHeader == null || !authorizationHeader.startsWith(BEARER_PREFIX)) {
chain.doFilter(request, response);
return;
}
String accessToken = authorizationHeader.substring(BEARER_PREFIX.length());
try {
var decoded = require(Algorithm.HMAC512(JwtProperties.SECRET)).build()
.verify(accessToken);
String userId = decoded.getClaim("userId").asString();
if (userId == null || userId.isBlank()) {
chain.doFilter(request, response);
return;
}
Authentication existing = SecurityContextHolder.getContext().getAuthentication();
if (existing != null && existing.isAuthenticated()) {
chain.doFilter(request, response);
return;
}
User user = userRepository.findById(userId).orElse(null);
if (user == null) {
chain.doFilter(request, response);
return;
}
String role = Objects.toString(user.getRole(), "ROLE_USER");
var authorities = List.of(new SimpleGrantedAuthority(role));
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(user, null,
authorities);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
chain.doFilter(request, response);
} catch (TokenExpiredException ex) {
chain.doFilter(request, response);
}
}
}
위 필터의 doFilterInternal는
1. Header에서 AccessToken을 꺼낸다 (만일 유효하지 않거나 없으면 pass)
2. 해당 값으로 JWT에서 userId를 꺼낸다.
3. 값을 토대로 userRepository (DB) 에서 user를 찾는다.
4. 이제 SecurityContextHolder에 Authentication을 세팅한다.
-> 이게 뭔말이냐?
일단 SecurityContextHolder는 Spring Security에서 사용자의 인증 상태를 보관하는 저장소이다. 각 스레드 별로 고유한 SecurityContext를 가지고, 이 안에 Authentication 객체가 있다.
Authentication에는 다음과 같은 정보가 있다.
1) principal -> 여기에 user가 담긴다.
2) credentials -> 여기에 자격증명 (비밀번호)가 담긴다. 하지만 나는 token으로 인증을 완료했으므로 이걸 null로 설정함
3) autorities -> 권한 목록이 담긴다.
그럼 이 Authentication 객체를 어떻게 활용하는가??
이후 사용자 정보 기반 서비스를 위해 사용된다. 권한 기반 접근 제어를 하거나, 사용자 정보 조회에 사용된다.
SecurityContextHolder.getContext().getAuthentication().getPrincipal()
이런 식으로
SecurityConfig는 어떻게 작성했을까?
@RequiredArgsConstructor
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final AuthenticationConfiguration authenticationConfiguration;
private final PrincipalOAuth2UserService principalOauth2UserService;
private final UserRepository userRepository;
private final TokenRotationService tokenRotationService;
private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
AuthenticationManager authenticationManager = authenticationConfiguration.getAuthenticationManager();
http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session.sessionCreationPolicy(
SessionCreationPolicy.STATELESS))
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.exceptionHandling(ex -> ex
.authenticationEntryPoint((request, response, authException) -> {
createUnauthorizedResponse(response);
})
.accessDeniedHandler((request, response, accessDeniedException) -> {
createForbiddenResponse(response);
})
)
.addFilterBefore(new RefreshRotationFilter(tokenRotationService),
JwtTokenAuthenticationFilter.class)
.addFilter(new JwtTokenAuthenticationFilter(authenticationManager, userRepository))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/statistics/**").authenticated() // 추후 통계 기능 인증 필요
.requestMatchers("/admin/**").hasRole("ADMIN") // 관리자 페이지
.requestMatchers("/test").authenticated() // test 용
.anyRequest().permitAll() // 나머지 모두 허용
)
.oauth2Login(oauth -> oauth
.userInfoEndpoint(user -> user
.userService(principalOauth2UserService)
)
.successHandler(oAuth2LoginSuccessHandler)
);
return http.build();
}
public void createUnauthorizedResponse(HttpServletResponse response) {
try {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(
"{\"message\": \"" + ExceptionMessage.UNAUTHORIZED.getMessage() + "\"}"
);
} catch (IOException e) {
}
}
public void createForbiddenResponse(HttpServletResponse response) {
try {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(
"{\"message\": \"" + ExceptionMessage.NOT_ENOUGH_ACCESS.getMessage() + "\"}"
);
} catch (IOException e) {
}
}
}
이렇게 작성했다.
.csrf(AbstractHttpConfigurer::disable)
는 CSRF를 disable한다는 코드이다.
그럼 여기서 CSRF는 뭔가?
CSRF(Cross Site Request Forgery) 사이트 간 요청 위조 공격이다. 사용자가 자신의 의지와 관계없이 공격자가 의도한 행위를 웹사이트에 요청하게 만드는 공격이다. JWT, OAuth2와 같이 토큰 기반 인증 시스템의 경우 세션을 유지하지 않는다. 따라서 세션에 대한 공격인 CSRF를 비활성화 해도 무방하다.
추가로 모든 에러는 Service에서 처리하도록 설계했다. 그러나 인증되지 않은 사용자로부터의 요청, 접근 권한이 없는 사용자로부터의 요청의 경우 Filter에서 처리가 필요했다. 지금 BE와 FE는 일관된 메세지 형식을 가지고 소통한다. 그냥 에러 메세지가 아닌 커스텀 에러 메세지를 전송해야겠기에, 다음과 같이 함수를 별도로 빼서 두 오류에 대해서만 에러 처리를 Filter에서 했다.
.addFilterBefore(new RefreshRotationFilter(tokenRotationService),
JwtTokenAuthenticationFilter.class)
.addFilter(new JwtTokenAuthenticationFilter(authenticationManager, userRepository))
이를 통해 Filter를 FilterChain에 올바르게 등록할 수 있다.
4. 토큰 회전
TokenRotationService
그럼 이제 Token을 회전시키자! 앞서 말했듯, 회전은 AccessToken 재발급 + RefreshToken 재발급이다.
@Service
@RequiredArgsConstructor
public class TokenRotationService {
private final RefreshTokenHandler refreshTokenHandler;
private final AccessTokenHandler accessTokenHandler;
public void issueTokens(String userId, HttpServletResponse response) {
String newRtPlain = refreshTokenHandler.issue(userId);
String newAt = accessTokenHandler.validateAndGenerate(userId);
response.setHeader(HttpHeaders.AUTHORIZATION, "Bearer " + newAt);
response.setHeader(HttpHeaders.SET_COOKIE,
CookieUtils.buildCookies(newRtPlain).toString());
}
public String rotateTokens(String refreshToken, HttpServletResponse response) {
var newRtCookie = refreshTokenHandler.validateAndRotate(refreshToken);
String newAt = accessTokenHandler.validateAndGenerate(newRtCookie.userId());
response.setHeader(HttpHeaders.AUTHORIZATION, "Bearer " + newAt);
response.setHeader(HttpHeaders.SET_COOKIE,
CookieUtils.buildCookies(newRtCookie.newRtPlain()).toString());
return newAt;
}
}
- issueTokens
새로운 RT, AT 발급을 위해 사용 됨.
일반 로그인 + OAuth2 로그인 성공 시 response의 값을 변경함 (Header + Cookie)
- rotationTokens
기존 RT 삭제 및 재발급, AT 발급을 위해 사용 됨.
필터를 통한 토큰 회전을 위해 사용 됨.
필터를 통한 회전 실패 시 client를 위한 별도의 엔드포인트를 제공한다. 이때 사용됨.
AccessTokenHandler
이전에 말했던 것처럼 에러는 서비스에서 모두 처리할 예정이었다.
그런데 토큰 발급에서 발생하는 에러는 Handler에서 처리해야 했다.
그러면 Handler에서 에러를 처리하기 VS Handler를 Service로 바꾸기
이런 고민거리가 생긴다..
일단 나는 Service로 초기에 만들었다. 이후 팀원들과 상의를 했다.
치열한 토의 끝에.. Handler로 변경하기로 했다. Handler에서 에러 처리를 하는 것 보다, Service가 Service를 호출하는 구조가 더 별로라는 의견이 있었기 때문이다. 이렇게는 생각 못했는데 맞는 말이라 변경했다.
@Component
@RequiredArgsConstructor
public class AccessTokenHandler {
private final UserRepository userRepository;
public String validateAndGenerate(String userId) {
return userRepository.findById(userId)
.map(user -> JWT.create()
.withSubject(user.getUserId())
.withClaim("userId", user.getUserId())
.withClaim("nickname", user.getNickname())
.withExpiresAt(
new Date(System.currentTimeMillis() + JwtProperties.ACCESS_EXPIRATION_TIME))
.sign(Algorithm.HMAC512(JwtProperties.SECRET)))
.orElseThrow(() -> new CustomException(ExceptionMessage.USER_NOT_FOUND));
}
}
AccessTokenHandler는 DB에서 사용자 정보를 찾은 후 JWT 토큰을 발급해 return한다.
RefreshTokenHandler
@Component
@RequiredArgsConstructor
public class RefreshTokenHandler {
private final StringRedisTemplate redis;
private final RtKeys rtKeys;
@Transactional
public String issue(String userId) {
try {
String rtPlain = TokenUtils.randomUrlSafe(64);
String rtHash = TokenUtils.sha256Hex(rtPlain);
String setKey = rtKeys.userSet(userId);
redis.opsForHash().put(rtHash, "userId", userId);
redis.opsForSet().add(setKey, rtHash);
redis.expire(rtHash, rtKeys.ttl());
redis.expire(setKey, rtKeys.ttl());
return rtPlain;
} catch (Exception e) {
System.out.println(e.getMessage());
throw new CustomException(ExceptionMessage.TOKEN_GENERATION_FAILED);
}
}
public record RotateResult(String userId, String newRtPlain) {
}
@Transactional
public RotateResult validateAndRotate(String oldRtPlain) {
String oldRtHash = TokenUtils.sha256Hex(oldRtPlain);
String userId = (String) redis.opsForHash().get(oldRtHash, "userId");
if (userId == null) {
throw new CustomException(ExceptionMessage.UNAUTHORIZED);
}
redis.delete(oldRtHash);
redis.opsForSet().remove(rtKeys.userSet(userId), oldRtHash);
String newRtPlain = issue(userId);
return new RotateResult(userId, newRtPlain);
}
@Transactional
public void revoke(String presentedRtPlain) {
String rtHash = TokenUtils.sha256Hex(presentedRtPlain);
String userId = (String) redis.opsForHash().get(rtHash, "userId");
if (userId == null) {
return;
}
redis.delete(rtHash);
redis.opsForSet().remove(rtKeys.userSet(userId), rtHash);
}
}
- issue
refreshtoken을 새발급한다. 발급 시 redis의 hash와 set에 값을 저장한다.
hash는 rthash와 userid를 각각 key:value 형태로 저장한다.
set은 setkey에 따라서 rtHash값을 추가한다.
왜 hash뿐만 아니라 set도 사용하는가?
이는 한 사용자에게 다양한 기기의 로그인을 지원하기 위함이다. 가끔 로그아웃 시 "모든 기기에서 로그아웃"이란 서비스를 본 적 있을거다. 이 경우 userId의 set에 저장된 rtHash값을 모두 삭제하는 거다. (처음 알았음..)
- validateAndRotate
일단 현재 refreshToken이 유효한지 본다. 유효하지 않으면 customException로 응답한다.
유효하면 redis에서 이를 삭제하고 (hash + set) issue를 이용해서 refreshToken은 새발급한다.
- revoke
로그 아웃 경우 refreshToken을 redis에서 삭제한다 (hash + set)
우리 서비스의 경우 모든 기기에서 로그아웃을 지원 할 필요가 없기에, 단일 refreshToken만 삭제한다.
5. 추가로 알게된 내용 + 알아본 내용 (경쟁 조건 문제)
나는 매일 유튜브를 들어간다. 밥친구 이기도 하고.. 노래듣기도 하고.. 암튼 자주 이용하는데 한 번도 로그인을 다시 한 적이 없다. 그에 반해 티스토리는 한 5일 정도 정기적으로 재로그인을 해줘야 한다. (대부분의 사이트가 그렇다) 아마 refreshToken의 유효기간이 되게 길지 않을까.. 생각해본다.
그런데 이렇게 refreshToken의 유효기간도 장기간 유지하게 되면, 탈취 위험성이 증가하고, refreshToken도 탈취가 되면 rotation으로 해결되지 않는 문제가 있다.
공격자가 사용자보다 먼저 refreshToken을 이용해서 accessToken을 발급하면 생기는 문제이다
이러면 redis에는 공격자의 refreshToken이 적히고, 이를 이용해서 무한 재발급이 가능하다. 기존 사용자는 당연히 그냥 재로그인하면 되지만, 공격자의 재발급이 문제이다.
이는 어떻게 해결할 수 있을까?
이 경우 redis의 key를 rtHash가 아닌, user_id로 저장하면 가능하다. 한 계정 당 하나의 RT만 허용하는 것이다. 서버에선 탈취가 감지되는 즉시 모든 토큰을 무효화 시키면 된다. 하지만 이 경우 여러 기기의 로그인은 지원하지 못한다.
우리 서비스의 특성 상 여러 기기 로그인이 더 사용자 편의성 측면에서 중요하다 생각했다. (공부를 하는 기기는 사용자 선호도에 따라 다양하다.) 그래서 보안 상 문제 보단 여러 기기 로그인을 지원하는 면을 선택했다.
유튜브의 경우.. 여러 기기 로그인과 이런 문제를 어떻게 동시에 해결하는지는 잘 모르겠다.
6. 후기
이런 저런 개념을 알게 될 수록 아! 이래서 이러구나! 하고 알게 되는 것들이 있다. 약간 뇌라는 돼지 저금통에 돈이 들어오는 기분이다. 개발 전 모든 팀원이 아 어려울텐데.. 했지만 뭐~ 그냥 공부하면서 하면 되는거 아냐? 했다. 하지만.. 공부 할 내용이 많고, 헷갈리기도 해서 구현이 많이 늦어졌다. 어렵지만 재미있는 경험이었다.
추가 참고 블로그
https://junior-datalist.tistory.com/352
Refresh Token Rotation 과 Redis로 토큰 탈취 시나리오 대응
I. 서론 JWT와 Session 비교 및 JWT의 장점 소개 II. 본론 Access Token과 Refresh Token의 도입 이유 Refresh Token 은 어떻게 Access Token의 재발급을 도와주는 걸까? Refresh Token Rotation Redis 저장 방식 변경 III. 결론
junior-datalist.tistory.com
위 블로그가 잘 적혀있어서 정말 도움 많이 됐다. (감사함당)
'개발 끄적끄적' 카테고리의 다른 글
| [JUnit5] 테스트 인스턴스 생성 시점? (0) | 2025.10.27 |
|---|---|
| [디자인 패턴] 추상 팩토리 패턴, 팩토리 메서드 패턴 (0) | 2025.10.25 |
| [Network + Spring] URL 관련 (0) | 2025.08.19 |
| [Spring] cookie, token, session (0) | 2025.08.08 |
| [Spring] Security - JWT 서버 구축 (4) | 2025.08.06 |