整合k8s系列-02 服务器端应用

Posted

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了整合k8s系列-02 服务器端应用相关的知识,希望对你有一定的参考价值。

参考技术A

适用版本: Kubernetes v1.22 [stable]

一个完整描述的目标并不是一个完整的对象,仅包括能体现用户意图的字段和值。 该目标(intent)可以用来创建一个新对象, 也可以通过服务器来实现与现有对象的合并。

系统支持多个应用者(appliers)在同一个对象上开展协作。

“字段管理(field management)”机制追踪对象字段的变化。 当一个字段值改变时,其所有权从当前管理器(manager)转移到施加变更的管理器。 当尝试将新配置应用到一个对象时,如果字段有不同的值,且由其他管理器管理, 将会引发冲突。 冲突引发警告信号:此操作可能抹掉其他协作者的修改。 冲突可以被刻意忽略,这种情况下,值将会被改写,所有权也会发生转移。

当你从配置文件中删除一个字段,然后应用这个配置文件, 这将触发服务端应用检查此字段是否还被其他字段管理器拥有。 如果没有,那就从活动对象中删除该字段;如果有,那就重置为默认值。 该规则同样适用于 list 或 map 项目。

服务器端应用既是原有 kubectl apply 的替代品, 也是控制器发布自身变化的一个简化机制。

如果你启用了服务器端应用,控制平面就会跟踪被所有新创建对象管理的字段。

用户管理字段这件事,在服务器端应用的场景中,意味着用户依赖并期望字段的值不要改变。 最后一次对字段值做出断言的用户将被记录到当前字段管理器。 这可以通过发送 POST、 PUT、 或非应用(non-apply)方式的 PATCH 等命令来修改字段值的方式实现, 或通过把字段放在配置文件中,然后发送到服务器端应用的服务端点的方式实现。 当使用服务器端应用,尝试着去改变一个被其他人管理的字段, 会导致请求被拒绝(在没有设置强制执行时,参见冲突)。

如果两个或以上的应用者均把同一个字段设置为相同值,他们将共享此字段的所有权。 后续任何改变共享字段值的尝试,不管由那个应用者发起,都会导致冲突。 共享字段的所有者可以放弃字段的所有权,这只需从配置文件中删除该字段即可。

字段管理的信息存储在 managedFields 字段中,该字段是对象的 metadata 中的一部分。

服务器端应用创建对象的简单示例如下:

上述对象在 metadata.managedFields 中包含了唯一的管理器。 管理器由管理实体自身的基本信息组成,比如操作类型、API 版本、以及它管理的字段。

Note: 该字段由 API 服务器管理,用户不应该改动它。

不过,执行 Update 操作修改 metadata.managedFields 也是可实现的。 强烈不鼓励这么做,但当发生如下情况时, 比如 managedFields 进入不一致的状态(显然不应该发生这种情况), 这么做也是一个合理的尝试。

managedFields 的格式在 API 文档中描述。

管理器识别出正在修改对象的工作流程(在冲突时尤其有用), 管理器可以通过修改请求的参数 fieldManager 指定。 虽然 kubectl 默认发往 kubectl 服务端点,但它则请求到应用的服务端点(apply endpoint)。 对于其他的更新,它默认的是从用户代理计算得来。

此特性涉及两类操作,分别是 Apply (内容类型为 application/apply-patch+yaml 的 PATCH 请求) 和 Update (所有修改对象的其他操作)。 这两类操作都会更新字段 managedFields,但行为表现有一点不同。

Note:

不管你提交的是 JSON 数据还是 YAML 数据, 都要使用 application/apply-patch+yaml 作为 Content-Type 的值。

所有的 JSON 文档 都是合法的 YAML。

例如,在冲突发生的时候,只有 apply 操作失败,而 update 则不会。 此外,apply 操作必须通过提供一个 fieldManager 查询参数来标识自身, 而此查询参数对于 update 操作则是可选的。 最后,当使用 apply 命令时,你不能在应用中的对象中持有 managedFields。

