JWT 인증 흐름
1. 로그인 요청: 클라이언트가 ID/PW를 보냅니다. 2. 검증 및 토큰 발급: 서버는 DB 확인 후 유효하면 Access Token을 생성하여 반환합니다. 3. 토큰 저장: 클라이언트는 토큰을 LocalStorage나 Cookie에 저장합니다. 4. 인가 요청: 이후 요청 시 HTTP 헤더(Authorization: Bearer <Token>)에 토큰을 담아 보냅니다. 5. 필터 검증: 서버의 JWT 필터가 토큰 유효성을 검증하고 SecurityContext에 인증 정보를 저장합니다.
Refresh Token 인증 흐름
1. 로그인 성공: 서버는 Access Token(짧은 수명)과 Refresh Token(긴 수명)을 모두 발급합니다. 2. 저장: 클라이언트는 두 토큰을 저장하고, 서버는 Refresh Token을 DB(Redis)에 저장합니다. 3. Access Token 만료: 클라이언트가 만료된 토큰으로 요청하면 서버는 401 Unauthorized를 응답합니다. 4. 토큰 재발급 요청: 클라이언트는 저장해둔 Refresh Token을 서버로 보냅니다. 5. 검증 및 재발급: 서버는 DB의 토큰과 대조하여 유효하면 새로운 Access Token을 발급합니다.
의존성 추가
dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'io.jsonwebtoken:jjwt-api:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' }
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final AuthenticationEntryPoint authenticationEntryPoint;
private final AccessDeniedHandler accessDeniedHandler;
// 비밀번호 암호화 (소셜로그인 + JWT 혼합 환경에서도 필요)
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/user/signup", "/user/login", "/user/reissue").permitAll()
.requestMatchers(
"/v3/api-docs/**", // API 명세 JSON 데이터
"/swagger-ui/**", // Swagger UI HTML 페이지
"/swagger-ui.html" // 구버전 호환용
).permitAll()
.anyRequest().authenticated()
)
.exceptionHandling(exception -> exception
.accessDeniedHandler(accessDeniedHandler)
.authenticationEntryPoint(authenticationEntryPoint)
)
// CSRF: REST API는 보통 비활성화
.csrf(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
// 세션 사용하지 않음 (JWT 기반)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 필터 체인에 JWT 필터 추가
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
// CORS 정책 정의
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("*")); // 허용할 도메인
configuration.setAllowedMethods(List.of("GET","POST","PUT","DELETE","OPTIONS"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setAllowCredentials(true); // 인증 정보 허용 (쿠키 등)
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
위를 통해, UsernamePasswordAuthenticationFilter가 동작하기 전에 OncePerRequestFilter을 구현한 JwtAuthenticationFilter가 동작한다.
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
private final UserDetailsService userDetailsService;
@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
String path = request.getRequestURI();
return path.startsWith("/swagger") ||
(path.startsWith("/user") && !path.equals("/user/logout"))
;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try{
String token = resolveToken(request);
if (token == null) {
filterChain.doFilter(request, response);
return;
}
if (jwtTokenProvider.validateToken(token)) {
String userId = jwtTokenProvider.getUserId(token).toString();
UserDetails userDetails = userDetailsService.loadUserByUsername(userId);
// SecurityContext에 인증 정보 설정
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (JwtException e) {
// 예외 발생 시 request에 메시지 저장
request.setAttribute("exception", e.getMessage());
}
filterChain.doFilter(request, response);
}
/**
* 요청 헤더에서 JWT 토큰 추출
* Authorization: Bearer <JWT>
*/
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
@Component
@RequiredArgsConstructor
public class JwtTokenProvider {
private final Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256);
private final long accessTokenValidityMs = 1000L * 60 * 60; // 1시간
private final long refreshTokenExpiration = 1000 * 60 * 60 * 24; // 24시간
private final RedisService redisService;
public JwtTokenDto generateToken(User user) {
long now = System.currentTimeMillis();
// 액세스 토큰 생성
String accessToken = Jwts.builder()
.claim("category", "access")
.claim("id", user.getId())
.claim("userRole", user.getUserRole())
.setIssuedAt(new Date(now))
.setExpiration(new Date(now + accessTokenValidityMs))
.signWith(key)
.compact();
// 리프레시 토큰 생성
String refreshToken = Jwts.builder()
.claim("category", "refresh")
.claim("id", user.getId())
.claim("userRole", user.getUserRole())
.setIssuedAt(new Date(now))
.setExpiration(new Date(now + refreshTokenExpiration))
.signWith(key)
.compact();
return new JwtTokenDto(accessToken, refreshToken);
}
public void isExpired(String token){
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
} catch (ExpiredJwtException e) {
// 여기서 만료 에러 던짐
throw new JwtException("EXPIRED_TOKEN");
}
}
public boolean isValidToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (Exception e) {
return false;
}
}
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (SecurityException | MalformedJwtException e) {
throw new JwtException("INVALID_TOKEN");
} catch (ExpiredJwtException e) {
throw new JwtException("EXPIRED_TOKEN");
} catch (UnsupportedJwtException e) {
throw new JwtException("UNSUPPORTED_TOKEN");
} catch (IllegalArgumentException e) {
throw new JwtException("EMPTY_TOKEN");
}
}
public Long getUserId(String accessToken){
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(accessToken)
.getBody()
.get("id", Long.class);
}
}
@RequiredArgsConstructor
@RestController
@Tag(name = "User(유저)")
@RequestMapping("/user")
public class UserController {
private final CreateUserUseCase createUserUsecase;
private final LoginUserUseCase loginUserUseCase;
private final LogoutUserUseCase logoutUserUseCase;
private final ReissueUseCase reissueUseCase;
@Operation(summary = "회원가입")
@PostMapping("/signup")
public ResponseEntity<Void> createUser(@RequestBody SignInRequest request){
CreateUserCommand createUserCommand = new CreateUserCommand(request.name(), request.username(), request.password());
createUserUsecase.createUser(createUserCommand);
return ResponseEntity.noContent().build();
}
@Operation(summary = "로그인")
@PostMapping("/login")
public ResponseEntity<TokenResponse> login(@RequestBody LoginRequest request){
LoginUserCommand loginUserCommand = new LoginUserCommand(request.username(), request.password());
TokenResponse tokenResponse = loginUserUseCase.login(loginUserCommand);
return ResponseEntity.ok(tokenResponse);
}
@Operation(summary = "로그아웃")
@PostMapping("/logout")
public ResponseEntity<Void> logout(@AuthenticationPrincipal CustomUserDetails userDetails){
logoutUserUseCase.logout(userDetails.getUser());
return ResponseEntity.noContent().build();
}
@Operation(summary = "토큰 재발급")
@PostMapping("/reissue")
public ResponseEntity<TokenResponse> reissue(@RequestHeader("refreshToken") String refreshToken){
TokenResponse response = reissueUseCase.reissue(refreshToken);
return ResponseEntity.ok(response);
}
}
회원가입, 로그인, 로그아웃, 토큰 재발급 등을 구현했다.
@RequiredArgsConstructor
@Service
public class UserService implements CreateUserUseCase, LoginUserUseCase, LogoutUserUseCase, ReissueUseCase {
private final LoadUserPort loadUserPort;
private final SaveUserPort saveUserPort;
private final PasswordEncoder passwordEncoder;
private final JwtTokenProvider jwtTokenProvider;
private final RedisService redisService;
@Override
public void createUser(CreateUserCommand command) {
// 1. 중복 검사 (비즈니스 규칙)
if (loadUserPort.existsByUsername(command.username())) {
throw new RuntimeException("이미 존재하는 아이디입니다.");
}
User user = User.create(
command.name(),
command.username(),
passwordEncoder.encode(command.password()),
UserRole.USER);
saveUserPort.save(user);
}
@Override
public TokenResponse login(LoginUserCommand command) {
User user = loadUserPort.findByUsername(command.username());
if(!passwordEncoder.matches(command.password(), user.getPassword())){
throw new RuntimeException("비밀번호가 일치하지 않습니다.");
}
JwtTokenDto jwtTokenDto = jwtTokenProvider.generateToken(user);
String redisKey = "refreshToken:" + user.getId();
redisService.setData(redisKey, jwtTokenDto.getRefreshToken(), 1000L * 60 * 60 * 24);
return TokenResponse.from(jwtTokenDto);
}
@Override
public void logout(User user) {
redisService.deleteData("refreshToken:" + user.getId());
}
@Override
public TokenResponse reissue(String refreshToken) {
try{
jwtTokenProvider.validateToken(refreshToken);
Long userId = jwtTokenProvider.getUserId(refreshToken);
// Redis에 저장된 토큰과 일치하는지 확인
String savedToken = (String) redisService.getData("refreshToken:" + userId);
if (savedToken == null || !savedToken.equals(refreshToken)) {
throw new RuntimeException("유효하지 않은 재발급 요청입니다.");
}
// 4. 새로운 토큰 세트 생성
User user = loadUserPort.findById(userId);
JwtTokenDto newTokens = jwtTokenProvider.generateToken(user);
// 5. Redis 값 업데이트 (RTR 전략: 기존 토큰 덮어쓰기)
redisService.setData("refreshToken:" + userId, newTokens.getRefreshToken(), 1000L * 60 * 60 * 24);
return TokenResponse.from(newTokens);
}catch (JwtException e){
throw new RuntimeException(e.getMessage());
}
}
}




에러 전역 처리 미적용 등, 미흡한 부분이 많지만 시큐리티를 다뤄봤다. 내부 동작 구조 등 자세하게 한 번 개념을 다시 잡아봐야 할 것 같다.
공부할 때 참고하겠습니다!