如何在Spring OAuth中为客户端不同资源配置差异化Scope安全策略
Awesome question—this kind of granular resource-scoped permissioning is exactly what teams need when outgrowing the default OAuth2 setup. Let’s walk through how to implement this in Spring Security, mirroring AWS IAM’s resource-specific permission logic.
1. Extend Client Details Storage (Core Change)
The default OAUTH_CLIENT_DETAILS table treats resource_ids and scope as flat lists, which can’t link specific scopes to specific resources. We need to add a join table to track this relationship, then customize how Spring loads client details.
Step 1: Add a New Database Table
Create a CLIENT_RESOURCE_SCOPES table to map clients to resources and their allowed scopes:
CREATE TABLE client_resource_scopes ( client_id VARCHAR(255) NOT NULL, resource_id VARCHAR(255) NOT NULL, scopes VARCHAR(255) NOT NULL, -- e.g., "user,admin" PRIMARY KEY (client_id, resource_id), FOREIGN KEY (client_id) REFERENCES oauth_client_details(client_id) );
For your example, insert:
INSERT INTO client_resource_scopes (client_id, resource_id, scopes) VALUES ('client1', 'resource1', 'user'), ('client1', 'resource2', 'admin,user');
Step 2: Customize ClientDetails Class
Create a custom ClientDetails implementation to hold the resource-to-scope mapping:
public class CustomClientDetails extends BaseClientDetails { private Map<String, Set<String>> resourceScopes; // Key: resource ID, Value: allowed scopes // Getters and setters public Map<String, Set<String>> getResourceScopes() { return resourceScopes; } public void setResourceScopes(Map<String, Set<String>> resourceScopes) { this.resourceScopes = resourceScopes; } }
Step 3: Override ClientDetailsService
Extend the default JDBC client details service to load the resource-specific scopes:
@Service public class CustomJdbcClientDetailsService extends JdbcClientDetailsService { private final JdbcTemplate jdbcTemplate; public CustomJdbcClientDetailsService(DataSource dataSource) { super(dataSource); this.jdbcTemplate = new JdbcTemplate(dataSource); } @Override public ClientDetails loadClientByClientId(String clientId) throws ClientRegistrationException { // Load base client details first CustomClientDetails clientDetails = jdbcTemplate.queryForObject( "SELECT client_id, client_secret, scope, authorized_grant_types, " + "web_server_redirect_uri, authorities, access_token_validity, " + "refresh_token_validity, additional_information, autoapprove " + "FROM oauth_client_details WHERE client_id = ?", new Object[]{clientId}, (rs, rowNum) -> { CustomClientDetails details = new CustomClientDetails(); details.setClientId(rs.getString("client_id")); details.setClientSecret(rs.getString("client_secret")); // Populate other default fields... return details; } ); // Load resource-specific scopes from the join table List<Map<String, Object>> resourceScopeRows = jdbcTemplate.queryForList( "SELECT resource_id, scopes FROM client_resource_scopes WHERE client_id = ?", clientId ); Map<String, Set<String>> resourceScopes = new HashMap<>(); for (Map<String, Object> row : resourceScopeRows) { String resourceId = (String) row.get("resource_id"); String scopesStr = (String) row.get("scopes"); Set<String> scopes = new HashSet<>(Arrays.asList(scopesStr.split(","))); resourceScopes.put(resourceId, scopes); } clientDetails.setResourceScopes(resourceScopes); return clientDetails; } }
2. Implement Resource-Specific Scope Validation
Next, we need to validate that the client’s token has the correct scope for the resource being accessed. Customize the token validation logic:
Step 1: Create a Custom Scope Validator
@Component public class ResourceSpecificScopeValidator implements OAuth2TokenValidator<OAuth2TokenValidationResult> { @Override public OAuth2TokenValidationResult validate(OAuth2TokenValidationContext context) { OAuth2Token token = context.getToken(); if (!(token instanceof OAuth2AccessToken accessToken)) { return OAuth2TokenValidationResult.success(); } // Get the current resource ID (we'll extract this from the request next) String currentResourceId = (String) context.getHttpRequest().getAttribute("current_resource_id"); if (currentResourceId == null) { return OAuth2TokenValidationResult.failure( new OAuth2Error("invalid_request", "Could not identify target resource", null) ); } // Fetch custom client details String clientId = accessToken.getClaimAsString("client_id"); ClientDetails clientDetails = context.getRegisteredClientRepository().findById(clientId); if (!(clientDetails instanceof CustomClientDetails customClientDetails)) { return OAuth2TokenValidationResult.failure( new OAuth2Error("invalid_client", "Client details not found", null) ); } // Check if the client has allowed scopes for this resource Set<String> allowedScopes = customClientDetails.getResourceScopes().get(currentResourceId); if (allowedScopes == null || allowedScopes.isEmpty()) { return OAuth2TokenValidationResult.failure( new OAuth2Error("insufficient_scope", "No scopes allowed for this resource", null) ); } // Verify token has at least one valid scope for the resource Set<String> tokenScopes = accessToken.getScopes(); boolean hasValidScope = tokenScopes.stream().anyMatch(allowedScopes::contains); if (!hasValidScope) { return OAuth2TokenValidationResult.failure( new OAuth2Error("insufficient_scope", "Invalid scope for target resource", null) ); } return OAuth2TokenValidationResult.success(); } }
Step 2: Extract the Current Resource ID
Add a filter to identify which resource the request is targeting (e.g., via URL path):
@Component public class ResourceIdExtractorFilter extends OncePerRequestFilter { private final Map<String, String> pathToResourceMap = Map.of( "/api/resource1/**", "resource1", "/api/resource2/**", "resource2" ); @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String requestPath = request.getRequestURI(); String resourceId = pathToResourceMap.entrySet().stream() .filter(entry -> new AntPathMatcher().match(entry.getKey(), requestPath)) .map(Map.Entry::getValue) .findFirst() .orElse(null); if (resourceId != null) { request.setAttribute("current_resource_id", resourceId); } filterChain.doFilter(request, response); } }
Step 3: Register Validator in Resource Server
Update your resource server config to use the custom validator:
@Configuration @EnableResourceServer public class ResourceServerConfig extends ResourceServerConfigurerAdapter { private final ResourceSpecificScopeValidator scopeValidator; private final ResourceIdExtractorFilter resourceIdFilter; public ResourceServerConfig(ResourceSpecificScopeValidator scopeValidator, ResourceIdExtractorFilter resourceIdFilter) { this.scopeValidator = scopeValidator; this.resourceIdFilter = resourceIdFilter; } @Override public void configure(HttpSecurity http) throws Exception { http.addFilterBefore(resourceIdFilter, UsernamePasswordAuthenticationFilter.class) .authorizeRequests() .antMatchers("/api/**").authenticated(); } @Override public void configure(ResourceServerSecurityConfigurer resources) throws Exception { resources.tokenServices(customTokenServices()); } @Bean public ResourceServerTokenServices customTokenServices() { DefaultTokenServices tokenServices = new DefaultTokenServices(); tokenServices.setTokenStore(jdbcTokenStore()); // Use your existing token store tokenServices.setTokenValidators(List.of(scopeValidator)); return tokenServices; } @Bean public TokenStore jdbcTokenStore(DataSource dataSource) { return new JdbcTokenStore(dataSource); } }
3. Bonus: Method-Level Security
For even finer control, add a custom SpEL expression to check scopes per resource in your controllers:
@Component public class CustomSecurityExpressionRoot extends SecurityExpressionRoot implements MethodSecurityExpressionOperations { public CustomSecurityExpressionRoot(Authentication authentication) { super(authentication); } public boolean hasScopeForResource(String resourceId, String scope) { OAuth2Authentication auth = (OAuth2Authentication) getAuthentication(); CustomClientDetails clientDetails = (CustomClientDetails) auth.getOAuth2Request().getClientDetails(); Set<String> allowedScopes = clientDetails.getResourceScopes().get(resourceId); return allowedScopes != null && allowedScopes.contains(scope); } }
Then use it in your controller:
@RestController @RequestMapping("/api/resource1") public class Resource1Controller { @GetMapping("/users") @PreAuthorize("@customSecurityExpressionRoot.hasScopeForResource('resource1', 'user')") public List<User> getUsers() { // Your logic here } }
内容的提问来源于stack exchange,提问作者Joey Trang