一个包含多个管理器的对象,示例如下:

在这个例子中, 第二个操作被管理器 kube-controller-manager 以 Update 的方式运行。 此 update 更改 data 字段的值, 并使得字段管理器被改为 kube-controller-manager。

如果把 update 操作改为 Apply,那就会因为所有权冲突的原因,导致操作失败。

由服务器端应用实现的合并策略,提供了一个总体更稳定的对象生命周期。 服务器端应用试图依据负责管理它们的主体来合并字段,而不是根据值来否决。 这么做是为了多个主体可以更新同一个对象,且不会引起意外的相互干扰。

当用户发送一个“完整描述的目标”对象到服务器端应用的服务端点, 服务器会将它和活动对象做一次合并,如果两者中有重复定义的值,那就以配置文件的为准。 如果配置文件中的项目集合不是此用户上一次操作项目的超集, 所有缺少的、没有其他应用者管理的项目会被删除。 关于合并时用来做决策的对象规格的更多信息,参见 sigs.k8s.io/structured-merge-diff.

Kubernetes 1.16 和 1.17 中添加了一些标记, 允许 API 开发人员描述由 list、map、和 structs 支持的合并策略。 这些标记可应用到相应类型的对象,在 Go 文件或在 CRD 的 OpenAPI 的模式中定义:

若未指定 listType,API 服务器将 patchMergeStrategy=merge 标记解释为 listType=map 并且视对应的 patchMergeKey 标记为 listMapKey 取值。

atomic 列表类型是递归的。

这些标记都是用源代码注释的方式给出的,不必作为字段标签(tag)再重复。

在极少的情况下,CRD 或者内置类型的作者可能希望更改其资源中的某个字段的 拓扑配置,同时又不提升版本号。 通过升级集群或者更新 CRD 来更改类型的拓扑信息与更新现有对象的结果不同。 变更的类型有两种:一种是将字段从 map/set/granular 更改为 atomic, 另一种是做逆向改变。

当 listType、mapType 或 structType 从 map/set/granular 改为 atomic 时,现有对象的整个列表、映射或结构的属主都会变为这些类型的 元素之一的属主。这意味着,对这些对象的进一步变更会引发冲突。

当一个列表、映射或结构从 atomic 改为 map/set/granular 之一 时,API 服务器无法推导这些字段的新的属主。因此,当对象的这些字段 再次被更新时不会引发冲突。出于这一原因,不建议将某类型从 atomic 改为 map/set/granular。

以下面的自定义资源为例:

在 spec.data 从 atomic 改为 granular 之前,manager-one 是 spec.data 字段及其所包含字段(key1 和 key2)的属主。 当对应的 CRD 被更改,使得 spec.data 变为 granular 拓扑时, manager-one 继续拥有顶层字段 spec.data(这意味着其他管理者想 删除名为 data 的映射而不引起冲突是不可能的),但不再拥有 key1 和 key2。因此,其他管理者可以在不引起冲突的情况下更改 或删除这些字段。

默认情况下,服务器端应用把自定义资源看做非结构化数据。 所有的键值(keys)就像 struct 的字段一样被处理, 所有的 list 被认为是原子性的。

如果自定义资源定义(Custom Resource Definition,CRD)定义了一个 模式, 它包含类似以前“合并策略”章节中定义过的注解, 这些注解将在合并此类型的对象时使用。

控制器的开发人员可以把服务器端应用作为简化控制器的更新逻辑的方式。 读-改-写 和/或 patch 的主要区别如下所示:

强烈推荐:设置控制器在冲突时强制执行,这是因为冲突发生时,它们没有其他解决方案或措施。

除了通过冲突解决方案提供的并发控制, 服务器端应用提供了一些协作方式来将字段所有权从用户转移到控制器。

最好通过例子来说明这一点。 让我们来看看,在使用 Horizo ntalPodAutoscaler 资源和与之配套的控制器, 且开启了 Deployment 的自动水平扩展功能之后, 怎么安全的将 replicas 字段的所有权从用户转移到控制器。

