Spring Security Oauth2 从零到一完整实践(四)资源服务器

本贴最后更新于 1703 天前,其中的信息可能已经时移世改

我们使用安全框架的最大意义就在于保护我们的资源,让我们的资源能够在我们希望他被访问到的时候才能够被访问,而存放我们资源的地方就是资源服务器。前面已经说过,资源服务器是围绕着授权服务器进行的,在 oauth2 中当有了授权服务器以后,才会有资源服务器,这样说虽然有点绝对,但是如果没有授权服务器,资源服务器其实也就没有太大的存在意义的了,那么还不如就作为一个普通的 web 应用即可。我们现在的任务就是来学习如何自定义配置我们的资源服务器,同以前一样,我们通过实践的方式来了解他。

github 地址:spring-security-oauth2-demo

博客地址:echocow.cn

[TOC]

系列文章

  1. 较为详细的学习 oauth2 的四种模式其中的两种授权模式
  2. spring boot oauth2 自动配置实现
  3. spring security oauth2 授权服务器配置
  4. spring security oauth2 资源服务器配置
  5. spring security oauth2 自定义授权模式(手机、邮箱等)~~~~
  6. spring security oauth2 踩坑记录

spring security oauth2 资源服务器

一般来说资源服务器同时也是我们的客户端,为什么这么说呢?因为客户端存在的前提就是需要有资源服务器提供资源,这个关系往往都是一对一的,对于 web 应用,他们之间应该有如下关系:

  • 客户端:前端应用,携带 client id 去请求授权服务器获取授权码。
  • 资源服务器:后端应用,一般会在这里存放 client secret,这样用户就不会得到 client 相关的密钥或者凭证,使用 client id 和 client secret 向授权服务器对凭证进行验证和解析,所以通常来说资源服务器也是作为客户端的存在。

专门的资源服务器为客户端提供受保护的资源。而且在请求令牌凭证的时候,就已经指定了当前客户端信息,但是对于前端应用,为了安全不会存放 client secret,因为前端基本是全部暴露在用户面前的,所以资源服务器也充当客户端,用来存放相应的客户端信息。在请求资源的时候也需要说明客户端信息,这个时候的客户端信息为了安全,都是存放在授权服务器之中的,所以可以理解成如下图:

授权码模式

所以这就是为什么授权码模式安全性最高的原因之一,一方面他拥有严密的流程,另一方面他的授权是在授权服务器上完成,客户端只需要提供 client id 就可以而不需要其他的,用户也就只知道 client id 而不知 client secret 了;所以更加安全。如果是密码模式,你需要自定义自己的一套登录流程然后向授权服务器请求授权才可以,不能够直接让用户从前端应用向授权服务器请求授权,因为完全可以从请求头中截取你的客户端信息。密码模式如下:

不安全的密码模式

密码模式

所以我们在配置资源服务器的时候需要同时配置一个客户端。我们来再次看看资源服务器的详细步骤:

  1. 向授权服务器请求获取 token(即凭证)
  2. 向授权服务器验证并解析 token 获取用户信息
  3. 资源服务器验证用户是否有权限访问此资源

这一切都是以一台授权服务器为前提的,所以我们需要先为他准备一台授权服务器。我们可以直接使用我们上篇文章说到的那些授权服务器,不过有些许改变。

在这之前

在这之前我们需要为已经有的授权服务器添加一个非常主要的端点:check_token

在所有的授权模块中(如:spring-security-oauth2-authorization )的 Oauth2AuthorizationServerConfig 授权服务器配置添加如下方法,具体作用参见上一篇文章的最后一部分。

@Override
public void configure(AuthorizationServerSecurityConfigurer security) {
    security
        .checkTokenAccess("isAuthenticated()");
}

这样我们就能够访问 check_token 端点,资源服务器就能够向授权服务器验证并解析 token 获取用户信息

接下来我们来创建我们资源服务器模块,创建方式和授权服务器中是一样的,不再赘述。模块名称为 spring-security-oauth2-resource ,同授权服务器一样,资源服务器的关键接口为 ResourceServerConfigurer,而他的适配器为 ResourceServerConfigurerAdapter,我们只需要继承他的适配器即可,他有如下两个方法:

