SpringSecurity OAuth2 (9) 自定义: 资源服务安全控制策略与动态权限实现

序言

在 SpringSecurity 专栏第五篇: SpringSecurity (5) SpringBoot + JWT + CSRF 动态权限的实现 中已经介绍了在"纯" SpringSecurity 下如何实现用户端的动态权限. 本章主要介绍如何在 SpringSecurity OAuth2 下, 实现客户端和用户端两端的动态权限校验.

设想有这么一个 Auth 模块:

  • 对于外部系统 (第三方客户端) 接入, 它能提供符合 OAuth2.0 标准的授权模式 - 授权码模式 (Authorization Code Grant);
  • 对于内部 (受信任的) 应用 (分离的前端 & 第一方后端服务), 它也具备符合场景的授权和认证能力:
    • 对于独立前端来说, Auth 是高度信任的, 其本身承载的用户登陆获取令牌的能力, 可以采用密码模式 (Resource Owner Password Grant). 这里的受限权限是用户权限, 对于客户端 (前端) 来说, 没有权限限制;
    • 对于后端服务间的通信, 本质上是服务与服务间的认证 (不会有用户介入) - 客户端模式 (Client Credentials Grant). 这里的受限权限是客户端权限, 没有用户介入;

☆ Authorization Code Grant 和 Resource Owner Password Grant 支持 refresh-token

对于 OAuth 2.0 标准中, 携带用户信息的授权模式, 不仅要认证用户端和客户端的身份信息, 还要对用户端和客户端鉴权: 用户有权限访问的资源, 不代表客户端端也有权限, 反之亦然.


目前, 我们的授权服务器的职能是认证 (Authentication), 核心是为第一方和第三方应用 (Client) 获取令牌提供支持, 其 “授权” 的意义体现在授予第三方应用访问后端服务的权限. 而真正意义上的鉴权 (Authorization), 即判定请求是不是能访问资源服务上的某一个具体的资源, 无法在授权服务器完成. 因为发往授权服务器的请求并没有指明资源 URL, 授权服务器不知道该请求获取令牌后会访问哪一个资源, 并且这类请求只是期望获取到可访问后端各个资源的令牌. 真正的鉴权, 应该由各个资源服务独立完成 (况且, 各个资源完全可能有符合自身业务特点的安全策略).

也就是说, 资源服务器只负责鉴权, 认证由授权服务器负责.

在这里插入图片描述

所以接下来, 本文着眼于如何在资源服务器上实现动态权限. 与 SpringSecurity (5) SpringBoot + JWT + CSRF 动态权限的实现 区别的是, 不仅要考虑用户端, 还需要考虑客户端的权限.

时序:

在这里插入图片描述

AuthorizationServer

缓存权限元数据

设想一下, 我们要实现动态权限, 最终目的就是用户被重新授权了, 安全控制策略能及时应用新的权限数据. 按照 「序言」 的分析, 鉴权的职能在资源服务器, 而如果每次请求进来, 资源服务器都要去数据库查询一次权限元数据, 这显然不合理也不高效. 且权限数据的变更频率不会很高, 再者, 每次请求都需要权限数据, 属于热点数据 - 所以, 权限元数据应当被缓存.

权限元数据缓存逻辑应当放到授权服务器. 为什么? 因为对于整个后端应用来说, 权限元数据是统一的, 放到各个资源服务器并不合理. 最理想的方式是随着授权服务器启动就放入缓存.

如果用户更改权限数据怎么办? 很简单, 如果授权变更, 刷新缓存就好了呀.


SpringBoot 提供了 ApplicationRuner 接口来实现在服务成功启动后执行某一段用户定义的逻辑.

按照总结的思路, 授权服务器端, 新增一个 Runner:

/**
 * Description: 资源地址元数据初始化<br>
 * Details: 授权服务器启动的时候, 从数据源里加载访问控制香瓜你的元数据, 并放入缓存. 供资源服务器调用
 *
 * @author LiKe
 * @version 1.0.0
 * @date 2020-08-03 14:33
 */
@Order(1)
@Slf4j
@Component
public class ResourceAddressMetadataInitializer implements ApplicationRunner {

    /**
     * metadata.resource-address 缓存前缀
     */
    private static final String CACHE_PREFIX_METADATA_RESOURCE_ADDRESS = "metadata.resource-address";

    private RedisService redisService;

    // ~ ResourceAddress Mappers
    // -----------------------------------------------------------------------------------------------------------------

    private ClientAccessScopeMapper clientAccessScopeMapper;

    private ClientAuthorityMapper clientAuthorityMapper;

    private UserAuthorityMapper userAuthorityMapper;

    // =================================================================================================================

    @Override
    public void run(ApplicationArguments args) {
        log.info("Resource address metadata initializing ...");

        final RedisService.Hash redisServiceOpsForHash = redisService.hash();

        redisServiceOpsForHash.putAll(
                RedisKey.builder()
            		.prefix(CACHE_PREFIX_METADATA_RESOURCE_ADDRESS)
		            .suffix(ClientAccessScopeResourceAddressMapping.CACHE_SUFFIX).build(),
                clientAccessScopeMapper.composeClientAccessScopeResourceAddressMapping().stream().collect(Collectors.toMap(
                        ClientAccessScopeResourceAddressMapping::getClientAccessScopeName,
                        ClientAccessScopeResourceAddressMapping::getResourceAddress
                ))
        );
        log.debug("==============================================================================");
        log.debug("|            Metadata: ClientAccessScope - ResourceAddress Cached            |");
        log.debug("==============================================================================");

        redisServiceOpsForHash.putAll(
                RedisKey.builder()
            		.prefix(CACHE_PREFIX_METADATA_RESOURCE_ADDRESS)
            		.suffix(ClientAuthorityResourceAddressMapping.CACHE_PREFIX).build(),
                clientAuthorityMapper.composeClientAuthorityResourceAddressMapping().stream().collect(Collectors.toMap(
                        ClientAuthorityResourceAddressMapping::getClientAuthorityName,
                        ClientAuthorityResourceAddressMapping::getResourceAddress
                ))
        );
        log.debug("==============================================================================");
        log.debug("|             Metadata: ClientAuthority - ResourceAddress Cached             |");
        log.debug("==============================================================================");

        redisServiceOpsForHash.putAll(
                RedisKey.builder()
            		.prefix(CACHE_PREFIX_METADATA_RESOURCE_ADDRESS)
            		.suffix(UserAuthorityResourceAddressMapping.CACHE_PREFIX).build(),
                userAuthorityMapper.composeUserAuthorityResourceAddressMapping().stream().collect(Collectors.toMap(
                        UserAuthorityResourceAddressMapping::getUserAuthorityName,
                        UserAuthorityResourceAddressMapping::getResourceAddress
                ))
        );
        log.debug("==============================================================================");
        log.debug("|              Metadata: UserAuthority - ResourceAddress Cached              |");
        log.debug("==============================================================================");

        log.info("Resource address metadata initialized.");
    }