假设用户定义了 Deployment,且 replicas 字段已经设置为期望的值:

application/ssa/nginx-deployment.yaml

并且,用户使用服务器端应用,像这样创建 Deployment:

然后,为 Deployment 启用 HPA,例如:

现在,用户希望从他们的配置中删除 replicas,所以他们总是和 HPA 控制器冲突。 然而,这里存在一个竟态: 在 HPA 需要调整 replicas 之前会有一个时间窗口, 如果在 HPA 写入字段成为所有者之前,用户删除了replicas, 那 API 服务器就会把 replicas 的值设为 1, 也就是默认值。 这不是用户希望发生的事情,即使是暂时的。

这里有两个解决方案:

首先,用户新定义一个只包含 replicas 字段的配置文件:

application/ssa/nginx-deployment-replicas-only.yaml

用户使用名为 handover-to-hpa 的字段管理器,应用此配置文件。

在此时间点,用户可以从配置文件中删除 replicas 。

application/ssa/nginx-deployment-no-replicas.yaml

注意,只要 HPA 控制器为 replicas 设置了一个新值, 该临时字段管理器将不再拥有任何字段,会被自动删除。 这里不需要执行清理工作。

通过在配置文件中把一个字段设置为相同的值,用户可以在他们之间转移字段的所有权, 从而共享了字段的所有权。 当用户共享了字段的所有权,任何一个用户可以从他的配置文件中删除该字段, 并应用该变更,从而放弃所有权,并实现了所有权向其他用户的转移。

由服务器端应用实现的冲突检测和解决方案的一个结果就是, 应用者总是可以在本地状态中得到最新的字段值。 如果得不到最新值,下次执行应用操作时就会发生冲突。 解决冲突三个选项的任意一个都会保证:此应用过的配置文件是服务器上对象字段的最新子集。

这和客户端应用(Client Side Apply) 不同,如果有其他用户覆盖了此值, 过期的值被留在了应用者本地的配置文件中。 除非用户更新了特定字段,此字段才会准确, 应用者没有途径去了解下一次应用操作是否会覆盖其他用户的修改。

另一个区别是使用客户端应用的应用者不能改变他们正在使用的 API 版本,但服务器端应用支持这个场景。

客户端应用方式时,用户使用 kubectl apply 管理资源, 可以通过使用下面标记切换为使用服务器端应用。

默认情况下,对象的字段管理从客户端应用方式迁移到 kubectl 触发的服务器端应用时,不会发生冲突。

Caution:

保持注解 last-applied-configuration 是最新的。 从注解能推断出字段是由客户端应用管理的。 任何没有被客户端应用管理的字段将引发冲突。

举例说明,比如你在客户端应用之后, 使用 kubectl scale 去更新 replicas 字段, 可是该字段并没有被客户端应用所拥有, 在执行 kubectl apply --server-side 时就会产生冲突。

此操作以 kubectl 作为字段管理器来应用到服务器端应用。 作为例外,可以指定一个不同的、非默认字段管理器停止的这种行为,如下面的例子所示。 对于 kubectl 触发的服务器端应用,默认的字段管理器是 kubectl。

如果你用 kubectl apply --server-side 管理一个资源, 可以直接用 kubectl apply 命令将其降级为客户端应用。

降级之所以可行,这是因为 kubectl server-side apply 会保存最新的 last-applied-configuration 注解。

此操作以 kubectl 作为字段管理器应用到服务器端应用。 作为例外,可以指定一个不同的、非默认字段管理器停止这种行为,如下面的例子所示。 对于 kubectl 触发的服务器端应用,默认的字段管理器是 kubectl。

启用了服务器端应用特性之后, PATCH 服务端点接受额外的内容类型 application/apply-patch+yaml。 服务器端应用的用户就可以把 YAMl 格式的 部分定义对象(partially specified objects)发送到此端点。 当一个配置文件被应用时,它应该包含所有体现你意图的字段。