方法名 参数类型 描述
configure ResourceServerSecurityConfigurer 资源服务器的属性配置,默认值应该适用于许多应用程序,但可能至少要更改资源 ID。
configure HttpSecurity 使用此项配置安全资源的访问规则。默认情况下,不在 “/oauth/**” 中的所有资源是受保护的。这个其实就是和 ``spring security` 的配置方式是一样的。

相比起授权服务器好理解许多。同时需要明白的一点是,对于资源服务器,提供了两种验证与解析令牌的方式:

解析方式 实现类 优点 缺点
本地解析 DefaultTokenServices 解析快速,不需要发送任何请求,可以配置令牌存储等。 一旦授权服务器令牌解析方式发生调整,本地也要进行调整。向资源服务器/客户端提供令牌解析方式是极其不安全的行为。
远程解析 RemoteTokenServices 资源服务器配置大大减少,方便快捷,自适应授权服务器变化。 受网络的影响,一旦两个服务器不再一个局域网内,效率会大大降低。

然而在实际的授权服务器中,我们将会采用的是第二种 远程解析 的方式,最主要的原因是因为他足够安全。所以我们主要分为两个部分来学习资源服务器:

  1. 基于普通加密的资源服务器
  2. 基于 jwt 加密的资源服务器

基于普通加密的资源服务器

对应的授权服务器模块为:spring-security-oauth2-authorization

对应的资源服务器模块为:spring-security-oauth2-resource

授权服务器需添加 check_token 端点支持。

资源服务器依赖如下:

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.security.oauth.boot</groupId>
            <artifactId>spring-security-oauth2-autoconfigure</artifactId>
            <version>${spring.boot.version}</version>
        </dependency>
    </dependencies>

我们先创建一个启动类如下:

@SpringBootApplication
public class ResourceApplication {
    public static void main(String[] args) {
        SpringApplication.run(ResourceApplication.class, args);
    }
}

而作为资源服务器,我们肯定是需要准备一个受保护的资源的,所以我们创建一个 controller 如下:

@RestController
@RequestMapping("/auth")
public class OauthController {
    
    /**
     * 获取当前登录的用户信息
     * 
     * @param principal 用户信息
     * @return http 响应
     */
    @GetMapping("/me")
    public HttpEntity<?> oauthMe(Principal principal) {
        return ResponseEntity.ok(principal);
    }

}

同授权服务器一样,资源服务器的关键接口为 ResourceServerConfigurer,而他的适配器为 ResourceServerConfigurerAdapter,我们只需要继承他的适配器即可,如下:

@Configuration
@EnableResourceServer
public class Oauth2ResourceServerConfig extends ResourceServerConfigurerAdapter {

    @Override
    public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
        // 设置资源服务器的 id
        resources.resourceId("oauth2");
    }

}

最后来添加我们的配置文件 application.yml ,分别指定了如下参数:

  • 资源服务器的启动端口在 9000
  • 资源服务器检查和解析用户信息的路径在 http://localhost:8000/oauth/check_token
  • 客户端获取令牌的位置 http://localhost:8000/oauth/token
  • 客户端信息

如下:

server:
  port: 9000

security:
  oauth2:
    resource:
      token-info-uri: http://localhost:8000/oauth/check_token
    client:
      access-token-uri: http://localhost:8000/oauth/token
      client-id: oauth2
      client-secret: oauth2 # 这里必须是加密前的密钥
      grant-type: authorization_code,password,refresh_token
      scope: all

Q:在授权服务器中,我们继承了 AuthorizationServerConfigurerAdapter 并注入之后,在配置文件中的配置就不会自动生效了,在授权服务器之中同理,那么我们为什么还要配置 token-info-uri 呢?

A:主要原因是因为 token-info-uri 不仅是在资源服务器中使用的。我们资源服务器在向授权服务器发送请求的时候需要一个 RestTemplate (具体作用请自行百度),而 spring oauth2 将创建的这个 RestTemplate 存放在了 org.springframework.security.oauth2.provider.token.RemoteTokenServices 内,在这里又使用了 token-info-uri,具体源码见 org.springframework.boot.autoconfigure.security.oauth2.resource.ResourceServerTokenServicesConfiguration 如下:

yuanma

可以看到他创建的时候又使用了我们的 token-info-uri,所以这里我们配置出来的是为了配置这个自动的远程服务,我们用来发送的请求都是它来完成的。

Q:上一步的源码中看到了给他设置了 客户端 id 和 客户端密钥,但是在资源服务器的配置中我们并没有配置,而是配置的是 client 客户端的配置,他怎么设置进去的呢?

A:我们还是从源码说起,直接上图:

属性

可以看到是有这两个属性的,但是使用 @JsonIgnore 进行忽视了,同时没有 set 方法,所以我们无法设置,那么它是来自于哪里呢?那就只有构造函数了,在哪儿设置的呢?源码如下:

auto

可以看到注入了 client 的配置文件然后直接把 idsecret 使用构造方法放进去了,所以我们配置 client 就可以了 ~!

所以我们现在的目录结构应该如下:

files

我们启动测试一下,启动两个项目,

我们直接访问一下受保护的资源看看:

get

401 未授权,我们需要提供相应的授权凭证。

我们现在要获取凭证,也就是 token,第一步要先去获取授权码,获取授权码的过程是在授权服务器中完成的,访问如下路径:

然后登录授权流程就不截图啦,和授权服务器是一样的,这个过程你应该要注意的是 url ,他一直在的是 8000 端口的服务器上,最后取到授权码:

code

携带授权码去获取 token

注意:在实际应用中,回调地址应该自动接收获取到授权码然后发送给资源服务器,资源服务器请求授权服务器获取 token,这个过程应该要在资源服务器完成,对用户不可见

token

然后携带 token 去请求我们的资源服务器资源

token

这样我们就请求到了具体的数据啦,这就是使用了远程的方式,变得非常简单!不需要配置任何 token 相关的东西 ~

基于 Jwt 加密的资源服务器

对应的授权服务器模块为:spring-security-oauth2-authorization-jwt

对应的资源服务器模块为:spring-security-oauth2-resource-jwt

同样,在授权服务器中我们要添加 check_token 端点的访问权限。

我们先来初始化我们的项目,其实就是把上一个的复制过来即可=-=不过对于资源的 id 改成了从配置文件读取,配置文件如下:

server:
  port: 9000

security:
  oauth2:
    resource:
      token-info-uri: http://localhost:8000/oauth/check_token
      id: oauth2
    client:
      access-token-uri: http://localhost:8000/oauth/token
      client-id: oauth2
      client-secret: oauth2
      grant-type: authorization_code,password,refresh_token
      scope: all

最终项目结构如下:

config

而同样,对于 jwt 有两种,分别是对称密钥加密以及非对称密钥加密,我们也要一个一个来。

对称密钥

我们首先改一下授权服务器使用对称密钥加密,Oauth2AuthorizationServerConfig 如下:

/**
 * 令牌转换器,非/对称密钥加密
 *
 * @return JwtAccessTokenConverter
 */
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
    JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
    //  对称密钥加密
    converter.setSigningKey("oauth2");
    //  非对称密钥加密
    //  KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(
    //          new ClassPathResource("oauth2.jks"), "123456".toCharArray());
    //  converter.setKeyPair(keyStoreKeyFactory.getKeyPair("oauth2"));
    return converter;
}

对于 jwt 有两种配置方式

  1. 自动配置
  2. 手动配置

自动配置很简单,配置文件添加:

security:
  oauth2:
    resource:
      jwt:
      	key-value: oauth2

就可以了。

对于手动配置,也很简单,添加如下 bean 然后配置进去即可

配置一个本地的令牌转化器,如下:

@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
    JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
    converter.setSigningKey("oauth2");
    return converter;
}

然后将它配置进去

/**
 * 远程服务
 */
private @NonNull RemoteTokenServices remoteTokenServices;

/**
 * 配置文件
 */
private @NonNull ResourceServerProperties resourceServerProperties;

@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
    remoteTokenServices.setAccessTokenConverter(jwtAccessTokenConverter());
    // 设置资源服务器的 id,从配置文件中读取
    resources.resourceId(resourceServerProperties.getResourceId())
        .tokenServices(remoteTokenServices);
}

截图如下:

next

然后我们测试一下,使用密码模式请求 token:

get

get

这样就成功了。

非对称密钥

我们首先改一下授权服务器使用非对称密钥加密,Oauth2AuthorizationServerConfig 如下:

/**
 * 令牌转换器,非/对称密钥加密
 *
 * @return JwtAccessTokenConverter
 */
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
    JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
    //  对称密钥加密
    //  converter.setSigningKey("oauth2");
    //  非对称密钥加密
    KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(
        new ClassPathResource("oauth2.jks"), "123456".toCharArray());
    converter.setKeyPair(keyStoreKeyFactory.getKeyPair("oauth2"));
    return converter;
}

/**
 * 资源服务器所需,后面会讲
 * 具体作用见本系列的第二篇文章授权服务器最后一部分
 * 具体原因见本系列的第三篇文章资源服务器
 *
 * @param security security
 */
@Override
public void configure(AuthorizationServerSecurityConfigurer security) {
    security
        // 能够验证和解析 token
        .checkTokenAccess("isAuthenticated()")
        // 能够访问我们的公钥
        .tokenKeyAccess("isAuthenticated()");
}

这里我们需要 tokenKeyAccess("isAuthenticated()") 能够访问 /oauth/token_key 端点,启动授权服务器可以直接通过浏览器访问 http://localhost:8000/oauth/token_key

key

对于资源服务器,我们需要授权服务器提供给我们公钥,我们能够通过请求获取到授权服务器的 公钥了,有两种方式获取公钥:

  1. 授权服务器下发,本地存储,本地读取
  2. 直接从授权服务器请求获取

当然,我们也有两种方式

  1. 自动配置
  2. 手动配置

自动配置就是添加配置文件即可:

server:
  port: 9000