    // ~ Autowired
    // -----------------------------------------------------------------------------------------------------------------

    @Autowired
    public void setRedisService(RedisService redisService) {
        this.redisService = redisService;
    }

    @Autowired
    public void setClientAccessScopeMapper(ClientAccessScopeMapper clientAccessScopeMapper) {
        this.clientAccessScopeMapper = clientAccessScopeMapper;
    }

    @Autowired
    public void setClientAuthorityMapper(ClientAuthorityMapper clientAuthorityMapper) {
        this.clientAuthorityMapper = clientAuthorityMapper;
    }

    @Autowired
    public void setUserAuthorityMapper(UserAuthorityMapper userAuthorityMapper) {
        this.userAuthorityMapper = userAuthorityMapper;
    }
}

从客户端访问范围, 客户端职权和用户端职权三个维度考虑, 数据结构采用 Hash. 这里依赖我们之前构建的 Starter, 请参考: SpringBoot 自动配置 (2) - 自己写个 Starter 二次封装 spring-boot-starter-data-redis.

每一个维度的权限元数据, 都是由其编码与资源的键值对存储. 这样就能描述权限与其可访问资源的对应关系.

还有一个问题, 假设我们后端有 N 个资源服务, 如何来标识一个资源的唯一? 这里我们定义了资源地址 (ResourceAddress) 的概念, ResourceAddress = Endpoint@ResourceServerId. 这样, 就可以唯一标识具体资源服务的端点了.

由于 Redis 的 Hash 本身是一个 mapmap 的结构, 即外层 map 的 value 也是一个 map. 所以以访问范围举例: 缓存 key 约定为: metadata.resource-address.client-access-scope, value 为子 map, entry 形如 ACCESS-SCOPE:/resource/access@resource-server. 这样, 在资源服务器中以访问范围的维度获取的实际上就是描述访问范围与其可访问资源地址的对应关系.

JWT 构建策略

现在回想一下我们之前做的 AuthorizationServer, 用户详情 UserDetails 和 客户端详情 ClientDetails 都有组织 authorities 属性, 表示当前用户和客户端的权限.

大致流程:

  1. 用户 / 客户端去授权服务器申请令牌;
  2. 如果身份认证通过, 授权服务器颁发令牌并构建出 fullyAuthenticatedOAuth2Authentication, 置入 JWT 中;
  3. 用户 / 客户端携带令牌访问资源服务器, 资源服务器验证签名, 解析令牌, 获得这个 OAuth2Authentication;
  4. 资源服务器用 OAuth2Authentication 中的权限信息决定当前请求是否有权限访问这个资源;

来回顾一下令牌创建的流程:

AuthorizationServerTokenServices#createAccessToken(OAuth2Authentication) - JwtAccessTokenConverter#enhance(OAuth2AccessToken, OAuth2Authentication) - DefaultAccessTokenConverter#convertAccessToken(OAuth2AccessToken, OAuth2Authentication).