可以从对象中剥离所有 managedField, 实现方法是通过使用 MergePatch、 StrategicMergePatch、 JSONPatch、 Update、以及所有的非应用方式的操作来覆盖它。 这可以通过用空条目覆盖 managedFields 字段的方式实现。以下是两个示例:

这一操作将用只包含一个空条目的列表覆写 managedFields, 来实现从对象中整个的去除 managedFields。 注意,只把 managedFields 设置为空列表并不会重置字段。 这么做是有目的的,所以 managedFields 将永远不会被与该字段无关的客户删除。

在重置操作结合 managedFields 以外其他字段更改的场景中, 将导致 managedFields 首先被重置,其他改变被押后处理。 其结果是,应用者取得了同一个请求中所有字段的所有权。

Caution: 对于不接受资源对象类型的子资源(sub-resources), 服务器端应用不能正确地跟踪其所有权。 如果你对这样的子资源使用服务器端应用,变更的字段将不会被跟踪。

参考链接:

https://kubernetes.io/zh/docs/reference/using-api/server-side-apply/

顺便祝大家五一假期愉快,也不要忘记提升自己。

欢迎大家提出不一样的观点,我们一起讨论,

我是辣个男人,一个运维人。

微服务架构中整合网关权限服务

前言:之前的文章有讲过微服务的权限系列和网关实现,都是孤立存在,本文将整合后端服务与网关、权限系统。安全权限部分的实现还讲解了基于前置验证的方式实现,但是由于与业务联系比较紧密,没有具体的示例。业务权限与业务联系非常密切,本次的整合项目将会把这部分的操作权限校验实现基于具体的业务服务。

1. 前文回顾与整合设计

认证鉴权与API权限控制在微服务架构中的设计与实现系列文章中,讲解了在微服务架构中Auth系统的授权认证和鉴权。在微服务网关中,讲解了基于netflix-zuul组件实现的微服务网关。下面我们看一下这次整合的架构图。

技术图片

整个流程分为两类:

  • 用户尚未登录。客户端(web和移动端)发起登录请求,网关对于登录请求直接转发到auth服务,auth服务对用户身份信息进行校验(整合项目省略用户系统,读者可自行实现,直接硬编码返回用户信息),最终将身份合法的token返回给客户端。
  • 用户已登录,请求其他服务。这种情况,客户端的请求到达网关,网关会调用auth系统进行请求身份合法性的验证,验证不通则直接拒绝,并返回401;如果通过验证,则转发到具体服务,服务经过过滤器,根据请求头部中的userId,获取该user的安全权限信息。利用切面,对该接口需要的权限进行校验,通过则proceed,否则返回403。

第一类其实比较简单,在讲解认证鉴权与API权限控制在微服务架构中的设计与实现就基本实现,现在要做的是与网关进行结合;第二类中,我们新建了一个后端服务,与网关、auth系统整合。

下面对整合项目涉及到的三个服务分别介绍。网关和auth服务的实现已经讲过,本文主要讲下这两个服务进行整合需要的改动,还有就是对于后端服务的主要实现进行讲解。

2. gateway实现

微服务网关已经基本介绍完了网关的实现,包括服务路由、几种过滤方式等。这一节将重点介绍实际应用时的整合。对于需要修改增强的地方如下:

  • 区分暴露接口(即对外直接访问)和需要合法身份登录之后才能访问的接口
  • 暴露接口直接放行,转发到具体服务,如登录、刷新token等
  • 需要合法身份登录之后才能访问的接口,根据传入的Access token进行构造头部,头部主要包括userId等信息,可根据自己的实际业务在auth服务中进行设置。
  • 最后,比较重要的一点,引入Spring Security的资源服务器配置,对于暴露接口设置permitAll(),其余接口进入身份合法性校验的流程,调用auth服务,如果通过则正常继续转发,否则抛出异常,返回401。

