토큰관련
토큰 관련해서 글을 작성해보려한다
토큰에는 보통 Access Token, Refresh Token이 있고 AccessToken의 경우 만료시간이 15분남짓이기 때문에 유효한지, 존재하는 사용자인지만 체크하고 다른 조치는 취하지 않는다
Refresh Token은 오로지 로그인상태를 유지하기 위해 존재하는것으로 액세스토큰과 로직은 비슷하지만 14일정도의 만료시간을 가진다. 보관시간이 길기때문에 따로 관리를 해주는 토큰이고 나는 Redis를 사용해 TTL을 14일로 맞추어 저장하고 비교하는식으로 구현하였다
@PostMapping("/refresh")
public ResponseEntity<?> refresh(HttpServletRequest request) {
String refreshToken = resolveToken(request);
if (refreshToken == null || !jwtUtil.validateToken(refreshToken)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body("Refresh token이 유효하지 않습니다.");
}
String userId = jwtUtil.getUserIdFromToken(refreshToken);
// Redis 등에서 저장된 refreshToken이 실제로 유효한지 비교
if (!refreshTokenService.isValid(userId, refreshToken)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body("저장된 Refresh token과 일치하지 않습니다.");
}
// 새 accessToken 발급
String newAccessToken = jwtUtil.generateAccessToken(userId);
return ResponseEntity.ok(Map.of("accessToken", newAccessToken));
}위 코드에서 HttpServletRequest request는 http 요청 메시지 자체를 파싱한다 → 헤더를 볼수있음
따라서 Bearer Token에 담겨온 refresh 토큰이 유효한지 검증하고 Redis에 저장되어있는 유저의 refreshToken과 일치하는지 검증한후 새로운 accessToken을 발급한다
JWT 라이브러리를 통해 토큰을 생성, 검증, 추출할수 있는데 방법이 사뭇 복잡하다
public String generateAccessToken(String userId) {
return Jwts.builder()
.setSubject(userId)
.setExpiration(new Date(System.currentTimeMillis() + ACCESS_TOKEN_VALIDITY))
.signWith(Keys.hmacShaKeyFor(SECRET_KEY.getBytes()))
.compact();
}우선 위와 같이 토큰생성 및 검증 함수, 클래스를 정의해야한다
그리고 내가 JWT 토큰을 도입한 가장 큰이유는 사용자 정보를 토큰안에 주입할수 있어서인데, 보안상 이슈가 좀 있지만 그래서 사용자 ID 하나만 넣었다
이렇게 되면 클라이언트에서 사용자 정보를 따로 받을필요없이 필요한 정보만 받을수있다
토큰을 먼저 검증을 하는 config 파일이 있는데
@Configuration
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/**").permitAll() // 로그인/회원가입은 인증 없이
.anyRequest().authenticated() // 나머지는 토큰 필요
)
.formLogin(form -> form.disable())
.httpBasic(basic -> basic.disable())
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); // JWT 필터 등록
return http.build();
}
}위에서 검증을 먼저 수행하고 controller에 전달된다. 따라서 controller에서 http메시지를 통해 토큰을 받는다 한들 한번 이미 검증을 한 토큰을 다시 분석하는게 비효율적이다 생각해서 이전 검증단계에서 미리 user정보를 추출할수 없는지 찾아봤는데 → 당연히 방법이 있다
기존에는 UserDetails 클래스를 통해 username정도만 추출하는 수준이어서, 내가 원하는 바를 이루지 못했다. 따라서 CustomUserDetails 를 만들어서 저 토큰 검증하는 클래스 및 함수에 등록해주는 식으로 구현을 해야한다
public record CustomUserDetails(User user) implements UserDetails {
@Override
public String getUsername() {
return user.getUserName();
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority("ROLE_USER"));
}
...
}record를 사용하면 클래스에 담긴 모든 파라미터에 대해 불변 멤버변수, 생성자, Getter를 생성해준다
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final UserService userService;
@Autowired
public JwtAuthenticationFilter(JwtUtil jwtUtil, UserService userService) {
this.jwtUtil = jwtUtil;
this.userService = userService;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = resolveToken(request);
if (token != null && jwtUtil.validateToken(token)) {
String userId = jwtUtil.getUserIdFromToken(token);
User user = userService.findByUserId(Long.parseLong(userId))
.orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다."));
// CustomUserDetails로 wrapping
CustomUserDetails userDetails = new CustomUserDetails(user);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}위와 같이 CustomUserDetails에 내가 추출하길 원하는 user를 뽑아올수 있다 (근데 지금 생각해보니 매 요청마다 사용자조회를 하는거니까 비효율적이란 생각이 든다 필요할때만 그냥 토큰 정보를 파싱하여 유저정보를 불러오는게 나을거 같다) → 근데 이게 아니더라도 매번 조회해서 실제 존재하는 유저인지 확인은 하지 않나? 많은 성능 차이를 일으키려나? 잘모르겠다
위코드를 잘 살펴보면 마지막에 filterChain.doFilter 를 호출하는데 이는 필터링을 모두 마쳤으니 다음 단계로 이동한다는 뜻을 내포하고
토큰인증을 못하면 setAuthentication(authentication)이 세팅되지 않았기 때문에
이후 필터에서 막히게 된다
지피티 말로는
[요청]
↓
[JwtAuthenticationFilter] ← 우리가 만든 필터 (인증 정보 설정)
↓
[ExceptionTranslationFilter]
↓
[FilterSecurityInterceptor] ← 여기서 "인증됐나?" 검사
↓
[컨트롤러 (@GetMapping, @PostMapping 등)]
Spring자체적으로 FilterSecurityInterceptor 가 있는데 여기서 인증됐는지 검사한다고 한다
번외 (Redis) → RefreshTokenService
public class RefreshTokenService {
private final RedisTemplate<String, String> redisTemplate;
private final Long expireMs = 1000L * 60 * 60 * 24 * 14;
public void saveRefreshToken(String userId, String refreshToken) {
redisTemplate.opsForValue().set("RT:" + userId, refreshToken, expireMs, TimeUnit.MILLISECONDS);
}
public Boolean isValid(String userId, String refreshToken) {
return Objects.equals(redisTemplate.opsForValue().get("RT:" + userId), refreshToken);
}
public String getRefreshToken(String userId) {
return redisTemplate.opsForValue().get("RT:" + userId);
}
public void deleteRefreshToken(String userId) {
redisTemplate.delete("RT:" + userId);
}
}