最终, 如果用户没有自定义令牌转换器, 则会使用默认的 DefaultAccessTokenConverter 将令牌对象和身份认证对象转换成 Map. 并且这个 Map 将作为我们 JWT 的 Payload 颁发给客户端 (参考源码: JwtAccessTokenConverter#encode(OAuth2AccessToken, OAuth2Authentication)). 以下是 DefaultAccessTokenConverter 核心转换方法的源代码:

public class DefaultAccessTokenConverter implements AccessTokenConverter {
    
    // ...
    
	public Map<String, ?> convertAccessToken(OAuth2AccessToken token, OAuth2Authentication authentication) {
		Map<String, Object> response = new HashMap<String, Object>();
		OAuth2Request clientToken = authentication.getOAuth2Request();

		if (!authentication.isClientOnly()) {
			response.putAll(userTokenConverter.convertUserAuthentication(authentication.getUserAuthentication()));
		} else {
			if (clientToken.getAuthorities()!=null && !clientToken.getAuthorities().isEmpty()) {
				response.put(UserAuthenticationConverter.AUTHORITIES,
							 AuthorityUtils.authorityListToSet(clientToken.getAuthorities()));
			}
		}

		if (token.getScope()!=null) {
			response.put(SCOPE, token.getScope());
		}
		if (token.getAdditionalInformation().containsKey(JTI)) {
			response.put(JTI, token.getAdditionalInformation().get(JTI));
		}

		if (token.getExpiration() != null) {
			response.put(EXP, token.getExpiration().getTime() / 1000);
		}
		
		if (includeGrantType && authentication.getOAuth2Request().getGrantType()!=null) {
			response.put(GRANT_TYPE, authentication.getOAuth2Request().getGrantType());
		}

		response.putAll(token.getAdditionalInformation());

		response.put(CLIENT_ID, clientToken.getClientId());
		if (clientToken.getResourceIds() != null && !clientToken.getResourceIds().isEmpty()) {
			response.put(AUD, clientToken.getResourceIds());
		}
		return response;
	}

	// ...
    
}

我们知道在 OAuth 2.0 标准中, 客户端授权模式是不需要用户端介入的, 而构建出的令牌也和其他模式不同. 在当前我们构建的 AuthorizationServer 中, 尝试去申请令牌, 并 Base64 解码其中 Payload 部分, 可以看到拿到的是如下格式数据:

※ 客户端模式的令牌 Payload 部分:

{
    "aud": [
        "resource-server"
    ],
    "scope": [
        "ACCESS_RESOURCE"
    ],
    "exp": 1596046911,
    "authorities": [
        "THIRD_PARTY_CLIENT"
    ],
    "jti": "3c62043a-5f89-46fd-84e1-d9032173c886",
    "client_id": "client-a"
}

※ 其他模式的令牌 Payload, 相比客户端模式, 多了 user_name 属性, 并且 authorities 也是指向的用户权限, 而非客户端权限:

{
    "aud": [
        "resource-server"
    ],
    "user_name": "caplike",
    "scope": [
        "ACCESS_RESOURCE"
    ],
    "exp": 1596048195,
    "authorities": [
        "USER"
    ],
    "jti": "44bd2b96-3cd0-49b0-b5e0-88c3583d4dfb",
    "client_id": "client-a"
}

同时用令牌去访问资源服务器, 可以看到资源服务器解析出的 OAuth2Authentication 结构也有区别:

※ 客户端模式:

{
    "authenticated": true,
    "authorities": [
        {
            "authority": "THIRD_PARTY_CLIENT"
        }
    ],
    "clientOnly": true,
    "credentials": "",
    "details": {
        "remoteAddress": "0:0:0:0:0:0:0:1",
        "tokenType": "Bearer",
        "tokenValue": "<JWT>"
    },
    "name": "client-a",
    "oAuth2Request": {
        "approved": true,
        "authorities": [
            {
                "$ref": "$.authorities[0]"
            }
        ],
        "clientId": "client-a",
        "extensions": {},
        "refresh": false,
        "requestParameters": {
            "client_id": "client-a"
        },
        "resourceIds": [
            "resource-server"
        ],
        "responseTypes": [],
        "scope": [
            "ACCESS_RESOURCE"
        ]
    },
    "principal": "client-a"
}

※ 其他 (需要用户介入的) 模式:

{
    "authenticated": true,
    "authorities": [
        {
            "authority": "USER"
        }
    ],
    "clientOnly": false,
    "credentials": "",
    "details": {
        "remoteAddress": "0:0:0:0:0:0:0:1",
        "tokenType": "Bearer",
        "tokenValue": "<JWT>"
    },
    "name": "caplike",
    "oAuth2Request": {
        "approved": true,
        "authorities": [],
        "clientId": "client-a",
        "extensions": {},
        "refresh": false,
        "requestParameters": {
            "client_id": "client-a"
        },
        "resourceIds": [
            "resource-server"
        ],
        "responseTypes": [],
        "scope": [
            "ACCESS_RESOURCE"
        ]
    },
    "principal": "caplike",
    "userAuthentication": {
        "authenticated": true,
        "authorities": [
            {
                "$ref": "$.authorities[0]"
            }
        ],
        "credentials": "N/A",
        "name": "caplike",
        "principal": "caplike"
    }
}

可以看到:

  • 对于不需要用户介入的授权模式, 构建的 OAuth2Authentication authorities 对应的值是客户端的权限, clientOnly = true;
  • 对于密码模式这样的需要用户认证的模式, authorities 对应的就是用户的权限, 并且会忽略客户端对象 (oauth2Request 的 authorities), clientOnly = false.

所以, 对于资源服务来说, 我们可以采用这样的策略鉴权:

  1. 对于 isClientOnly 为 true 时构建的 OAuth2Authentication, 限制必须是 “第一方客户端后端应用”. 仅进行 (客户端的) 权限校验: 判断请求的客户端是不是有足够的权限访问这个资源服务器的资源;

    (并且原则上不允许第三方客户端使用 client_credentials 方式请求授权)

  2. 对于 isClientOnly 为 false 时构建的 OAuth2Authentication:

    1. 如果是第一方应用的前端, 仅校验用户权限. 从客户端角度来看, 第一方应用的前端 (也被看做一个客户端) 有权限访问当前后端的所有资源;
    2. 其他情况, 第三方客户端的访问请求: 不仅要校验用户权限, 还要校验第三方客户端访问范围 (Scope);

关于 SCOPE:

Scope 是 OAuth 2.0 标准里提出的用于限制客户端对用户资源访问范围的概念. 一个客户端可以申请一个或者多个 Scope, 这个信息会被呈现在用户确认允许的界面上 , 接着客户端会被授予对应着用户允许的 Scope 的令牌. 这样客户端只可以访问这些范围内的资源.

☞ Scope 是在有用户介入的场景下才有意义的, 标识的是客户端可以访问的 “用户资源”; 注意: 有别于 Authorities (在没有用户介入时, 是客户端的可访问的当前资源服务的资源集).

Reference: OAuth Scopes, The OAuth 2.0 Authorization Framework#Access Token Scope

接下来, 我们详细讨论如何在资源服务器上实现动态权限…

权限体系结构

因为我们需要一个同时具备用户端和客户端两端的身份认证能力的 Auth. 所以较 SpringSecurity 的动态权限实现 (并且 SpringSecurity 的实现并没有区分 AuthorizationServer 和 ResourceServer), SpringSecurity OAuth2 的实现需要考虑用户侧和客户端侧的权限体系. 为此, 我们大致需要如下几张表:

☞ 资源

  • RESOURCE: 资源定义表. 所有资源端点都定义在这张表. 各个资源服务启动的时候也会扫描自身可访问的资源, 初始化这张表. 这张表以资源服务器 ID 和资源端点标识唯一一个可访问的资源.
  • RESOURCE_SERVER: 资源服务器定义表. 持有资源服务器的信息.

☞ 客户端

  • CLIENT: 这是客户端定义表, 所有第三方客户端在申请接入的时候, 身份信息都会存入这张表. 表结构基本和 org.springframework.security.oauth2.provider.ClientDetails 一致, 有个别非简单属性例如 authorities, 存在关联表里. 下面会介绍到.

  • CLIENT_AUTHORITY: 客户端职权定义表. 该表定义了客户端职权的详情结构. 客户可以有多个职权, 每一个职权代表了一簇该客户可访问的资源集.

  • MAPPING_CLIENT_TO_CLIENT_AUTHORITY: 维护客户端到客户端职权的映射关系.

  • MAPPING_CLIENT_TO_RESOURCE_SERVER: 维护客户端到资源服务器的映射关系. 标识了一个客户端可访问的资源服务器.

  • MAPPING_CLIENT_AUTHORITY_TO_RESOURCE: 维护客户端职权到可访问资源的映射关系. 标识了一个客户端职权代表的一簇资源集.

  • CLIENT_ACCESS_SCOPE: 客户端访问范围定义. 在有用户介入的授权模式中, 客户端的访问范围标识客户端可以访问的用户的资源范围. (注意与客户端权限在使用场景上的区别)

  • MAPPING_CLIENT_TO_CLIENT_ACCESS_SCOPE: 维护客户端到客户端访问范围的映射关系. 一个客户端端访问范围标识了该客户端可以访问的用户的资源的范围.

  • MAPPING_CLIENT_ACCESS_SCOPE_TO_RESOURCE: 维护客户端访问范围到资源的映射关系.

☞ 用户端

  • USER: 用户定义表. 保存了用户的基本信息. 授权服务器将这张表作为查询主表并关联必要信息构建出用户详情对象: org.springframework.security.core.userdetails.UserDetails;
  • USER_AUTHORITY: 用户端职权定义表. 该表定义了用户端职权的详情结构. 用户可以有多个职权, 每一个职权代表了一簇该用户可访问的资源集.
  • MAPPING_USER_TO_USER_AUTHORITY: 维护用户到用户职权的映射关系. 标识了一个用户对应的用户职权 (可以多个);
  • MAPPING_USER_AUTHORITY_TO_RESOURCE: 维护用于职权到资源的映射关系. 标识了一个用户职权可访问的一簇资源集.

☞ 相关 SQL 及初始化数据请查看 代码仓库里 ./demo-spring-security-oauth2/doc/sql/*

ResourceServer

之前的文章已经介绍了如何在 SpringSecurity 中实现动态权限. 对于 SpringSecurity OAuth2 来说, 区别就是这里需要处理的是 OAuth2Authentication.

引言

OAuth2Authentication

OAuth2Authentication 是 SpringSecurity OAuth2 定义的, 支持 OAuth2 的 SpringSecurity 认证对象. 其主要由两部分组成:

  1. 一个是 OAuth2Request storedRequest: 表示客户端认证对象;

  2. 一个是 Authentication userAuthentication: 表示客户端认证对象, 但是有些授权模式 (如客户端授权), 并不需要用户认证, 所以这个对象可能是空;

☀ isClientOnly: 当 userAuthentication 为空时, isClientOnly 标识也为 true;

☀ authenticated: 如果身份已经被认证, 则会置为 true. 同时 AbstractSecurityInterceptor 也不会再次将 SecurityContextHolder 中的 Authentication 作为参数传入 AuthenticationManager.authenticate(Authentication) 进行认证 (ref: AbstractSecurityInterceptor#authenticateIfRequired);

☀ principal: 如果 userAuthentication 为空, 它的值就是客户端 ID; 否则就是 userAuthentication 的 principal (ref: Authentication#getPrincipal);

在这里插入图片描述

SpringSecurity 中的 “访问控制”

访问控制, 也被称作 “鉴权”. 在 SpringSecurity 中, 有一个名为 AccessDecisionManager 的接口, 它负责访问控制的决策. AccessDecisionManager 的 decide 方法接收三个参数, 它们分别是:

  1. 携带了当前请求信息的 Authentication 对象;
  2. 一个 “安全对象”: Secure Object, SpringSecurity 用这个术语指代任何有安全属性的对象, 一般来讲是方法调用对象和网络请求对象;
  3. 一个 “配置属性” (譬如有权限访问当前资源的角色) 的列表: Configuration Attribute. 在安全框架内部, 由 ConfigAttribute 接口标识. 可以是简单的角色名, 或承载更复杂的含义 (取决于实际应用场景中, AccessDecisionManager 的复杂程度). AbstractSecurityInterceptor 被配置了一个 SecurityMetadataSource 源, 后者用于从 “安全对象” 中构建 “配置属性”;

每一类 SpringSecurity 的 “安全对象” 都有继承于 AbstractSecurityInterceptor 的专属拦截器. SpringSecurity 保证, 只要 AbstractSecurityInterceptor 被调用, SecurityContextHolder 就会持有一个有效的 Authenticatio 对象 (如果被认证).

在这里插入图片描述

AbstractSecurityInterceptor 提供了处理安全请求的流程, 通常地包含以下几个方面:

  1. 查找与当前请求关联的 ''配置属性" (Configuration Attributes);
  2. 提交 “安全对象”, Authentication 对象 和 “配置属性” 到 AccessDecisionManager 用作访问控制决策;
  3. 可选地, 更改 Authentication (ref: RunAsManager);
  4. 允许安全对象的调用继续执行 (假设访问被允许);
  5. 如果配置了 AfterInvocationManager, 调用它 (如果前序环节正常执行 (没有抛出异常));

ObjectPostProcessor

SpringSecurity 的 Java 配置没有公开每个配置对象的每一个属性, 这么做的目的是简化用户需要关注的配置项. 但是仍然有使用到高级配置的场景, 为此 SpringSecurity 引入了 ObjectPostProcessor 的概念, 用于修改或替换 Java 配置的对象实例. 例如像在 FilterSecurityInterceptor 里配置 filterSecurityPublishAuthorizationSuccess, 你可以这么做:

@Override 
protected void configure(HttpSecurity http) throws Exception {
 http.authorizeRequests().anyRequest().authenticated()
         .withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
             public <O extends FilterSecurityInterceptor> O postProcess(O fsi) {
                 fsi.setPublishAuthorizationSuccess(true);
                 return fsi;
             }
         });
}

动态权限实现

综上所述, 在资源服务器中, 我们需要利用 SpringSecurity 提供的 ObjectPostProcessor 机制, 为 FilterSecurityInterceptor 配置 SecurityMetadataSourceAccessDecisionManager 来解析 OAuth2Authentication 以完成针对每一次访问请求的 (Incoming Request) 动态权限控制.

先来看看资源服务的代码结构:

~/DEMO-SPRING-SECURITY\DEMO-SPRING-SECURITY-OAUTH2\DYNAMIC-AUTHORIZATION-RESOURCE-SERVER
│  pom.xml
│  README.md
│
├─doc
│  └─diagram
│          diagram-network-topology.png
│          diagram-network-topology.vsdx
│          Security interceptors and the SECURE OBJECT model.png
│
└─src
   └─main
       ├─java
       │  └─c
       │      └─c
       │          └─d
       │              └─s
       │                  └─s
       │                      └─o
       │                          └─da
       │                              └─rs
       │                                  │  DynamicAuthorizationResourceServer.java
       │                                  │
       │                                  ├─configuration
       │                                  │  │  CustomAuthenticationEntryPoint.java
       │                                  │  │  ResourceServerConfiguration.java
       │                                  │  │
       │                                  │  └─support
       │                                  │      ├─accesscontrol
       │                                  │      │      CustomAccessDecisionManager.java
       │                                  │      │      CustomFilterInvocationSecurityMetadataSource.java
       │                                  │      │      FilterSecurityInterceptorPostProcessor.java
       │                                  │      │      package-info.java
       │                                  │      │
       │                                  │      ├─response
       │                                  │      │      ResponseWrapper.java
       │                                  │      │      SecurityResponse.java
       │                                  │      │
       │                                  │      └─token
       │                                  │              CustomResourceServerTokenServices.java
       │                                  │
       │                                  └─controller
       │                                          ResourceController.java
       │
       └─resources
               application.yml
               authorization-server.pub

其中 accesscontrol 包为访问控制相关代码, 该包内的代码与资源服务器其他代码和逻辑都是没耦合的, 方便我们后期将其封装成一个 Starter. 考虑到这不是本文着重点, 所以不在这里详述. 稍后来做. 可以先参考之前的博文: SpringBoot 自动配置 (1) - 自定义简单 Starter (Gradle), SpringBoot 自动配置 (2) - 自己写个 Starter 二次封装 spring-boot-starter-data-redis.

下面让我们来实现 ResourceServer 的访问控制.

ResourceServerConfiguration

最核心的还是要从资源服务器的配置类入手, 上一篇 (SpringSecurity OAuth2 (8) 自定义: ResourceServerTokenServices 资源服务器自行验证签名并解析令牌) 比起来, 配置方式与 SpringSecurity (5) SpringBoot + JWT + CSRF 动态权限的实现 一致, 仍然是需要为 HttpSecurity 配置上 ObjectPostProcessor 最终是为了为 FilterSecurityInterceptor 设置 SecurityMetadataSourceAccessDecisionManager.

关于 FilterSecurityInterceptor:

FilterSecurityInterceptor 是一个针对 HTTP 资源执行安全控制的过滤器实现. 如 「SpringSecurity 中的 “访问控制”」 一章所述, 这个拦截器的 SecurityMetadataSourceFilterInvocationSecurityMetadataSource.

FilterSecurityInterceptor 继承于 AbstractSecurityInterceptor, 后者定义了对所有安全对象的安全处理方法. 特别地, 在其 beforeInvocation 方法中, 你可以看到从 SecurityMetadataSource 获取配置属性并提交给 AccessDecisionManager 的逻辑.

完整的 ResourceServerConfiguration 代码如下, 大部分设定在 上一篇 已经介绍过, 不再赘述. 重点关注 public void configure(HttpSecurity http), 其中配置了本文已多次提及的 ObjectPostProcessor:

/**
 * 资源服务器配置
 *
 * @author LiKe
 * @version 1.0.0
 * @date 2020-06-13 20:55
 */
@Slf4j
@Configuration
@EnableResourceServer
public class ResourceServerConfiguration extends ResourceServerConfigurerAdapter {

    /**
     * 资源服务器保存的持有公钥的文件名
     */
    private static final String AUTHORIZATION_SERVER_PUBLIC_KEY_FILENAME = "authorization-server.pub";

    /**
     * 资源服务器 ID
     */
    public static final String RESOURCE_ID = "resource-server";

    /**
     * 授权服务器的 {@link org.springframework.security.oauth2.provider.endpoint.TokenKeyEndpoint} 供资源服务器请求授权服务器获取公钥的端点<br>
     * 在资源服务器中, 可以有两种方式获取授权服务器用于签名 JWT 的私钥对应的公钥:
     * <ol>
     *     <li>本地获取 (需要公钥文件)</li>
     *     <li>请求授权服务器提供的端点 (/oauth/token_key) 获取</li>
     * </ol>
     */
    private static final String AUTHORIZATION_SERVER_TOKEN_KEY_ENDPOINT_URL = "http://localhost:18957/token-customize-authorization-server/oauth/token_key";

    // =================================================================================================================

    /**
     * {@link CustomAuthenticationEntryPoint}
     */
    private AuthenticationEntryPoint authenticationEntryPoint;

    /**
     * {@link c.c.d.s.s.o.da.rs.configuration.support.accesscontrol.CustomAccessDecisionManager}
     */
    private AccessDecisionManager accessDecisionManager;

    /**
     * {@link c.c.d.s.s.o.da.rs.configuration.support.accesscontrol.CustomFilterInvocationSecurityMetadataSource}
     */
    private FilterInvocationSecurityMetadataSource filterInvocationSecurityMetadataSource;

    // ~ ResourceServerSecurityConfigurer configure
    // -----------------------------------------------------------------------------------------------------------------

    @Override
    public void configure(ResourceServerSecurityConfigurer resources) {
        // @formatter:off
        resources.resourceId(RESOURCE_ID).stateless(true);

        // ~ 指定 ResourceServerTokenServices
        resources.tokenServices(new CustomResourceServerTokenServices(jwtAccessTokenConverter()));

        // ~ AuthenticationEntryPoint. ref: OAuth2AuthenticationProcessingFilter
        resources.authenticationEntryPoint(authenticationEntryPoint);
        // @formatter:on
    }

    // ~ TokenStore

    /**
     * Description: 为签名验证和解析提供转换器<br>
     * Details: 看起来 {@link org.springframework.security.jwt.crypto.sign.RsaVerifier} 已经被标记为过时了, 究其原因, 似乎 Spring 已经发布了一个新的产品 Spring Authorization Server, 有空再研究.
     *
     * @see <a href="https://github.com/spring-projects/spring-security/wiki/OAuth-2.0-Migration-Guide">OAuth 2.0 Migration Guide</a>
     * @see <a href="https://spring.io/blog/2020/04/15/announcing-the-spring-authorization-server">Announcing the Spring Authorization Server</a>
     * @see JwtAccessTokenConverter
     */
    @SuppressWarnings("deprecation")
    private JwtAccessTokenConverter jwtAccessTokenConverter() {
        final JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter();
        jwtAccessTokenConverter.setVerifier(new org.springframework.security.jwt.crypto.sign.RsaVerifier(retrievePublicKey()));
        return jwtAccessTokenConverter;
    }

    /**
     * Description: 获取公钥 (Verifier Key)<br>
     * Details: 启动时调用
     *
     * @return java.lang.String
     * @author LiKe
     * @date 2020-07-22 11:45:40
     */
    private String retrievePublicKey() {
        final ClassPathResource classPathResource = new ClassPathResource(AUTHORIZATION_SERVER_PUBLIC_KEY_FILENAME);
        try (
                // ~ 先从本地取读取名为 authorization-server.pub 的公钥文件, 获取公钥
                final BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(classPathResource.getInputStream()))
        ) {
            log.debug("{} :: Retrieve public key locally ...", RESOURCE_ID);
            return bufferedReader.lines().collect(Collectors.joining("\n"));
        } catch (IOException e) {
            // ~ 如果本地没有, 则尝试通过授权服务器的 /oauth/token_key 端点获取公钥
            log.debug("{} :: 从本地获取公钥失败: {}, 尝试从授权服务器 /oauth/token_key 端点获取 ...", RESOURCE_ID, e.getMessage());
            final RestTemplate restTemplate = new RestTemplate();
            final String responseValue = restTemplate.getForObject(AUTHORIZATION_SERVER_TOKEN_KEY_ENDPOINT_URL, String.class);

            log.debug("{} :: 授权服务器返回原始公钥信息: {}", RESOURCE_ID, responseValue);
            return JSON.parseObject(JSON.parseObject(responseValue).getString("data")).getString("value");
        }
    }

    // =================================================================================================================

    // ~ HttpSecurity configure
    // -----------------------------------------------------------------------------------------------------------------

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests().anyRequest().authenticated()
                // ~ 动态权限设置
                .withObjectPostProcessor(new FilterSecurityInterceptorPostProcessor(accessDecisionManager, filterInvocationSecurityMetadataSource));
    }

    // =================================================================================================================

    // ~ Autowired
    // -----------------------------------------------------------------------------------------------------------------

    @Autowired
    public void setAuthenticationEntryPoint(@Qualifier("customAuthenticationEntryPoint") AuthenticationEntryPoint authenticationEntryPoint) {
        this.authenticationEntryPoint = authenticationEntryPoint;
    }

    @Autowired
    public void setAccessDecisionManager(@Qualifier("customAccessDecisionManager") AccessDecisionManager accessDecisionManager) {
        final CustomAccessDecisionManager customAccessDecisionManager = (CustomAccessDecisionManager) accessDecisionManager;
        customAccessDecisionManager.setResourceId(RESOURCE_ID);
        this.accessDecisionManager = accessDecisionManager;
    }

    @Autowired
    public void setFilterInvocationSecurityMetadataSource(@Qualifier("customFilterInvocationSecurityMetadataSource") FilterInvocationSecurityMetadataSource filterInvocationSecurityMetadataSource) {
        final CustomFilterInvocationSecurityMetadataSource customFilterInvocationSecurityMetadataSource = (CustomFilterInvocationSecurityMetadataSource) filterInvocationSecurityMetadataSource;
        customFilterInvocationSecurityMetadataSource.setResourceId(RESOURCE_ID);
        this.filterInvocationSecurityMetadataSource = customFilterInvocationSecurityMetadataSource;
    }
}