绘制的流程图如下:

技术图片

2.1 permitAll实现

对外暴露的接口可以直接访问,这可以依赖配置文件,而配置文件又可以通过配置中心进行动态更新,所以不用担心有hard-code的问题。
在配置文件中定义需要permitall的路径。

1
2
3
4
5
6
auth:
permitall:
-
pattern: /login/**
-
pattern: /web/public/**

服务启动时,读入相应的Configuration,下面的配置属性读取以auth开头的配置。

1
2
3
4
5
@Bean
@ConfigurationProperties(prefix = "auth")
public PermitAllUrlProperties getPermitAllUrlProperties() {
return new PermitAllUrlProperties();
}

当然还需要有PermitAllUrlProperties对应的实体类,比较简单,不列出来了。

2.2 加强头部

Filter过滤器,它是Servlet技术中最实用的技术,Web开发人员通过Filter技术,对web服务器管理的所有web资源进行拦截。这边使用Filter进行头部增强,解析请求中的token,构造统一的头部信息,到了具体服务,可以利用头部中的userId进行操作权限获取与判断。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
public class HeaderEnhanceFilter implements Filter {

//...

@Autowired
private PermitAllUrlProperties permitAllUrlProperties;

@Override
public void init(FilterConfig filterConfig) throws ServletException {

}

//主要的过滤方法
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
String authorization = ((HttpServletRequest) servletRequest).getHeader("Authorization");
String requestURI = ((HttpServletRequest) servletRequest).getRequestURI();
// test if request url is permit all , then remove authorization from header
LOGGER.info(String.format("Enhance request URI : %s.", requestURI));
//将isPermitAllUrl的请求进行传递
if(isPermitAllUrl(requestURI) && isNotOAuthEndpoint(requestURI)) {
//移除头部,但不包括登录端点的头部
HttpServletRequest resetRequest = removeValueFromRequestHeader((HttpServletRequest) servletRequest);
filterChain.doFilter(resetRequest, servletResponse);
return;
}
//判断是不是符合规范的头部
if (StringUtils.isNotEmpty(authorization)) {
if (isJwtBearerToken(authorization)) {
try {
authorization = StringUtils.substringBetween(authorization, ".");
String decoded = new String(Base64.decodeBase64(authorization));

Map properties = new ObjectMapper().readValue(decoded, Map.class);
//解析authorization中的token,构造USER_ID_IN_HEADER
String userId = (String) properties.get(SecurityConstants.USER_ID_IN_HEADER);

RequestContext.getCurrentContext().addZuulRequestHeader(SecurityConstants.USER_ID_IN_HEADER, userId);
} catch (Exception e) {
LOGGER.error("Failed to customize header for the request", e);
}
}
} else {
//为了适配,设置匿名头部
RequestContext.getCurrentContext().addZuulRequestHeader(SecurityConstants.USER_ID_IN_HEADER, ANONYMOUS_USER_ID);
}

filterChain.doFilter(servletRequest, servletResponse);
}

@Override
public void destroy() {

}

//...

}

上面代码列出了头部增强的基本处理流程,将isPermitAllUrl的请求进行直接传递,否则判断是不是符合规范的头部,然后解析authorization中的token,构造USER_ID_IN_HEADER。最后为了适配,设置匿名头部。
需要注意的是,HeaderEnhanceFilter也要进行注册。Spring 提供了FilterRegistrationBean类,此类提供setOrder方法,可以为filter设置排序值,让spring在注册web filter之前排序后再依次注册。

2.3 资源服务器配置

利用资源服务器的配置,控制哪些是暴露端点不需要进行身份合法性的校验,直接路由转发,哪些是需要进行身份loadAuthentication,调用auth服务。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {

//...
//配置permitAll的请求pattern,依赖于permitAllUrlProperties对象
@Override
public void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.requestMatchers().antMatchers("/**")
.and()
.authorizeRequests()
.antMatchers(permitAllUrlProperties.getPermitallPatterns()).permitAll()
.anyRequest().authenticated();
}

//通过自定义的CustomRemoteTokenServices,植入身份合法性的相关验证
@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
CustomRemoteTokenServices resourceServerTokenServices = new CustomRemoteTokenServices();
//...
resources.tokenServices(resourceServerTokenServices);
}
}

资源服务器的配置大家看了笔者之前的文章应该很熟悉,此处不过多重复讲了。关于ResourceServerSecurityConfigurer配置类,之前的安全系列文章已经讲过,ResourceServerTokenServices接口,当时我们也用到了,只不过用的是默认的DefaultTokenServices。这边通过自定义的CustomRemoteTokenServices,植入身份合法性的相关验证。

当然这个配置还要引入Spring Cloud Security oauth2的相应依赖。

1
2
3
4
5
6
7
8
9
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-security</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>

2.4 自定义RemoteTokenServices实现

ResourceServerTokenServices接口其中的一个实现是RemoteTokenServices

Queries the /check_token endpoint to obtain the contents of an access token.
If the endpoint returns a 400 response, this indicates that the token is invalid.

RemoteTokenServices主要是查询auth服务的/check_token端点以获取一个token的校验结果。如果有错误,则说明token是不合法的。笔者这边的的CustomRemoteTokenServices实现就是沿用该思路。需要注意的是,笔者的项目基于Spring cloud,auth服务是多实例的,所以这边使用了Netflix Ribbon获取auth服务进行负载均衡。Spring Cloud Security添加如下默认配置,对应auth服务中的相应端点。

1
2
3
4
5
6
7
8
9
security:
oauth2:
client:
accessTokenUri: /oauth/token
clientId: gateway
clientSecret: gateway
resource:
userInfoUri: /user
token-info-uri: /oauth/check_token

至于具体的CustomRemoteTokenServices实现,可以参考上面讲的思路以及RemoteTokenServices,很简单,此处略去。

至此,网关服务的增强完成,下面看一下我们对auth服务和后端backend服务的实现。
强调一下,为什么头部传递的userId等信息需要在网关构造?读者可以自己思考一下,结合安全等方面,??笔者暂时不给出答案。

3. auth整合

auth服务的整合修改,其实没那么多,之前对于user、role以及permission之间的定义和关系没有给出实现,这部分的sql语句已经在auth.sql中。所以为了能给出一个完整的实例,笔者把这部分实现给补充了,主要就是user-role,role、role-permission的相应接口定义与实现,实现增删改查。

读者要是想参考整合项目进行实际应用,这部分完全可以根据自己的业务进行增强,包括token的创建,其自定义的信息还可以在网关中进行统一处理,构造好之后传递给后端服务。

这边的接口只是列出了需要的几个,其他接口没写(因为懒。。)

这两个接口也是给backend项目用来获取相应的userId权限。

1
2
3
4
5
6
7
8
9
//根据userId获取用户对应的权限
@RequestMapping(method = RequestMethod.GET, value = "/api/userPermissions?userId={userId}",
consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
List<Permission> getUserPermissions(@RequestParam("userId") String userId);

//根据userId获取用户对应的accessLevel(好像暂时没用到。。)
@RequestMapping(method = RequestMethod.GET, value = "/api/userAccesses?userId={userId}",
consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
List<UserAccess> getUserAccessList(@RequestParam("userId") String userId);

好了,这边的实现已经讲完了,具体见项目中的实现。

4. backend项目实现

本节是进行实现一个backend的实例,后端项目主要实现哪些功能呢?我们考虑一下,之前网关服务和auth服务所做的准备:

  • 网关构造的头部userId(可能还有其他信息,这边只是示例),可以在backend获得
  • 转发到backend服务的请求,都是经过身份合法性校验,或者是直接对外暴露的接口
  • auth服务,提供根据userId进行获取相应的权限的接口

根据这些,笔者绘制了一个backend的通用流程图:

技术图片

上面的流程图其实已经非常清晰了,首先经过filter过滤器,填充SecurityContextHolder的上下文。其次,通过切面来实现注解,是否需要进入切面表达式处理。不需要的话,直接执行接口内的方法;否则解析注解中需要的权限,判断是否有权限执行,有的话继续执行,否则返回403 forbidden。

4.1 filter过滤器

Filter过滤器,和上面网关使用一样,拦截客户的HttpServletRequest。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class AuthorizationFilter implements Filter {

@Autowired
private FeignAuthClient feignAuthClient;

@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
logger.info("过滤器正在执行...");
// pass the request along the filter chain
String userId = ((HttpServletRequest) servletRequest).getHeader(SecurityConstants.USER_ID_IN_HEADER);

if (StringUtils.isNotEmpty(userId)) {
UserContext userContext = new UserContext(UUID.fromString(userId));
userContext.setAccessType(AccessType.ACCESS_TYPE_NORMAL);

List<Permission> permissionList = feignAuthClient.getUserPermissions(userId);
List<SimpleGrantedAuthority> authorityList = new ArrayList<>();
for (Permission permission : permissionList) {
SimpleGrantedAuthority authority = new SimpleGrantedAuthority();
authority.setAuthority(permission.getPermission());
authorityList.add(authority);
}

CustomAuthentication userAuth = new CustomAuthentication();
userAuth.setAuthorities(authorityList);
userContext.setAuthorities(authorityList);
userContext.setAuthentication(userAuth);
SecurityContextHolder.setContext(userContext);
}
filterChain.doFilter(servletRequest, servletResponse);
}

//...
}

上述代码主要实现了,根据请求头中的userId,利用feign client获取auth服务中的该user所具有的权限集合。之后构造了一个UserContext,UserContext是自定义的,实现了Spring Security的UserDetails, SecurityContext接口。

4.2 通过切面来实现@PreAuth注解

基于Spring的项目,使用Spring的AOP切面实现注解是比较方便的一件事,这边我们使用了自定义的注解@PreAuth

1
2
3
4
5
6
7
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface PreAuth {
String value();
}

Target用于描述注解的使用范围,超出范围时编译失败,可以用在方法或者类上面。在运行时生效。不了解注解相关知识的,可以自行Google。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Component
@Aspect
public class AuthAspect {


@Pointcut("@annotation(com.blueskykong.auth.demo.annotation.PreAuth)")
private void cut() {
}

/**
* 定制一个环绕通知,当想获得注解里面的属性,可以直接注入该注解
*
* @param joinPoint
* @param preAuth
*/
@Around("cut()&&@annotation(preAuth)")
public Object record(ProceedingJoinPoint joinPoint, PreAuth preAuth) throws Throwable {
//取出注解中的表达式
String value = preAuth.value();
//Spring EL 对value进行解析
SecurityExpressionOperations operations = new CustomerSecurityExpressionRoot(SecurityContextHolder.getContext().getAuthentication());
StandardEvaluationContext operationContext = new StandardEvaluationContext(operations);
ExpressionParser parser = new SpelExpressionParser();
Expression expression = parser.parseExpression(value);
//获取表达式判断的结果
boolean result = expression.getValue(operationContext, boolean.class);
if (result) {
//继续执行接口内的方法
return joinPoint.proceed();
}
return "Forbidden";
}
}

