-本文为填坑文-
很早之前我在《Spring cloud gatway适配Keycloak》中给自己留了一个坑。
可以看出token中已包含有用户相关信息,可进一步传递给下游,作为权限判断输入,符合上文中的预期。好了,用户登录有了,剩下的就是在资源服务器(即下游微服务)上实现权限验证了。
后面再单独写一章吧,因为资源服务器的实现略有不同。
其实能鉴权的资源服务器很早就实现了,不过因为最近在愉快的玩儿其他有意思的东东,没能顾上填坑,正好最近天气不错,那就把它填了吧。
先来看看效果。
在用户未登录的情况下,点击任意链接(根据Gateway配置判断是否需要登录访问)都会跳转至Keycloak以完成SSO流程,这一点在之前的文章中已经有过介绍。
但是对于微服务框架(功能尽可能解耦)来讲,Gateway最好只作为用户的访问入口(将访问跳转至服务提供者)或特殊时段的流量分流器(比如灰度版本上线时)。而用户是否有权限访问/使用某一具体服务,应该由对应的该服务提供者来判断,Gateway路由时将相关的信息(如token)完整传递下去就好,这里的服务提供者就是资源服务器。
服务权限限定
- POM依赖配置
既然资源服务器要对访问的用户进行鉴权,那么需要首先给暴露的端点加上权限限定,这在Spring框架里倒是挺简单,只需要在POM中加入spring-boot-starter-oauth2-resource即可。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
它会自动引用Security相关依赖,因此不用再单独引入。
- 权限配置
有了resource-server依赖之后,就可以对端点进行权限(访问)定义了。
resource-server默认使用WebSecurityConfigurerAdapter作为其安全配置器,其初始状态为:所有端点均需要登录后才能访问,换句话表述即为所有端点一视同仁。但是这并不是我们想要的效果(通常来讲,不同端点有不同的访问权限限制才是较常见的情况)。因此我们需要继承WebSecurityConfigurerAdapter,并重写里面的 protected void configure(HttpSecurity) 方法。
protected void configure(HttpSecurity http) throws Exception{
http.authorizeRequests(authz -> authz
.antMatchers(HttpMethod.GET, "/testing/").permitAll()
.antMatchers(HttpMethod.GET, "/testing/auth/**").hasRole(role_auth)
.antMatchers(HttpMethod.GET, "/testing/vip/**").hasRole(role_vip)
.antMatchers(HttpMethod.GET, "/testing/admin/**").hasRole(role_admin)
.anyRequest().authenticated())
.oauth2ResourceServer().jwt().jwtAuthenticationConverter(new JwtAuthenticationConverter());
}
代码很简单,将所有需要控制的端点按各自访问需求配置即可。又因为KeyCloak返回的是jwt格式token,所以配完权限设定后,我们告诉资源服务器使用JWT进行token解析。
按道理讲这样配置后就可以正常工作了,但是实际是无法完成校验的(所有访问均无法通过)。
Token解析
我们将KeyCloak返回的token解码后会发现一些有意思的事儿。因为我的鉴权是基于role的,因此将token里面与role相关的字段筛选出来,如下:
{
……
"realm_access": {
"roles": [
"offline_access",
"admin",
……
]
},
"resource_access": {
"realm-management": {
"roles": [
"view-realm",
"view-identity-providers",
"manage-identity-providers",
"impersonation",
"realm-admin",
"create-client",
……
]
},
"account": {
"roles": [
"manage-account",
"manage-account-links",
"view-profile"
]
}
},
"scope": "profile email",
……
"roles": [
"ROLE_offline_access",
"ROLE_vip",
"ROLE_user",
……
],
……
}
可以看出token中有好几个与role相关的字段,彼此之间还都有差别,我们依次解读一下。
- realm_access.roles 用户具有的外部自定义权限,即提供给资源服务器鉴权用的。
- resource_access 用户具有的内部权限,主要是指访问KeyCloak子功能的权限。
- realm-management.roles 管理当前realm的权限(如:创建、删除、查看等)
- account.roles 对当前账户的管理权限(如:查看、更改等) - roles 所有已分配的角色描述
备注:2与3 是可以一一对应上的
token中角色信息基本上就这些了,有用的的都有了,不怎么有用的也在token当中。回头再看看Spring Security是怎么取的这些信息呢?
经过一系列的定位,跟读代码,我发现Spring读取的信息是token中的scope字段(别问怎么跟的,也别问具体是那个文件,因为过去太久了,我也忘记了),而scope字段的值是profile email。所以默认情况下永远也无法校验通过(当然也可以把角色名改成profile email →_→)。
Token适配
怎么解决这个问题呢?至少有两种办法值得尝试。
- 修改KeyCloak产生的Token,使其适配Spring解析规则。
- 修改Spring解析方式,适应KeyCloak。
KeyCloak映射
KeyCloak提供了丰富而强大的映射功能,在上文解析token内容时所发现的ROLE_信息即通过映射的方式将自定义的角色信息映射到token自定义字段当中的。
然而就算有如此强大丰富的映射功能,我们也不能将realm.roles信息映射到scope中,并不是无法配置这样的映射规则,而是配置之后在校验时会有很多莫名其妙的问题。
如果有好心人知道如何正确配置,也请不吝赐教
自定义解析器
之前我们跟踪Spring Security会发现在使用JWT解析器的时候,实际的转换是通过JwtGrantedAuthoritiesConverter 中的covert完成。解析方法到也不复杂,简单来说就是读取jwt token的scpe字段。要解决这个问题就得用我们自己covert去替换默认的。
幸运的是Spring Security开放了相关接口。只需要用自定义的解析方法替换即可。
protected void configure(HttpSecurity http) throws Exception{
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(new Converter<Jwt, Collection<GrantedAuthority>>() {
@Override
public Collection<GrantedAuthority> convert(Jwt source) {
JSONArray roles = (JSONArray) ((JSONObject) (source.getClaims().get("realm_access"))).get("roles");
// JSONArray scopes = (JSONArray) ((JSONObject) (source.getClaims().get("resource_access"))).get("roles");
ArrayList<GrantedAuthority> roleArray = new ArrayList<>();
for(int i = 0; i < roles.size(); i++){
roleArray.add(new Role(roles.get(i)));
}
return roleArray;
}
});
http.authorizeRequests(authz -> authz
.antMatchers(HttpMethod.GET, "/testing/").permitAll()
.antMatchers(HttpMethod.GET, "/testing/auth/**").hasRole(role_auth)
.antMatchers(HttpMethod.GET, "/testing/vip/**").hasRole(role_vip)
.antMatchers(HttpMethod.GET, "/testing/admin/**").hasRole(role_admin)
.anyRequest().authenticated())
.oauth2ResourceServer().jwt().jwtAuthenticationConverter(converter);
}
因为covert需返回GrantedAuthority格式的队列,因此自定义了一个子类Role。这个子类只做一件事,给role加上ROLE_前缀。
public class Role implements Serializable, GrantedAuthority {
private String authority;
public Role(Object role){
authority = "ROLE_" + (String) role;
}
@Override
public String getAuthority() {
return authority;
}
}
为什么还需要一个自定义Role呢?Token中不是带有已加有前缀的字段了吗?因为在实际部署生产环境的时候,我们更倾向只对一端进行修改,而不是两端同时修改,这也是出于减少依赖,方便日后替换另一端考虑。
至此,一个适配KeyColaK,支持鉴权的资源服务器就算做好了。
本文内容在Spring boot 2.5.5版本上验证通过
源码 Gitee