FilterSecurityInterceptorPostProcessor

借助 ObjectPostProcessor 机制, 用户才有机会配置 FilterSecurityInterceptor 这样的拦截器.

/**
 * 配置 {@link FilterSecurityInterceptor}
 *
 * @author LiKe
 * @version 1.0.0
 * @date 2020-07-27 17:17
 */
public class FilterSecurityInterceptorPostProcessor implements ObjectPostProcessor<FilterSecurityInterceptor> {

    private final AccessDecisionManager accessDecisionManager;

    private final FilterInvocationSecurityMetadataSource securityMetadataSource;

    public FilterSecurityInterceptorPostProcessor(AccessDecisionManager accessDecisionManager, FilterInvocationSecurityMetadataSource securityMetadataSource) {
        this.accessDecisionManager = accessDecisionManager;
        this.securityMetadataSource = securityMetadataSource;
    }

    @Override
    public <GenericFilterSecurityInterceptor extends FilterSecurityInterceptor> GenericFilterSecurityInterceptor postProcess(
            GenericFilterSecurityInterceptor filterSecurityInterceptor
    ) {
        filterSecurityInterceptor.setSecurityMetadataSource(securityMetadataSource);
        filterSecurityInterceptor.setAccessDecisionManager(accessDecisionManager);
        return filterSecurityInterceptor;
    }
}