因为Aspect作用在bean上,所以先用Component把这个类添加到容器中。@Pointcut定义要拦截的注解。@Around定制一个环绕通知,当想获得注解里面的属性,可以直接注入该注解。切面表达式内主要实现了,利用Spring EL对value进行解析,将SecurityContextHolder.getContext()转换成标准的操作上下文,然后解析注解中的表达式,最后获取对表达式判断的结果。

1
2
3
4
5
6
public class CustomerSecurityExpressionRoot extends SecurityExpressionRoot {

public CustomerSecurityExpressionRoot(Authentication authentication) {
super(authentication);
}
}

CustomerSecurityExpressionRoot继承的是抽象类SecurityExpressionRoot,而我们用到的实际表达式是定义在SecurityExpressionOperations接口,SecurityExpressionRoot又实现了SecurityExpressionOperations接口。不过这里面的具体判断实现,Spring Security 调用的也是Spring EL。

4.3 controller接口

下面我们看看最终接口是怎么用上面实现的注解。

1
2
3
4
5
@RequestMapping(value = "/test", method = RequestMethod.GET)
@PreAuth("hasAuthority(‘CREATE_COMPANY‘)") // 还可以定义很多表达式,如hasRole(‘Admin‘)
public String test() {
return "ok";
}

@PreAuth中,可以定义的表达式很多,可以看SecurityExpressionOperations接口中的方法。目前笔者只是实现了hasAuthority()表达式,如果你想支持其他所有表达式,只需要构造相应的SecurityContextHolder即可。

