Spring极简资源服务器
Keep Team Lv4

-本文为填坑文-

  很早之前我在《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相关的字段,彼此之间还都有差别,我们依次解读一下。

  1. realm_access.roles 用户具有的外部自定义权限,即提供给资源服务器鉴权用的。
  2. resource_access 用户具有的内部权限,主要是指访问KeyCloak子功能的权限。
      - realm-management.roles 管理当前realm的权限(如:创建、删除、查看等)
      - account.roles 对当前账户的管理权限(如:查看、更改等)
  3. 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