CustomFilterInvocationSecurityMetadataSource

SecurityMetadataSource 为访问决策控制提供判断依据 (ConfigAttribute).

完整代码如下:

/**
 * Description: 自定义的 {@link FilterInvocationSecurityMetadataSource}<br>
 * Details: SecurityMetadataSource 的提供的 Configuration Attributes 正是 AccessDecisionManager 的判断依据
 * (ref: org.springframework.security.access.intercept.AbstractSecurityInterceptor#beforeInvocation(Object))
 *
 * @author LiKe
 * @version 1.0.0
 * @date 2020-07-27 17:58
 */
@Slf4j
@Component
public class CustomFilterInvocationSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {

    public static final String AT = "@";

    private static final String METADATA_RESOURCE_ADDRESS_CACHE_PREFIX = "metadata.resource-address";

    private static final String CLIENT_ACCESS_SCOPE = "client-access-scope";

    private static final String CLIENT_AUTHORITY = "client-authority";

    private static final String USER_AUTHORITY = "user-authority";

    // =================================================================================================================

    /**
     * {@link ConfigAttribute} 配置属性的前缀: 客户端访问范围
     */
    public static final String CONFIG_ATTR_PREFIX_CLIENT_ACCESS_SCOPE = CLIENT_ACCESS_SCOPE + AT;

