Spring Security OAuth2 Resource Server란?
Spring Security 6에서 JWT 기반 인증을 구현하는 표준 방법은 spring-boot-starter-oauth2-resource-server를 사용하는 것이다. 직접 JWT 파싱 로직을 작성하는 대신, Spring이 제공하는 Resource Server 추상화를 활용하면 토큰 검증, 권한 매핑, 에러 응답까지 선언적으로 처리할 수 있다.
이 글에서는 JWT 발급부터 Resource Server 설정, 커스텀 권한 매핑, Method Security, 테스트 전략까지 실무에서 바로 적용할 수 있는 수준으로 다룬다.
의존성과 기본 설정
// build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
}
// application.yml
spring:
security:
oauth2:
resourceserver:
jwt:
# Keycloak / Auth0 등 외부 IdP 사용 시
issuer-uri: https://auth.example.com/realms/myapp
# 또는 직접 JWK Set URI 지정
# jwk-set-uri: https://auth.example.com/.well-known/jwks.json
issuer-uri만 설정하면 Spring이 자동으로 /.well-known/openid-configuration에서 JWK Set URI를 조회하고, 공개키를 캐싱하여 JWT 서명을 검증한다. 별도의 필터나 인터셉터 코드가 필요 없다.
SecurityFilterChain 구성
Spring Security 6에서는 WebSecurityConfigurerAdapter가 제거되었다. 컴포넌트 기반 SecurityFilterChain 빈을 등록하는 방식을 사용한다.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**", "/actuator/health").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/**").authenticated()
.anyRequest().denyAll()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
)
.exceptionHandling(ex -> ex
.authenticationEntryPoint((request, response, authException) -> {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.getWriter().write(
"{"error":"unauthorized","message":""
+ authException.getMessage() + ""}");
})
)
.build();
}
}
핵심 포인트: 세션을 STATELESS로 설정하고, CSRF를 비활성화한다. JWT 기반 API 서버는 상태를 서버에 저장하지 않으므로 세션과 CSRF가 불필요하다.
JWT 클레임 → Spring 권한 매핑
실무에서 가장 중요한 부분이다. IdP마다 역할(Role) 정보를 JWT에 담는 방식이 다르다. Keycloak은 realm_access.roles, Auth0는 커스텀 클레임, AWS Cognito는 cognito:groups를 사용한다. JwtAuthenticationConverter로 이를 통일한다.
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter authoritiesConverter =
new JwtGrantedAuthoritiesConverter();
// scope_ 접두사 대신 ROLE_ 사용
authoritiesConverter.setAuthorityPrefix("ROLE_");
// 기본 "scope" 대신 커스텀 클레임 경로 지정
authoritiesConverter.setAuthoritiesClaimName("roles");
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(authoritiesConverter);
converter.setPrincipalClaimName("preferred_username");
return converter;
}
// Keycloak처럼 중첩된 클레임 구조를 처리해야 할 때
@Bean
public JwtAuthenticationConverter keycloakJwtConverter() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
if (realmAccess == null) return Collections.emptyList();
@SuppressWarnings("unchecked")
Collection<String> roles = (Collection<String>) realmAccess.get("roles");
return roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))
.collect(Collectors.toList());
});
return converter;
}
| IdP | 역할 클레임 경로 | 매핑 방식 |
|---|---|---|
| Keycloak | realm_access.roles |
중첩 Map 수동 파싱 |
| Auth0 | 커스텀 네임스페이스 (예: https://myapp/roles) |
authoritiesClaimName 지정 |
| AWS Cognito | cognito:groups |
authoritiesClaimName 지정 |
| 자체 발급 | 자유 설계 (예: roles) |
authoritiesClaimName 지정 |
자체 JWT 발급: 로그인 엔드포인트 구현
외부 IdP 없이 직접 JWT를 발급하는 경우, nimbus-jose-jwt 라이브러리를 활용한다.
@Service
public class TokenService {
private final RSAPrivateKey privateKey;
private final RSAPublicKey publicKey;
public String generateToken(UserDetails userDetails) {
Instant now = Instant.now();
Duration expiry = Duration.ofHours(1);
String scope = userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(" "));
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer("self")
.issuedAt(now)
.expiresAt(now.plus(expiry))
.subject(userDetails.getUsername())
.claim("roles", scope)
.build();
JwsHeader header = JwsHeader.with(SignatureAlgorithm.RS256).build();
return new NimbusJwtEncoder(
new ImmutableJWKSet<>(new JWKSet(
new RSAKey.Builder(publicKey).privateKey(privateKey).build()
))
).encode(JwtEncoderParameters.from(header, claims)).getTokenValue();
}
}
// 로그인 컨트롤러 (공개 엔드포인트)
@RestController
@RequestMapping("/api/public")
public class AuthController {
@PostMapping("/login")
public ResponseEntity<TokenResponse> login(
@RequestBody LoginRequest request) {
Authentication auth = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getUsername(), request.getPassword()));
String token = tokenService.generateToken(
(UserDetails) auth.getPrincipal());
return ResponseEntity.ok(new TokenResponse(token, 3600));
}
}
자체 발급 시 issuer-uri 대신 public-key-location으로 공개키를 직접 지정한다:
spring:
security:
oauth2:
resourceserver:
jwt:
public-key-location: classpath:keys/public.pem
Method Security: 메서드 레벨 권한 제어
URL 패턴 매칭만으로는 세밀한 권한 제어가 어렵다. @PreAuthorize와 @PostAuthorize로 비즈니스 로직 레벨의 인가를 구현한다.
@Configuration
@EnableMethodSecurity // Spring Security 6 방식
public class MethodSecurityConfig {}
@Service
public class OrderService {
// 주문 생성은 USER 이상
@PreAuthorize("hasRole('USER')")
public Order createOrder(OrderRequest request) { ... }
// 자기 주문만 조회 가능
@PreAuthorize("#userId == authentication.name")
public List<Order> getOrders(String userId) { ... }
// 반환값 기반 필터링 (주문 소유자만 볼 수 있음)
@PostAuthorize("returnObject.userId == authentication.name " +
"or hasRole('ADMIN')")
public Order getOrder(Long orderId) { ... }
// 커스텀 SpEL: 팀 멤버 확인
@PreAuthorize("@teamService.isMember(#teamId, authentication.name)")
public void updateTeamSettings(Long teamId, TeamSettings settings) { ... }
// 컬렉션 필터링
@PostFilter("filterObject.department == authentication.principal.claims['dept']")
public List<Employee> getAllEmployees() { ... }
}
JWT 검증 커스터마이징: Validator 추가
기본 검증(서명, 만료시간, issuer) 외에 커스텀 검증 로직을 추가할 수 있다. 예를 들어, 특정 audience가 포함되었는지, 토큰이 블랙리스트에 있는지 등을 검사한다.
@Bean
public JwtDecoder jwtDecoder() {
NimbusJwtDecoder decoder = NimbusJwtDecoder
.withPublicKey(publicKey).build();
// 복합 검증기 조합
OAuth2TokenValidator<Jwt> withIssuer =
JwtValidators.createDefaultWithIssuer("https://auth.example.com");
OAuth2TokenValidator<Jwt> audienceValidator = token -> {
if (token.getAudience().contains("myapp-api")) {
return OAuth2TokenValidatorResult.success();
}
return OAuth2TokenValidatorResult.failure(
new OAuth2Error("invalid_audience", "Missing required audience", null));
};
// 토큰 블랙리스트 검증 (로그아웃 처리)
OAuth2TokenValidator<Jwt> blacklistValidator = token -> {
if (tokenBlacklistService.isBlacklisted(token.getId())) {
return OAuth2TokenValidatorResult.failure(
new OAuth2Error("token_revoked", "Token has been revoked", null));
}
return OAuth2TokenValidatorResult.success();
};
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
withIssuer, audienceValidator, blacklistValidator));
return decoder;
}
Refresh Token 전략
Access Token의 수명은 짧게(15분~1시간), Refresh Token은 길게(7일~30일) 설정하는 것이 표준이다. Refresh Token은 절대 JWT로 만들지 말고, 서버 측 저장소(DB/Redis)에 opaque 토큰으로 관리한다.
@Service
public class RefreshTokenService {
private final RefreshTokenRepository repository;
public RefreshToken createRefreshToken(String username) {
RefreshToken token = RefreshToken.builder()
.token(UUID.randomUUID().toString())
.username(username)
.expiryDate(Instant.now().plus(Duration.ofDays(7)))
.build();
return repository.save(token);
}
public String rotateRefreshToken(String oldToken) {
RefreshToken existing = repository.findByToken(oldToken)
.orElseThrow(() -> new TokenRefreshException("Invalid refresh token"));
if (existing.getExpiryDate().isBefore(Instant.now())) {
repository.delete(existing);
throw new TokenRefreshException("Refresh token expired");
}
// Rotation: 기존 토큰 삭제 → 새 토큰 발급
repository.delete(existing);
return createRefreshToken(existing.getUsername()).getToken();
}
}
// /api/public/refresh 엔드포인트
@PostMapping("/refresh")
public ResponseEntity<TokenResponse> refresh(
@RequestBody RefreshRequest request) {
String newRefreshToken = refreshTokenService
.rotateRefreshToken(request.getRefreshToken());
String newAccessToken = tokenService
.generateToken(loadUserByRefreshToken(newRefreshToken));
return ResponseEntity.ok(
new TokenResponse(newAccessToken, newRefreshToken, 3600));
}
Refresh Token Rotation은 보안 필수 패턴이다. 매번 갱신 시 이전 토큰을 무효화하면, 탈취된 토큰의 재사용을 탐지할 수 있다.
테스트: @WithMockUser와 JWT 모킹
@WebMvcTest(OrderController.class)
@Import(SecurityConfig.class)
class OrderControllerSecurityTest {
@Autowired
private MockMvc mockMvc;
// 간단한 역할 테스트
@Test
@WithMockUser(roles = "USER")
void userCanCreateOrder() throws Exception {
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("{"item":"laptop","qty":1}"))
.andExpect(status().isCreated());
}
// JWT 클레임까지 세밀하게 제어
@Test
void adminCanAccessAdminEndpoint() throws Exception {
mockMvc.perform(get("/api/admin/users")
.with(jwt()
.jwt(builder -> builder
.claim("preferred_username", "admin01")
.claim("roles", List.of("ADMIN"))
)
.authorities(new SimpleGrantedAuthority("ROLE_ADMIN"))
))
.andExpect(status().isOk());
}
// 인증 없이 접근 시 401
@Test
void unauthenticatedReturns401() throws Exception {
mockMvc.perform(get("/api/orders"))
.andExpect(status().isUnauthorized());
}
// 권한 부족 시 403
@Test
@WithMockUser(roles = "USER")
void userCannotAccessAdmin() throws Exception {
mockMvc.perform(get("/api/admin/users"))
.andExpect(status().isForbidden());
}
}
운영 체크리스트
- 키 관리: RSA 키 쌍은 환경변수나 Vault로 주입한다. 절대 소스코드에 포함하지 않는다
- 토큰 수명: Access Token 15~60분, Refresh Token 7~30일이 권장값이다
- HTTPS 필수: JWT는 Base64 인코딩일 뿐 암호화가 아니다. 반드시 TLS 위에서 전송한다
- 클레임 최소화: JWT payload에 민감 정보(비밀번호, 카드번호)를 넣지 않는다
- 로그아웃: JWT는 서버에서 무효화할 수 없으므로, 블랙리스트 또는 짧은 수명 + Refresh Token 조합을 사용한다
- CORS 설정:
CorsConfigurationSource빈으로 허용 Origin을 명시적으로 제한한다 - 관측성: Micrometer로 인증 실패율, 토큰 만료 비율을 모니터링한다
Spring Security OAuth2 Resource Server는 JWT 기반 인증의 복잡성을 대부분 추상화해준다. 핵심은 클레임 매핑을 IdP에 맞게 정확히 설정하는 것과, Refresh Token Rotation으로 토큰 탈취에 대응하는 것이다. Resilience4j와 결합하면 IdP 장애 시 폴백 처리까지 완성할 수 있다.