Contents
see ListSpring Security 6는 Spring Boot 3과 함께 대폭 개편되었다. 기존 WebSecurityConfigurerAdapter가 제거되고 SecurityFilterChain 빈 기반 설정으로 전환되었으며, 람다 DSL이 기본이 되었다. 이 글에서는 Spring Security 6에서 JWT 기반 인증/인가 시스템을 처음부터 구현하는 방법을 다룬다.
의존성 설정
<!-- pom.xml -->
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- JWT 라이브러리 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
</dependencies>
JWT 토큰 서비스
@Service
public class JwtTokenService {
@Value("${jwt.secret}")
private String secretKey;
@Value("${jwt.access-token-expiration:3600000}") // 1시간
private long accessTokenExpiration;
@Value("${jwt.refresh-token-expiration:604800000}") // 7일
private long refreshTokenExpiration;
private SecretKey getSigningKey() {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
return Keys.hmacShaKeyFor(keyBytes);
}
// Access Token 생성
public String generateAccessToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
claims.put("roles", userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.toList());
claims.put("type", "access");
return Jwts.builder()
.claims(claims)
.subject(userDetails.getUsername())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + accessTokenExpiration))
.signWith(getSigningKey())
.compact();
}
// Refresh Token 생성
public String generateRefreshToken(UserDetails userDetails) {
return Jwts.builder()
.claim("type", "refresh")
.subject(userDetails.getUsername())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + refreshTokenExpiration))
.signWith(getSigningKey())
.compact();
}
// 토큰에서 사용자명 추출
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
// 토큰 유효성 검증
public boolean isTokenValid(String token, UserDetails userDetails) {
final String username = extractUsername(token);
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
}
private boolean isTokenExpired(String token) {
return extractClaim(token, Claims::getExpiration).before(new Date());
}
private <T> T extractClaim(String token, Function<Claims, T> resolver) {
Claims claims = Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
return resolver.apply(claims);
}
}
JWT 인증 필터
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenService jwtService;
private final UserDetailsService userDetailsService;
public JwtAuthenticationFilter(JwtTokenService jwtService,
UserDetailsService userDetailsService) {
this.jwtService = jwtService;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// Authorization 헤더에서 토큰 추출
final String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
final String jwt = authHeader.substring(7);
try {
final String username = jwtService.extractUsername(jwt);
// SecurityContext에 인증 정보가 없을 때만 처리
if (username != null &&
SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtService.isTokenValid(jwt, userDetails)) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authToken.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
} catch (ExpiredJwtException e) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("{\"error\": \"토큰이 만료되었습니다\"}";
return;
} catch (JwtException e) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("{\"error\": \"유효하지 않은 토큰입니다\"}");
return;
}
filterChain.doFilter(request, response);
}
}
SecurityFilterChain 설정
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // @PreAuthorize 활성화
public class SecurityConfig {
private final JwtAuthenticationFilter jwtFilter;
private final UserDetailsService userDetailsService;
public SecurityConfig(JwtAuthenticationFilter jwtFilter,
UserDetailsService userDetailsService) {
this.jwtFilter = jwtFilter;
this.userDetailsService = userDetailsService;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
// CSRF 비활성화 (JWT 사용 시)
.csrf(csrf -> csrf.disable())
// 세션 사용하지 않음 (Stateless)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// URL별 접근 권한 설정
.authorizeHttpRequests(auth -> auth
// 공개 엔드포인트
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
// 관리자 전용
.requestMatchers("/api/admin/**").hasRole("ADMIN")
// 나머지는 인증 필요
.anyRequest().authenticated()
)
// JWT 필터를 UsernamePasswordAuthenticationFilter 앞에 추가
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
// 예외 처리
.exceptionHandling(ex -> ex
.authenticationEntryPoint((req, res, authEx) -> {
res.setStatus(401);
res.setContentType("application/json");
res.getWriter().write("{\"error\": \"인증이 필요합니다\"}");
})
.accessDeniedHandler((req, res, accessEx) -> {
res.setStatus(403);
res.setContentType("application/json");
res.getWriter().write("{\"error\": \"접근 권한이 없습니다\"}");
})
)
.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}
인증 컨트롤러
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthenticationManager authManager;
private final JwtTokenService jwtService;
private final UserDetailsService userDetailsService;
private final RefreshTokenRepository refreshTokenRepo;
// 로그인
@PostMapping("/login")
public ResponseEntity<TokenResponse> login(@RequestBody @Valid LoginRequest request) {
// 인증 수행
authManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.username(), request.password()));
UserDetails user = userDetailsService.loadUserByUsername(request.username());
String accessToken = jwtService.generateAccessToken(user);
String refreshToken = jwtService.generateRefreshToken(user);
// Refresh Token DB 저장
refreshTokenRepo.save(new RefreshToken(
request.username(), refreshToken,
Instant.now().plusMillis(604800000)));
return ResponseEntity.ok(new TokenResponse(accessToken, refreshToken));
}
// 토큰 갱신
@PostMapping("/refresh")
public ResponseEntity<TokenResponse> refresh(@RequestBody RefreshRequest request) {
String refreshToken = request.refreshToken();
String username = jwtService.extractUsername(refreshToken);
// DB에서 Refresh Token 검증
RefreshToken stored = refreshTokenRepo.findByToken(refreshToken)
.orElseThrow(() -> new RuntimeException("유효하지 않은 리프레시 토큰"));
if (stored.getExpiryDate().isBefore(Instant.now())) {
refreshTokenRepo.delete(stored);
throw new RuntimeException("리프레시 토큰이 만료되었습니다");
}
UserDetails user = userDetailsService.loadUserByUsername(username);
String newAccessToken = jwtService.generateAccessToken(user);
return ResponseEntity.ok(new TokenResponse(newAccessToken, refreshToken));
}
// 로그아웃
@PostMapping("/logout")
public ResponseEntity<Void> logout(@RequestBody RefreshRequest request) {
refreshTokenRepo.findByToken(request.refreshToken())
.ifPresent(refreshTokenRepo::delete);
return ResponseEntity.noContent().build();
}
}
// DTO Records
record LoginRequest(@NotBlank String username, @NotBlank String password) {}
record RefreshRequest(@NotBlank String refreshToken) {}
record TokenResponse(String accessToken, String refreshToken) {}
메서드 레벨 보안
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping("/me")
public ResponseEntity<UserDto> getCurrentUser(
@AuthenticationPrincipal UserDetails user) {
// 현재 인증된 사용자 정보
return ResponseEntity.ok(userService.getProfile(user.getUsername()));
}
@PreAuthorize("hasRole('ADMIN')")
@GetMapping
public ResponseEntity<List<UserDto>> getAllUsers() {
return ResponseEntity.ok(userService.findAll());
}
@PreAuthorize("#id == authentication.principal.id or hasRole('ADMIN')")
@PutMapping("/{id}")
public ResponseEntity<UserDto> updateUser(
@PathVariable Long id,
@RequestBody @Valid UpdateUserRequest request) {
return ResponseEntity.ok(userService.update(id, request));
}
}
설정 파일
# application.yml
jwt:
# openssl rand -base64 64 명령으로 생성
secret: "your-256-bit-secret-key-here-must-be-at-least-256-bits-long"
access-token-expiration: 3600000 # 1시간
refresh-token-expiration: 604800000 # 7일
spring:
security:
filter:
order: -100
Spring Security 6의 JWT 인증 구현은 SecurityFilterChain 빈 등록, JWT 필터 구현, 토큰 서비스 구현이 핵심이다. 특히 Refresh Token을 DB에 저장하여 로그아웃 시 무효화할 수 있도록 하고, Access Token은 짧은 만료 시간을 설정하는 것이 보안상 중요하다.