    /**
     * {@link ConfigAttribute} 配置属性的前缀: 客户端职权
     */
    public static final String CONFIG_ATTR_PREFIX_CLIENT_AUTHORITY = CLIENT_AUTHORITY + AT;

    /**
     * {@link ConfigAttribute} 配置属性的前缀: 用户端职权
     */
    public static final String CONFIG_ATTR_PREFIX_USER_AUTHORITY = USER_AUTHORITY + AT;

    // =================================================================================================================

    /**
     * 缓存键: 客户端访问范围-资源路径元数据
     */
    private static final RedisKey METADATA_CLIENT_ACCESS_SCOPE_RESOURCE_ADDRESS_CACHE_KEY =
            RedisKey.builder().prefix(METADATA_RESOURCE_ADDRESS_CACHE_PREFIX).suffix(CLIENT_ACCESS_SCOPE).build();

    /**
     * 缓存键: 客户端职权-资源路径元数据
     */
    private static final RedisKey METADATA_CLIENT_AUTHORITY_RESOURCE_ADDRESS_CACHE_KEY =
            RedisKey.builder().prefix(METADATA_RESOURCE_ADDRESS_CACHE_PREFIX).suffix(CLIENT_AUTHORITY).build();

    /**
     * 缓存键: 用户端职权-资源路径元数据
     */
    private static final RedisKey METADATA_USER_AUTHORITY_RESOURCE_ADDRESS_CACHE_KEY =
            RedisKey.builder().prefix(METADATA_RESOURCE_ADDRESS_CACHE_PREFIX).suffix(USER_AUTHORITY).build();


    // =================================================================================================================

    /**
     * 资源服务 ID
     */
    private String resourceId;

    private RedisService redisService;

    @Override
    public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
        final RedisService.Hash redisServiceOpsForHash = redisService.hash();

        // ~ 由 supports 方法决定
        final FilterInvocation filterInvocation = (FilterInvocation) object;
        final String endpoint = filterInvocation.getRequestUrl();
        // 资源地址
        final String resourceAddress = StringUtils.join(endpoint, AT, Objects.requireNonNull(resourceId, "资源服务器 ID 未定义!"));

        // ~ 通过要访问的端点和当前资源服务器 ID 获取可访问当前资源的 ClientAuthority, UserAuthority 和 ClientAccessScope 集合,
        //   约定, 每一种权限按照约定的前缀放入集合, 便于 AccessDecisionManager.
        //   然后, AccessDecisionManager 根据 OAuth2Authentication 判断 authorities / scopes 是否在集合中

        // ~ [ClientAccessScope]
        final Map<Object, Object> clientAccessScopeResourceAddressMapping = redisServiceOpsForHash.getAll(METADATA_CLIENT_ACCESS_SCOPE_RESOURCE_ADDRESS_CACHE_KEY);
        final Collection<ConfigAttribute> configAttributes = clientAccessScopeResourceAddressMapping.keySet()
                .stream()
                .filter(clientAccessScopeName ->
                        StringUtils.equals(MapUtils.getString(clientAccessScopeResourceAddressMapping, clientAccessScopeName), resourceAddress)
                )
                .map(clientAccessScopeName -> new SecurityConfig(StringUtils.join(CONFIG_ATTR_PREFIX_CLIENT_ACCESS_SCOPE, clientAccessScopeName))).collect(Collectors.toSet());