security:
  oauth2:
    resource:
      token-info-uri: http://localhost:8000/oauth/check_token
      id: oauth2
      jwt:
        key-uri: http://localhost:8000/oauth/token_key
        # 如果没有配置这项,会自动联网获取
        key-value: |
          -----BEGIN PUBLIC KEY-----
          MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAiNMiywFLjao8P86kkhwu
          49Ycys35RRZaKgqZ6JNtbgFq5dCA2kBtdArhm2GS2zplOyPGDlog3r9Ka2jA33Pf
          A9vl60zq1oI1AAAd8CLnyTvIekCnpwaGeBfYFv++LwhWPPT617XVhmF46c25F29t
          tMnGuzHzqKprysgdfBaIXUKZkMeVudGSLPgR0RjZvcM8MMs1cZ1rAISRgIT/D1RL
          Do/HhQkKOvhW2IrQgrqrgu+R/V+7AqS6dz/YAdroYpcBoXKSai+HtZ6yTDxrWdxh
          pbaTCvW2M/IObYVZaHpdOYNTufOzR6+w4SXagT++OopWEQ8w1vLKQzHk+uTrBfzQ
          kQIDAQAB
          -----END PUBLIC KEY-----          
    client:
      access-token-uri: http://localhost:8000/oauth/token
      client-id: oauth2
      client-secret: oauth2
      grant-type: authorization_code,password,refresh_token
      scope: all

手动配置比较麻烦。。。配置如下:


    @Override
    public void configure(ResourceServerSecurityConfigurer resources) {
        resources.resourceId(resourceServerProperties.getResourceId())
                .tokenServices(tokenServices());
    }

    @Bean
    public TokenStore tokenStore() {
        return new JwtTokenStore(jwtAccessTokenConverter());
    }

    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter() {
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        converter.setVerifierKey(getPubKey());
        return converter;
    }

    @Bean
    @Primary
    public DefaultTokenServices tokenServices() {
        DefaultTokenServices defaultTokenServices = new DefaultTokenServices();
        defaultTokenServices.setTokenStore(tokenStore());
        return defaultTokenServices;
    }

    private String getPubKey() {
       return StringUtils.isEmpty(resourceServerProperties.getJwt().getKeyValue())
               ? getKeyFromAuthorizationServer()
               : resourceServerProperties.getJwt().getKeyValue();
    }

    private String getKeyFromAuthorizationServer() {
        ObjectMapper objectMapper = new ObjectMapper();
        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.add("Authorization", encodeClient());
        HttpEntity<String> requestEntity = new HttpEntity<>(null, httpHeaders);
        String pubKey = new RestTemplate()
                .getForObject(resourceServerProperties.getJwt().getKeyUri(), String.class, requestEntity);
        try {
            Map map = objectMapper.readValue(pubKey, Map.class);
            System.out.println("联网公钥");
            return map.get("value").toString();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }

    private String encodeClient() {
        return "Basic " + Base64.getEncoder().encodeToString((resourceServerProperties.getClientId()
                + ":" + resourceServerProperties.getClientSecret()).getBytes());
    }

示例里面没有写,我写在了另外一个示例项目里面,参见 资源服务器示例

测试就不测试了。。。效果一样的。。好累了的说。

总结

资源服务器简单太多拉,因为需要做的复杂操作都在授权服务器上去做了,所以资源服务器其实事情没多少,但是里面的自动配置还是帮我们完成了很多事情。简单太多了,接下来就涉及到源码的一些东西了,这次拖了一周才写完,主要是要写开题报告,而且云顶之亦真香哈哈哈。

  • Spring

    Spring 是一个开源框架,是于 2003 年兴起的一个轻量级的 Java 开发框架,由 Rod Johnson 在其著作《Expert One-On-One J2EE Development and Design》中阐述的部分理念和原型衍生而来。它是为了解决企业应用开发的复杂性而创建的。框架的主要优势之一就是其分层架构,分层架构允许使用者选择使用哪一个组件,同时为 JavaEE 应用程序开发提供集成的框架。

    938 引用 • 1456 回帖 • 163 关注
  • OAuth

    OAuth 协议为用户资源的授权提供了一个安全的、开放而又简易的标准。与以往的授权方式不同之处是 oAuth 的授权不会使第三方触及到用户的帐号信息(如用户名与密码),即第三方无需使用用户的用户名与密码就可以申请获得该用户资源的授权,因此 oAuth 是安全的。oAuth 是 Open Authorization 的简写。

    36 引用 • 103 回帖 • 4 关注

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...
  • someone

    打卡

  • someone
    作者

    哈哈,~ 谢谢 ~

  • lizhongyue248

    注意注意:本文章适用于 5.3 以前的 spring security 以及 spring boot 2.3.x 以前的 oauth,以下内容应该为过时!spring 提供新的 oauth2 授权服务器,目前正在实验性阶段,同时资源服务器由 oauth 模块迁移到 spring security 之内。