4.4 为什么这样设计?

有些读者看了上面的设计,既然好多用到了Spring Security的工具类,肯定会问,为什么要引入这么复杂的工具类?

其实很简单,首先因为SecurityExpressionOperations接口中定义的表达式足够多,且较为合理,能够覆盖我们在平时用到的大部分场景;其次,笔者之前的设计是直接在注解中指定所需权限,没有扩展性,且可读性差;最后,Spring Security 4 确实引入了@PreAuthorize,@PostAuthorize等注解,本来想用来着,自己尝试了一下,发现对于微服务架构这样的接口级别的操作权限校验不是很适合,十多个过滤器太过复杂,而且还涉及到的Principal、Credentials等信息,这些已经在auth系统实现了身份合法性校验。笔者认为这边的功能实现并不是很复杂,需要很轻量的实现,读者有兴趣可以试着这部分的实现封装成jar包或者Spring Boot的starter。

4.5 后期优化

优化的地方主要有两点:

  • 现在的设计是,每次请求过来都会去调用auth服务获取该user相应的权限信息。而后端微服务数量有很多,没必要每个服务,或者说一个服务的多个服务实例,每次都去调用auth服务,笔者认为完全可以引入redis集群的缓存机制,在请求到达一个服务的某个实例时,首先去查询对应的user的缓存中的权限,如果没有再调用auth服务,最后写入redis缓存。当然,如果权限更新了,在auth服务肯定要delete相应的user权限缓存。
  • 关于被拒绝的请求,在切面表达式中,直接返回了对象,笔者认为可以和response status 403进行绑定,定制返回对象的内容,返回的response更加友好。