        // ~ [ClientAuthority]
        final Map<Object, Object> clientAuthorityResourceAddressMapping = redisServiceOpsForHash.getAll(METADATA_CLIENT_AUTHORITY_RESOURCE_ADDRESS_CACHE_KEY);
        configAttributes.addAll(clientAuthorityResourceAddressMapping.keySet()
                .stream()
                .filter(clientAuthorityName ->
                        StringUtils.equals(MapUtils.getString(clientAuthorityResourceAddressMapping, clientAuthorityName), resourceAddress)
                )
                .map(clientAuthorityName -> new SecurityConfig(StringUtils.join(CONFIG_ATTR_PREFIX_CLIENT_AUTHORITY, clientAuthorityName)))
                .collect(Collectors.toSet())
        );

        // ~ [UserAuthority]
        final Map<Object, Object> userAuthorityResourceAddressMapping = redisServiceOpsForHash.getAll(METADATA_USER_AUTHORITY_RESOURCE_ADDRESS_CACHE_KEY);
        configAttributes.addAll(userAuthorityResourceAddressMapping.keySet()
                .stream()
                .filter(userAuthorityName ->
                        StringUtils.equals(MapUtils.getString(userAuthorityResourceAddressMapping, userAuthorityName), resourceAddress)
                )
                .map(userAuthorityName -> new SecurityConfig(StringUtils.join(CONFIG_ATTR_PREFIX_USER_AUTHORITY, userAuthorityName)))
                .collect(Collectors.toSet())
        );

        // ~ 为 AccessDecisionManager 提供包含匹配当前访问的资源端点的 ClientAuthority, UserAuthority, 以及 ClientAccessScope 的集合
        //   格式:
        //       - ClientAccessScope: ClientAccessScope.CACHE_PREFIX@ClientAccessScopeName
        //       - ClientAuthority: ClientAuthority.CACHE_PREFIX@ClientAuthorityName
        //       - UserAuthority: UserAuthority.CACHE_PREFIX@UserAuthorityName
        return configAttributes;
    }

    @Override
    public Collection<ConfigAttribute> getAllConfigAttributes() {
        log.debug("CustomFilterInvocationSecurityMetadataSource :: getAllConfigAttributes");
        throw new UnsupportedOperationException("不支持的操作!");
    }

    @Override
    public boolean supports(Class<?> clazz) {
        log.debug("CustomFilterInvocationSecurityMetadataSource :: supports :: {}", clazz.getCanonicalName());
        // ~ FilterInvocation: 持有与 HTTP 过滤器相关的对象
        return FilterInvocation.class.isAssignableFrom(clazz);
    }

    // =================================================================================================================

    // ~ Autowired
    // -----------------------------------------------------------------------------------------------------------------

    @Autowired
    public void setRedisService(RedisService redisService) {
        this.redisService = redisService;
    }

    // =================================================================================================================

    /**
     * 设置资源服务器 ID
     */
    public void setResourceId(String resourceId) {
        this.resourceId = resourceId;
    }
}

如 「AuthorizationServer - JWT 构建策略」 一章中分析的, 对于资源服务器来说, 针对其资源的请求可能有 3 个来源:

  1. 第一方客户端后端;
  2. 资源服务器本身的前端;
  3. 第三方客户端;

SecurityMetadataSource 的职责就是组织用于支撑访问控制决策的 ConfigAttribute, 开发者需要把匹配当前请求端点路径的所有职权 (客户端和用户端) 信息和客户端访问范围组织到一个集合中, 由 AbstractSecurityInterceptor 传递给 AccessDecisionManager. 所以这里我们需要做的就是将 3 个来源的数据整合到一个集合中, 约定为每一个来源添加一个前缀用以区分. 最终 getAttributes 返回的集合的数据结构应形如:

[
    {
        "attribute": "client-access-scope@ACCESS_RESOURCE"
    },
    {
        "attribute": "user-authority@USER"
    },
    {
        "attribute": "client-authority@THIRD_PARTY_CLIENT"
    }
]
  • client-access-scope@ 是客户端访问范围的前缀标识, 前缀后是客户端访问范围名称;
  • user-authority@ 是用户职权的前缀标识. 用于标识用户职权名称;
  • client-authority@ 是客户端职权的前缀标识. 对应的, 用户标识客户端职权名称;

SecurityMetadataSource, 我们从缓存中加载权限元数据, 并按照以上策略过滤包装成 ConfigAttribute, 当我们把这样的数据提交给 AccessDecisionManager 后, 它该如何处理? 请继续往下看…

CustomAccessDecisionManager

AccessDecisionManager 用于提供最终的访问决策控制.

完整代码如下:

/**
 * 自定义的 {@link AccessDecisionManager}
 *
 * @author LiKe
 * @version 1.0.0
 * @date 2020-07-27 17:57
 */
@Slf4j
@Component
public class CustomAccessDecisionManager implements AccessDecisionManager {

    /**
     * 第一方客户端前端 CLIENT_AUTHORITY 名称
     */
    private static final String CLIENT_AUTHORITY_FIRST_PARTY_FRONTEND_CLIENT = "FIRST_PARTY_FRONTEND_CLIENT";

    // =================================================================================================================

    private static final String CONFIG_ATTR_PREFIX_CLIENT_AUTHORITY = CustomFilterInvocationSecurityMetadataSource.CONFIG_ATTR_PREFIX_CLIENT_AUTHORITY;

    private static final String CONFIG_ATTR_PREFIX_USER_AUTHORITY = CustomFilterInvocationSecurityMetadataSource.CONFIG_ATTR_PREFIX_USER_AUTHORITY;

    private static final String CONFIG_ATTR_PREFIX_CLIENT_ACCESS_SCOPE = CustomFilterInvocationSecurityMetadataSource.CONFIG_ATTR_PREFIX_CLIENT_ACCESS_SCOPE;

    // =================================================================================================================

    /**
     * 资源服务 ID
     */
    private String resourceId;