5. 总结

如上,首先讲了整合的设计思路,主要包含三个服务:gateway、auth和backend demo。整合的项目,总体比较复杂,其中gateway服务扩充了好多内容,对于暴露的接口进行路由转发,这边引入了Spring Security 的starter,配置资源服务器对暴露的路径进行放行;对于其他接口需要调用auth服务进行身份合法性校验,保证到达backend的请求都是合法的或者公开的接口;auth服务在之前的基础上,补充了role、permission、user相应的接口,供外部调用;backend demo是新起的服务,实现了接口级别的操作权限的校验,主要用到了自定义注解和Spring AOP切面。

由于实现的细节实在有点多,本文限于篇幅,只对部分重要的实现进行列出与讲解。如果读者有兴趣实际的应用,可以根据实际的业务进行扩增一些信息,如auth授权的token、网关拦截请求构造的头部信息、注解支持的表达式等等。

可以优化的地方当然还有很多,整合项目中设计不合理的地方,各位同学可以多多提意见。

以上是关于整合k8s系列-02 服务器端应用的主要内容,如果未能解决你的问题,请参考以下文章

SpringCloud系列之四---Zuul网关整合Swaagger2管理API

C# 编写WCF简单的服务端与客户端

Java 服务端监控方案(四. Java 篇)

百度云推送服务端SDK怎么用

thinkphp整合系列之融云即时通讯在线聊天

thinkphp整合系列之融云即时通讯在线聊天