    /**
     * Description: 实现该资源服务的访问控制<br>
     * <dl>
     *     <dt>Access control principles:</dt>
     *     <dd>{@link OAuth2Authentication#isClientOnly()}{@code = true}: 验证客户端权限</dd>
     *     <dd>
     *         {@link OAuth2Authentication#isClientOnly()}{@code = false}: <br>
     *             - 如果是第一方客户端前端 ({@code CLIENT_AUTHORITY_FIRST_PARTY_FRONTEND_CLIENT}), 校验用户权限;<br>
     *             - 如果是第三方应用, 校验用户全和客户端的权限;
     *     </dd>
     * </dl>
     *
     * @param authentication   {@link org.springframework.security.oauth2.provider.OAuth2Authentication}
     * @param configAttributes 由 {@link CustomFilterInvocationSecurityMetadataSource} 组织的资源标识:
     *                         <pre>
     *                             - ClientAccessScope: ClientAccessScope.CACHE_PREFIX@ClientAccessScopeName<br>
     *                             - ClientAuthority: ClientAuthority.CACHE_PREFIX@ClientAuthorityName<br>
     *                             - UserAuthority: UserAuthority.CACHE_PREFIX@UserAuthorityName
     *                         </pre>
     * @see AccessDecisionManager#decide(Authentication, Object, Collection)
     * @see CustomFilterInvocationSecurityMetadataSource#getAttributes(Object)
     */
    @Override
    public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes) throws AccessDeniedException, InsufficientAuthenticationException {
        log.debug("Access controller :: start ...");

        final OAuth2Authentication oAuth2Authentication = (OAuth2Authentication) authentication;
        final FilterInvocation filterInvocation = (FilterInvocation) object;
        final String resourceAddress =
                StringUtils.join(filterInvocation.getRequestUrl(), CustomFilterInvocationSecurityMetadataSource.AT, Objects.requireNonNull(resourceId, "资源服务器 ID 未定义!"));

        final boolean clientOnly = oAuth2Authentication.isClientOnly();
        final String principalName = oAuth2Authentication.getName();
        final Set<String> metadataSource = configAttributes.stream().map(ConfigAttribute::getAttribute).collect(Collectors.toSet());

        if (clientOnly) {
            log.debug("Access controller :: 请求来自第一方客户端 ...");
            final Set<String> clientAuthorities = oAuth2Authentication.getOAuth2Request().getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.toSet());
            if (metadataSource.stream()
                    .filter(configAttrStr -> StringUtils.startsWith(configAttrStr, CONFIG_ATTR_PREFIX_CLIENT_AUTHORITY))
                    .noneMatch(filteredConfigAttrStr -> CollectionUtils.containsAny(clientAuthorities, StringUtils.substring(filteredConfigAttrStr, CONFIG_ATTR_PREFIX_CLIENT_AUTHORITY.length())))
            ) {
                throw new InsufficientAuthenticationException(String.format("Access controller :: denied :: (客户端: %s) 没有足够的权限访问该资源: %s", principalName, resourceAddress));
            }
        } else {
            log.debug("Access controller :: 请求可能来自第一方前端 ...");
            final Set<String> userAuthorities = oAuth2Authentication.getUserAuthentication().getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.toSet());

            // ~ 校验用户权限
            if (metadataSource.stream()
                    .filter(configAttrStr -> StringUtils.startsWith(configAttrStr, CONFIG_ATTR_PREFIX_USER_AUTHORITY))
                    .noneMatch(filteredConfigAttrStr -> CollectionUtils.containsAny(userAuthorities, StringUtils.substring(filteredConfigAttrStr, CONFIG_ATTR_PREFIX_USER_AUTHORITY.length())))
            ) {
                throw new InsufficientAuthenticationException(String.format("Access controller :: denied :: (用户: %s) 没有足够的权限访问该资源: %s", principalName, resourceAddress));
            }

            if (!CollectionUtils.containsAny(userAuthorities, CLIENT_AUTHORITY_FIRST_PARTY_FRONTEND_CLIENT)) {
                log.debug("Access controller :: 请求来自第三方客户端 ...");
                final Set<String> clientScopeNames = oAuth2Authentication.getOAuth2Request().getScope();
                // ~ 第三方应用: 还需要客户端 SCOPE
                if (metadataSource.stream()
                        .filter(configAttrStr -> StringUtils.startsWith(configAttrStr, CONFIG_ATTR_PREFIX_CLIENT_ACCESS_SCOPE))
                        .noneMatch(filteredConfigAttrStr -> CollectionUtils.containsAny(clientScopeNames, StringUtils.substring(filteredConfigAttrStr, CONFIG_ATTR_PREFIX_CLIENT_ACCESS_SCOPE.length())))
                ) {
                     throw new InsufficientAuthenticationException(String.format("Access controller :: denied :: (客户端: %s) 的方位范围不包括资源: %s", principalName, resourceAddress));
                }
            }
        }
        log.debug("Access controller :: allowed");
    }

    @Override
    public boolean supports(ConfigAttribute attribute) {
        return true;
    }

    @Override
    public boolean supports(Class<?> clazz) {
        log.debug("CustomFilterInvocationSecurityMetadataSource :: supports :: {}", clazz.getCanonicalName());
        // ~ FilterInvocation: 持有与 HTTP 过滤器相关的对象
        return FilterInvocation.class.isAssignableFrom(clazz);
    }

    // -----------------------------------------------------------------------------------------------------------------

    public void setResourceId(String resourceId) {
        this.resourceId = resourceId;
    }
}

根据在 「AuthorizationServer - JWT 构建策略」 中讨论过的访问控制策略, 在 AccessDecisionManager 实现. 整体上, 授权服务和资源服务支持的安全策略会应用 OAuth 2.0 标准的客户端授权 (client_credentials), 密码授权 (password) 和 授权码授权 (authorization_code):

  • 对于应用程序本身后端的客户端, 高度信任且服务间的通信不需要用户介入, 采用客户端授权模式. 也正由于没有用户介入, 仅能也仅仅有必要校验客户端职权;
  • 对于应用程序自身的分离前端, 从客户端维度来看, 前端理应能访问后端资源; 但是从用户端来看, 则不一定, 所以采用密码授权模式. 仅校验用户端职权;
  • 对于外部的第三方应用, 采用功能最全也最安全的授权码授权模式. 用户端职权和客户端的访问范围都会校验;

总结

至此, 基于 SpringSecurity OAuth2 的动态权限特性就实现完了, 基于 ObjectPostProcessor 机制, 在 SpringSecurity (5) SpringBoot + JWT + CSRF 动态权限的实现 的思路下实现. 整体是基于授权服务和资源服务体系构建的, 授权服务负责认证和缓存权限元数据, 资源服务负责做具体的鉴权 (访问控制), 在通用的访问控制策略下, 也可以提供符合自己资源特色的特性控制策略.

未完事项: 作为通用的访问控制策略包 (也就是 accesscontrol 包), 本身采用接口式设计, 可以将其封装成 Starter. 进一步抽取公共资源. 这个我们稍后在做.

实际测试步骤:

  1. 先从授权服务器获取令牌;
  2. 用令牌去访问资源服务的资源;
  3. 更改权限, 刷新缓存;
  4. 再次访问可以看到访问控制策略已经应用了最新的权限元数据.

P.S.

受限于篇幅以及为了突出重点, 本文只罗列了核心代码. 完整的请参考: AuthorizationServer & ResourceServer.

Reference

评论 14
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值