Spring Security Oauth2 从零到一完整实践(三)授权服务器

本贴最后更新于 1529 天前,其中的信息可能已经事过景迁

前面说了自动配置,现在就是来说自定义配置啦,这个是十分重要的一节,可以说 oauth2 的核心就是授权服务器了,所有的角色都是围绕着授权服务器而运作的,这里基本包含了资源服务器的所有配置。

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 授权服务器

我们首先再次回顾下授权服务器的详细作用:

  1. 客户端的验证与授权
  2. 令牌的生成与发放
  3. 令牌的校验与更新

所以我们以下的操作都会围绕 客户端令牌 来完成。

注意:以下授权服务器全默认在 8000 端口运行!!!

现在我们需要进行的就是授权服务器配置实现,我们完成项目的初始化,和之前创建完全一样,创建完成后,我们把 8080 端口修改为 8000 端口,然后项目结构如下:

new

同时添加一下如下依赖

<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>

既然是授权服务器,那么我们也就不用把它注册为资源服务器了,因为我们不对外暴露任何资源,仅仅只是为了令牌的下发,不需要做资源保护。

在我们配置授权服务器之前,需要先进行我们前面遇到过的配置 spring security web 安全,复制一下上一次的配置,就不截图了,如下:

package cn.echocow.oauth.authorization.config;

import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    /**
     * 密码加密方式,spring 5 后必须对密码进行加密
     *
     * @return BCryptPasswordEncoder
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    /**
     * 创建两个内存用户
     * 用户名 user 密码 123456 角色 ROLE_USER
     * 用户名 admin 密码 admin 角色 ROLE_ADMIN
     *
     * @return InMemoryUserDetailsManager
     */
    @Bean
    @Override
    public UserDetailsService userDetailsService() {
        InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
        manager.createUser(User.withUsername("user")
                .password(passwordEncoder().encode("123456"))
                .authorities("ROLE_USER").build());
        manager.createUser(User.withUsername("admin")
                .password(passwordEncoder().encode("admin"))
                .authorities("ROLE_ADMIN").build());
        return manager;
    }

}

上一部分我们知道 spring-security-oauth2-autoconfigure 是自动配置的包,通过陪配置文件就可以完成一个授权服务器和资源服务器,现在我们需要来自定义他的授权服务器该怎么做呢?我们需要做的就是配置属于我们自己的 AuthorizationServerConfigurer 了,当 spring 扫描到我们实现的配置以后,他就不回去自动配置 oauth2 了。为什么这么说呢?可以通过查看他的自动配置的源码你就会发现为什么,如下:

bean

所以,如果我们配置了 AuthorizationServerConfigurer 的 bean,它是不会执行自动配置的。我们现在需要自定义,所以就要来实现一下这个接口。当然,spring 提供了相应的适配器来供我们实现这个接口的,他就是 AuthorizationServerConfigurerAdapter,我们只要继承这个类即可。我们来看看里面的三个配置方法:

方法名 参数 描述
configure AuthorizationServerSecurityConfigurer 配置授权服务器的安全信息,比如 ssl 配置、checktoken 是否允许访问,是否允许客户端的表单身份验证等。
configure ClientDetailsServiceConfigurer 配置客户端的 service,也就是应用怎么获取到客户端的信息,一般来说是从内存或者数据库中获取,已经提供了他们的默认实现,你也可以自定义。
configure AuthorizationServerEndpointsConfigurer 配置授权服务器各个端点的非安全功能,如令牌存储,令牌自定义,用户批准和授权类型。如果需要密码授权模式,需要提供 AuthenticationManager 的 bean。

所以为了方便,我们先在我们的 SecurityConfig 配置中创建一个 AuthenticationManager Bean,直接调用父类的方法获取即可,如下:

/**
 * 认证管理
 *
 * @return 认证管理对象
 * @throws Exception 认证异常信息
 */
@Override
@Bean  // 重点是这行,父类并没有将它注册为一个 Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
    return super.authenticationManagerBean();
}

接下来就是我们配置我们自己的授权服务器了,我们要完成如下的几种授权服务器配置

  • 基于内存的客户端信息与令牌存储
  • 基于 mysql 的客户端信息与令牌存储
  • 基于 redis 的令牌存储
  • 基于 jwt 的令牌生成与配置
  • 授权服务器小扩展

以上可以自由组合,例如 mysql 客户端配合 redis 令牌存储等。

由于内容过多,防止由于依赖的问题导致不好运行查看效果,我每一种方式,都将它放在新的模块之中,模块的创建将会省略不写。分别为 内存、mysql、redis、jwt 四个模块

不过在那之前,我们需要准备一个已经继承 AuthorizationServerConfigurerAdapter 的配置类,同时上面提到过,如果需要密码模式,我们要提供 AuthenticationManager 的 bean,所以我们在这里提前进行配置下,后面就不再进行赘述,如下:

@Configuration
@RequiredArgsConstructor
@EnableAuthorizationServer
public class Oauth2AuthorizationServerConfig
    extends AuthorizationServerConfigurerAdapter {
    
    private final @NonNull AuthenticationManager authenticationManager;

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.authenticationManager(this.authenticationManager);
    }
}

现在的项目结构如下:

authconfig

注意,为了方便,后面的测试均使用密码模式进行测试!

基于内存的客户端信息与令牌存储

代码参见项目模块 spring-security-oauth2-authorization

我们将在内存中存储和读取客户端信息以及下发的令牌信息:

  • 优点:速度快,读取速度和写入速度都很快,配置也极其方便。
  • 缺点:扩展性差,需要在代码中配置,重启应用后已经下发的令牌失效。
  • 适用场景:小型不易改变的应用,授权服务器和资源服务器一体的应用。

客户端信息

对于客户端信息的配置,你完全可以通过 org.springframework.boot.autoconfigure.security.oauth2.authserver.OAuth2AuthorizationServerConfiguration 这个类学习到,对于客户端的配置我们主要实现对参数为 ClientDetailsServiceConfigurer 的方法配置,我们分来两个方式来学习:

  1. 直接代码写死配置客户端信息
  2. 读取配置文件中的客户端信息

代码配置

我们需要以下几步完成配置

  1. 构建内存存储的 ClientDetailsService 实现类(spring security oauth 已经提供)。
  2. 利用构建出来的进行配置客户端。

所以我们先进行第一步,我们获取他的建造者:

InMemoryClientDetailsServiceBuilder builder = clients.inMemory();

然后通过他构建一个内存客户端:

builder
		// 构建一个 id 为 oauth2 的客户端
        .withClient("oauth2")
        // 设置她的密钥,加密后的
        .secret("$2a$10$wlgcx61faSJ8O5I4nLiovO9T36HBQgh4RhOQAYNORCzvANlInVlw2")
        // 设置允许访问的资源 id
        .resourceIds("oauth2")
        // 授权的类型
        .authorizedGrantTypes("password", "authorization_code", "refresh_token")
        // 可以授权的角色
        .authorities("ROLE_ADMIN", "ROLE_USER")
        // 授权的范围
        .scopes("all")
        // token 有效期
        .accessTokenValiditySeconds(Math.toIntExact(Duration.ofHours(1).getSeconds()))
        // 刷新 token 的有效期
        .refreshTokenValiditySeconds(Math.toIntExact(Duration.ofHours(1).getSeconds()))
        // 授权码模式的重定向地址
        .redirectUris("http://example.com");

看起来她配置的东西和我们在配置文件中写的东西是基本一致的,不过密码现在是加密后的了,如何获取呢?我是写了一个测试类如下:

package cn.echocow.oauth.authorization;

import org.junit.Test;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

/**
 * 获取加密后的密码
 *
 * @author <a href="https://echocow.cn">EchoCow</a>
 * @date 19-7-13 下午4:36
 */
public class PasswordTest {

    @Test
    public void password() {
        // 每次打印的结果都不一样,不影响
        System.out.println(new BCryptPasswordEncoder().encode("oauth2"));
    }

}

然后将打印的密码填入即可,不过值得注意的是,她每次的加密结果都是不一样的。现在的文件如下:

file

我们启动然后测试一下:

test

这个就从内存中存存储和读取客户端信息了,如果多个客户端呢?复制一遍就好啦

more

亦或者完全使用链式结构如下:

all

配置文件配置

对于配置文件配置其实他已经有了默认的实现了,但是只能对一个客户端进行配置,我们需要多个的时候怎么办呢?就需要我们来扩展了,这个实现其实很简单,就是一个配置类和一个循环的实现,我们来捋一下步骤。

  1. 读取配置文件,多个客户端信息
  2. 逐个配置客户端信息

先来书写配置类,使用 lombok 自动生成 get/set 等方法:

@Data
@Configuration
@ConfigurationProperties("application.security.oauth")
public class ClientDetails {
    private List<BaseClientDetails> client;
}

书写配置文件:

application:
  security:
    oauth:
      client[0]:
        registered-redirect-uri: http://example.com
        # 客户端 id
        client-id: client1
        # 客户端密钥
        client-secret: $2a$10$wlgcx61faSJ8O5I4nLiovO9T36HBQgh4RhOQAYNORCzvANlInVlw2
        # 授权范围
        scope: all
        # token 有效期
        access-token-validity-seconds: 6000
        # 刷新 token 的有效期
        refresh-token-validity-seconds: 6000
        # 允许的授权类型
        grant-type: authorization_code,password,refresh_token
        # 可以访问的资源 id
        resource-ids: oauth2
      client[1]:
        registered-redirect-uri: http://example.com
        # 客户端 id
        client-id: client2
        # 客户端密钥
        client-secret: $2a$10$wlgcx61faSJ8O5I4nLiovO9T36HBQgh4RhOQAYNORCzvANlInVlw2
        # 授权范围
        scope: all
        # token 有效期
        access-token-validity-seconds: 6000
        # 刷新 token 的有效期
        refresh-token-validity-seconds: 6000
        # 允许的授权类型
        grant-type: authorization_code,password,refresh_token
        # 可以访问的资源 id
        resource-ids: oauth2

为了防止混淆,我单独写了一个方法来配置,如下:

private void configClient(ClientDetailsServiceConfigurer clients) throws Exception {
        InMemoryClientDetailsServiceBuilder builder = clients.inMemory();
        for (BaseClientDetails client : clientDetails.getClient()) {
            ClientDetailsServiceBuilder<InMemoryClientDetailsServiceBuilder>.ClientBuilder clientBuilder =
                    builder.withClient(client.getClientId());
            clientBuilder
                    .secret(client.getClientSecret())
                    .resourceIds(client.getResourceIds().toArray(new String[0]))
                    .authorizedGrantTypes(client.getAuthorizedGrantTypes().toArray(new String[0]))
                    .authorities(
                            AuthorityUtils.authorityListToSet(client.getAuthorities())
                                    .toArray(new String[0]))
                    .scopes(client.getScope().toArray(new String[0]));
            if (client.getAutoApproveScopes() != null) {
                clientBuilder.autoApprove(
                        client.getAutoApproveScopes().toArray(new String[0]));
            }
            if (client.getAccessTokenValiditySeconds() != null) {
                clientBuilder.accessTokenValiditySeconds(
                        client.getAccessTokenValiditySeconds());
            }
            if (client.getRefreshTokenValiditySeconds() != null) {
                clientBuilder.refreshTokenValiditySeconds(
                        client.getRefreshTokenValiditySeconds());
            }
            if (client.getRegisteredRedirectUri() != null) {
                clientBuilder.redirectUris(
                        client.getRegisteredRedirectUri().toArray(new String[0]));
            }
        }
    }

最终如下:

result

然后运行测试一下两个客户端

2

1

这样也实现了效果

令牌存储

其实他默认的令牌存储就是使用到内存存储,所以我们无需配置 ~ 何以见得呢?我们来简单分析一下。

在前面我们说过 AuthorizationServerConfigurer 的三个配置方法,其中就有一个参数为 AuthorizationServerEndpointsConfigurer 类型的配置方法,它可以配置我们令牌信息,所以我们就要把目标放在他的上面看看,去找一找他是如何配置的。

他的核心配置类是 org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerEndpointsConfiguration,这个类内容很多,我们只关注他是默认配置的为什么是内存的,首先找到一个工厂类:

factory

我们跟进去看看:

default

再进去看看

这样我们就找到她是如何默认创建的了。

基于 mysql 的客户端信息与令牌存储

代码参见项目模块 spring-security-oauth2-authorization-mysql

模块创建步骤省略

我们将在 mysql 中存储和读取客户端信息以及下发的令牌信息:

  • 优点:扩展性极高,不用修改代码与重启就可以完成客户端管理,安全性高。
  • 缺点:使用数据库速度过慢,多客户端高并发情况下可能会造成性能瓶颈
  • 适用场景:中大型项目,独立且完整的授权服务器。

在这之前你要添加如下的 mysql 和 jdbc 依赖

<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>

配置文件如下,我的 mysql 版本为 8.0 ,url 参数请自行修改

server:
  port: 8000
spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://127.0.0.1:3306/auth?useUnicode=true&characterEncoding=UTF-8&useOldAliasMetadataBehavior=true&autoReconnect=true&serverTimezone=UTC
    username: root
    password: 123456
    # 用来初始化数据库的,如果不存在表就自动创建
    initialization-mode: ALWAYS
    schema: classpath:ddl.sql

导入 官方提供 的 h2 的表,由于官方使用的是 h2 的数据库,有些字段类型不对,我修改成 mysql 的后如下:

-- used in tests that use MYSQL
create table if not exists oauth_client_details (
  client_id VARCHAR(256) PRIMARY KEY,
  resource_ids VARCHAR(256),
  client_secret VARCHAR(256),
  scope VARCHAR(256),
  authorized_grant_types VARCHAR(256),
  web_server_redirect_uri VARCHAR(256),
  authorities VARCHAR(256),
  access_token_validity INTEGER,
  refresh_token_validity INTEGER,
  additional_information VARCHAR(4096),
  autoapprove VARCHAR(256)
);

create table if not exists oauth_client_token (
  token_id VARCHAR(256),
  token BLOB,
  authentication_id VARCHAR(256) PRIMARY KEY,
  user_name VARCHAR(256),
  client_id VARCHAR(256)
);

create table if not exists oauth_access_token (
  token_id VARCHAR(256),
  token BLOB,
  authentication_id VARCHAR(256) PRIMARY KEY,
  user_name VARCHAR(256),
  client_id VARCHAR(256),
  authentication BLOB,
  refresh_token VARCHAR(256)
);

create table if not exists oauth_refresh_token (
  token_id VARCHAR(256),
  token BLOB,
  authentication BLOB
);

create table if not exists oauth_code (
  code VARCHAR(256), authentication BLOB
);

create table if not exists oauth_approvals (
	userId VARCHAR(256),
	clientId VARCHAR(256),
	scope VARCHAR(256),
	status VARCHAR(10),
	expiresAt TIMESTAMP,
	lastModifiedAt TIMESTAMP
);


-- customized oauth_client_details table
create table if not exists ClientDetails (
  appId VARCHAR(256) PRIMARY KEY,
  resourceIds VARCHAR(256),
  appSecret VARCHAR(256),
  scope VARCHAR(256),
  grantTypes VARCHAR(256),
  redirectUrl VARCHAR(256),
  authorities VARCHAR(256),
  access_token_validity INTEGER,
  refresh_token_validity INTEGER,
  additionalInformation VARCHAR(4096),
  autoApproveScopes VARCHAR(256)
);

先给大家介绍一下几张表的具体意思和结构:

oauth_client_details ===> 客户端信息

列名 类型 描述
client_id(主键) VARCHAR(256) 主键,必须唯一,不能为空. 用于唯一标识每一个客户端(client); 在注册时必须填写(也可由服务端自动生成). 对于不同的 grant_type,该字段都是必须的. 在实际应用中的另一个名称叫 appKey,与 client_id 是同一个概念.
resource_ids VARCHAR(256) 客户端所能访问的资源 id 集合,多个资源时用逗号(,)分隔
client_secret VARCHAR(256) 用于指定客户端(client)的访问密匙; 在注册时必须填写(也可由服务端自动生成). 对于不同的 grant_type,该字段都是必须的. 在实际应用中的另一个名称叫 appSecret,与 client_secret 是同一个概念.
scope VARCHAR(256) 指定客户端申请的权限范围,可选值包括 read,write,trust;若有多个权限范围用逗号(,)分隔,如: “read,write”.
authorized_grant_types VARCHAR(256) 指定客户端支持的 grant_type,可选值包括 authorization_code,password,refresh_token,implicit,client_credentials,
若支持多个 grant_type 用逗号(,)分隔,如: “authorization_code,password”.
在实际应用中,当注册时,该字段是一般由服务器端指定的,而不是由申请者去选择的,
web_server_redirect_uri VARCHAR(256) 客户端的重定向 URI,可为空, 当 grant_type 为 authorization_code 或 implicit 时, 在 Oauth 的流程中会使用并检查与注册时填写的 redirect_uri 是否一致.
authorities VARCHAR(256) 指定客户端所拥有的 Spring Security 的权限值,可选, 若有多个权限值,用逗号(,)分隔, 如: "ROLE_ADMIN"
access_token_validity INTEGER 设定客户端的 access_token 的有效时间值(单位:秒),可选, 若不设定值则使用默认的有效时间值(60 * 60 * 12, 12 小时).
refresh_token_validity INTEGER 设定客户端的 refresh_token 的有效时间值(单位:秒),可选, 若不设定值则使用默认的有效时间值(60 * 60 * 12, 12 小时).
additional_information VARCHAR(4096) 这是一个预留的字段,在 Oauth 的流程中没有实际的使用,可选,但若设置值,必须是 JSON 格式的数据,在实际应用中, 可以用该字段来存储关于客户端的一些其他信息
autoapprove VARCHAR(256) 设置用户是否自动 Approval 操作, 默认值为 ‘false’, 可选值包括 ‘true’,‘false’, ‘read’,‘write’.
该字段只适用于 grant_type="authorization_code"的情况,当用户登录成功后,若该值为’true’或支持的 scope 值,则会跳过用户 Approve 的页面,
直接授权.

oauth_client_token ===> 客户端系统中存储从服务端获取的 token 数据

字段名 字段类型 描述
token_id VARCHAR(256) 从服务器端获取到的 access_token 的值.
token BLOB 这是一个二进制的字段, 存储的数据是 OAuth2AccessToken.java 对象序列化后的二进制数据.
authentication_id VARCHAR(256) 该字段具有唯一性, 是根据当前的 username(如果有),client_id 与 scope 通过 MD5 加密生成的. 具体实现请参考 DefaultClientKeyGenerator.java 类.
user_name VARCHAR(256) 登录时的用户名
client_id VARCHAR(256) 客户端 id

oauth_access_token ===> 生成的 token 数据

字段名 字段类型 描述
token_id VARCHAR(256) 从服务器端获取到的 access_token 的值.
token BLOB 存储将 OAuth2AccessToken.java 对象序列化后的二进制数据, 是真实的 AccessToken 的数据值.
authentication_id VARCHAR(256) 该字段具有唯一性, 其值是根据当前的 username(如果有),client_id 与 scope 通过 MD5 加密生成的.
user_name VARCHAR(256) 登录时的用户名, 若客户端没有用户名(如 grant_type=“client_credentials”),则该值等于 client_id
client_id VARCHAR(256) 客户端 id
authentication BLOB 存储将 OAuth2Authentication 对象序列化后的二进制数据.
refresh_token VARCHAR(256) 该字段的值是将 refresh_token 的值通过 MD5 加密后存储的.

oauth_refresh_token ===> 刷新 token

字段名 字段类型 描述
token_id VARCHAR(256) 该字段的值是将 refresh_token 的值通过 MD5 加密后存储的.
token BLOB 存储将 OAuth2RefreshToken.java 对象序列化后的二进制数据.
authentication BLOB 存储将 OAuth2Authentication.java 对象序列化后的二进制数据.

oauth_code ===> 服务端生成的 code 值

字段名 字段类型 描述
code VARCHAR(256) 存储服务端系统生成的 code 的值(未加密).

oauth_approvals ===> 授权同意信息

字段名 字段类型 描述
userId VARCHAR(256) 用户 id
clientId VARCHAR(256) 客户端 id
scope VARCHAR(256) 请求的范围
status VARCHAR(10) 授权的状态
expiresAt TIMESTAMP 时间
lastModifiedAt TIMESTAMP 最后修改的时间

最后一张 ClientDetails 是我们要自定义他的 表 的情况,在我们需要自定义的时候使用,但是目前我们暂时不去自定义,所以无用。

所以你现在的项目结构应该如下

mysql

记得启动测试一下,确定不报错。

接下来我们就是来进行配置了,同样的,分为客户端信息配置和令牌配置

客户端信息

同样,对于客户端的配置我们主要实现对参数为 ClientDetailsServiceConfigurer 的方法进行配置,我们需要完成以下两步:

  1. 构建一个 jdbc 的 ClientDetailsService,通过他来链接数据库。
  2. 将它配置进 ClientDetailsServiceConfigurer 之中。

我们首先先来配置一个 jdbc 的 ClientDetailsService ,非常简单,因为他已经提供了默认的实现了的,构建方式如下:

// 数据源
private final @NonNull DataSource dataSource;

/**
 * 声明 ClientDetails实现
 *
 * @return ClientDetailsService
 */
@Bean
public ClientDetailsService clientDetails() {
    return new JdbcClientDetailsService(dataSource);
}

然后将他配置进 ClientDetailsServiceConfigurer 之中,如下:

@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
    clients.withClientDetails(clientDetails());
}

config

然后我们启动并添加一条客户端信息

添加一条数据

用密码模式测试一下

test

然后我们用授权码模式测试一下,访问地址 http://localhost:8000/oauth/authorize?response_type=code&client_id=oauth2&redirect_uri=http://example.com&scope=all 然后登录

login

code

这样就获取到授权码了,这样就完成了客户端的 mysql 存储,但是现在 token 还是存在内存中的,下面我们将它存在数据库中。

令牌存储

我们使用 mysql 对令牌进行存储有个最大的好处,就是在授权服务器重启后,以前下发的令牌依旧有效,不用让用户重复登录。和客户端一样配置十分简单,它主要配置参数为 AuthorizationServerEndpointsConfigurer 的 配置方法。同样也只需要两步:

  1. 构建一个 jdbc 的 TokenStore,通过他来链接数据库。
  2. 将它配置进 AuthorizationServerEndpointsConfigurer 之中。

我们先来完成第一步,如下:

/**
 * 声明 jdbc TokenStore实现
 *
 * @return JdbcTokenStore
 */
@Bean
public TokenStore jdbcTokenStore() {
    return new JdbcTokenStore(dataSource);
}

然后完成第二步,如下:

@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
    endpoints.authenticationManager(this.authenticationManager)
        .tokenStore(jdbcTokenStore());
}

所以现在应该是这样的

now

运行测试一下,先请求下 token

token

看一下表有没有 token 存进去

data

会发现两张表的数据存进去了,来看看授权码呢?

code

get

这就完成使用 mysql 存储令牌的配置。

基于 redis 的令牌存储

代码参见项目模块 spring-security-oauth2-authorization-redis

模块创建步骤省略

我们将在内存中存储和读取客户端信息以及在 redis 中存储令牌信息

  • 优点:速度快,项目重启 token 依旧有效且适用于分布式场景。
  • 缺点:想不到。。。
  • 适用场景:通用。

在这之前你要添加如下的 redis 依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

同时修改你的配置文件:

server:
  port: 8000

#如果有密码记得设置,没有就不管
#spring:
  #redis:
    #password: 123456

然后复制一下第一个模块的内存客户端,现在的项目结构如下:

next

同样我们只需要两步,配置 token store,让他生效即可

config

运行测试一下

请求

查看下 redis

redis

这样我们就将 token 存粗进 redis 内了!

基于 jwt 的令牌生成与配置

代码参见项目模块 spring-security-oauth2-authorization-jwt

模块创建步骤省略

我们将在内存中存储和读取客户端信息存储令牌信息,使用 jwt 规范化 token:

  • 优点:jwt 可以加密,可以携带更多的信息。
  • 缺点:token 会变得比较长
  • 适用场景:通用。

具体什么是 jwt,可以参考 什么是 JWT -- JSON WEB TOKEN 这篇文章,很不错。我们要实现的就是将现在的 access_tokenrefresh_token 两个字段使用 jwt 代替。jwt 的第三部分 signature 是一个签证信息,这个签证信息由三部分组成:

  • header (base64 后的)
  • payload (base64 后的)
  • secret

这个部分需要 base64 加密后的 header 和 base64 加密后的 payload 使用 . 连接组成的字符串,然后通过 header 中声明的加密方式进行加盐 secret 组合加密,然后就构成了 jwt 的第三部分。而 secret 进行组合加密就涉及到两种加密方式:

  • 对称加密:又称私钥加密,即信息的发送方和接收方用一个密钥去加密和解密数据。它的最大优势是加/解密速度快,适合于对大数据量进行加密,对称加密的一大缺点是密钥的管理与分配,换句话说,如何把密钥发送到需要解密你的消息的人的手里是一个问题。在发送密钥的过程中,密钥有很大的风险会被黑客们拦截。现实中通常的做法是将对称加密的密钥进行非对称加密,然后传送给需要它的人。而在 spring security 之中的相应的实现类是 org.springframework.security.jwt.crypto.sign.MacSigner

    Signer  jwtSigner = new MacSigner("hand");//默认HMACSHA256 算法加密
    Signer  jwtSigner = new MacSigner("HMACSHA256","hand");//手动设置算法
    
  • 非对称加密:又称公钥密钥加密。非对称加密为数据的加密与解密提供了一个非常安全的方法,它使用了一对密钥,公钥(public key)和私钥(private key)。私钥只能由一方安全保管,不能外泄,而公钥则可以发给任何请求它的人。非对称加密使用这对密钥中的一个进行加密,而解密则需要另一个密钥。在 spring security 之中的相应实现是 org.springframework.security.jwt.crypto.sign.RsaSigner

    KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(new ClassPathResource("mytool.jks"), "mypass".toCharArray());
    KeyPair demo = keyStoreKeyFactory.getKeyPair("mytool");
    Signer jwtSigner = new RsaSigner((RSAPrivateKey)demo.getPrivate());
    

我们从三个方面学习:

  1. 使用对称密钥生成 jwt 令牌
  2. 使用非对称密钥生成 jwt 令牌
  3. 为 jwt 添加更多的信息

在现在的模块中添加如下依赖:

<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>

配置文件如下:

server:
  port: 8000

初始化的结构应该如下:

client

使用对称密钥生成 jwt 令牌

从前面几次经验来看,应该知道要配置一个令牌的存储,最为核心的就是配置相应的 TokenStore 了。配置 jwt 也是一样需要配置一个 JwtTokenStore,前面的 JdbcTokenStore 需要的是数据源,那现在的 jwt 需要的是什么呢?他需要一个叫做 令牌转换器 的东西,有了他我们才能够生成 jwt 格式的 token,所以我们需要如下几步:

  1. 创建 令牌转换器
  2. 创建 JwtTokenStore
  3. 配置进 AuthorizationServerEndpointsConfigurer

我们先来第一步,配置令牌转换器。令牌转换器就是帮助程序在 JWT 编码的令牌和 OAuth 身份验证信息之间进行转换,既然我们选择对称密钥,那么我们就直接设置即可,如下:

@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
    // 3. 配置进 AuthorizationServerEndpointsConfigurer
    endpoints.authenticationManager(this.authenticationManager)
        .tokenStore(tokenStore())
        .accessTokenConverter(jwtAccessTokenConverter());
}

/**
 * 1. 令牌转换器,对称密钥加密
 *
 * @return JwtAccessTokenConverter
 */
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
    JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
    converter.setSigningKey("oauth2");
    return converter;
}

/**
 * 2. token store 实现
 *
 * @return JwtTokenStore
 */
@Bean
public TokenStore tokenStore() {
    return new JwtTokenStore(jwtAccessTokenConverter());
}

截图如下:

show

前面说了要用的是 MacSigner,那么这里为什么没用呢?原因自然是已经为我们实现了,我们来看看他怎么实现的:

see

see

然后我么启动测试一下!

test

我们去检验一下,检验网址:jwt.io

jwt

这样就完成了对称加密的 jwt 生成

使用非对称密钥生成 jwt 令牌

大多时候,我们更加需要的是一个安全的授权服务器,所以更加愿意选择 非对称加密 来生成 jwt 令牌,现在我们来完成这件事,需要如下步骤:

  1. 生成密钥对
  2. 创建 令牌转换器
  3. 创建 JwtTokenStore
  4. 配置进 AuthorizationServerEndpointsConfigurer

我们首先利用 keytool 进行密钥对的生成

➜  resources git:(master)pwd
/home/echo/IdeaProjects/spring-security-oauth2-demo/spring-security-oauth2-authorization-jwt/src/main/resources
➜  resources git:(master) ✗ keytool -genkey -alias oauth2 -keyalg RSA -keystore oauth2.jks -keysize 2048                  
输入密钥库口令:  
再次输入新口令: 
您的名字与姓氏是什么?
  [Unknown]:  oauth2 
您的组织单位名称是什么?
  [Unknown]:  oauth2 
您的组织名称是什么?
  [Unknown]:  oauth2
您所在的城市或区域名称是什么?
  [Unknown]:  oauth2
您所在的省/市/自治区名称是什么?
  [Unknown]:  oauth2
该单位的双字母国家/地区代码是什么?
  [Unknown]:  oauth2
CN=oauth2, OU=oauth2, O=oauth2, L=oauth2, ST=oauth2, C=oauth2是否正确?
  []:  y

输入 <oauth2> 的密钥口令
        (如果和密钥库口令相同, 按回车):  
再次输入新口令: 

Warning:
JKS 密钥库使用专用格式。建议使用 "keytool -importkeystore -srckeystore oauth2.jks -destkeystore oauth2.jks -deststoretype pkcs12" 迁移到行业标准格式 PKCS12。
➜  resources git:(master) ✗ keytool -importkeystore -srckeystore oauth2.jks -destkeystore oauth2.jks -deststoretype pkcs12
输入源密钥库口令:  
已成功导入别名 oauth2 的条目。
已完成导入命令: 1 个条目成功导入, 0 个条目失败或取消

Warning:
已将 "oauth2.jks" 迁移到 Non JKS/JCEKS。将 JKS 密钥库作为 "oauth2.jks.old" 进行了备份。

keytool

下面就是生成公钥:

keytool -list -rfc --keystore oauth2.jks | openssl x509 -inform pem -pubkey

public

现在应该有如下两个文件

files

接下来配置增强器这些


@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
    endpoints.authenticationManager(this.authenticationManager)
        .tokenStore(tokenStore())
        .accessTokenConverter(jwtAccessTokenConverter());
}

/**
 * 令牌转换器,非/对称密钥加密
 *
 * @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;
}

/**
 * token store 实现
 *
 * @return JwtTokenStore
 */
@Bean
public TokenStore tokenStore() {
    return new JwtTokenStore(jwtAccessTokenConverter());
}

config

然后接下来我们进行测试,请求数据:

res

进行校验

v

请注意提示框内的提示信息

Public Key or Certificate. Enter it in plain text only if you want to verify a token

公钥或证书。仅当您想验证令牌时,才以纯文本形式输入它

Private Key. Enter it in plain text only if you want to generate a new token. The key never leaves your browser.

私钥。只有在希望生成新令牌时,才以纯文本形式输入它。密钥永远不会离开浏览器。

所以我们只需要去复制公钥给他即可!~

get

ok

这样 jwt 的非对称加密其实就完成了!~ 这样如果资源服务器要请求我们资源,必须要有授权服务器的公钥才能够成功通过认证得到用户信息 ~!

为 jwt 添加更多的信息

前面我们提到的一个优点就是能够添加许多自定义信息,我们就来添加一下这个自定义信息。这个时候我们就需要一个 令牌增强器(前面的粗心打错了,,,图片改不了不好意思凑合看啦 ~)我们需要一个类来实现 TokenEnhancer 接口,我们分为如下几步:

  1. 实现 TokenEnhancer 接口
  2. 使用一个复合令牌增强器 TokenEnhancerChain,循环遍历将其委托给增强器。
  3. 配置进 AuthorizationServerEndpointsConfigurer
@Component
public class InfoTokenEnhancer implements TokenEnhancer {
    @Override
    public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
        // 创建一个自定义信息
        Map<String, Object> additionalInfo = new HashMap<>(1);
        // 设置值
        additionalInfo.put("organization", authentication.getName());
        // 存进去
        ((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(additionalInfo);
        // 返回
        return accessToken;
    }
}

impl

对于不是很复杂的逻辑,我更加喜欢使用 lambda 来写一个匿名内部类的方式:

@Bean
public TokenEnhancer tokenEnhancer() {
    return (accessToken, authentication) -> {
        Map<String, Object> additionalInfo = new HashMap<>(1);
        additionalInfo.put("organization", authentication.getName());
        ((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(additionalInfo);
        return accessToken;
    };
}

然后需要配置使用一个复合令牌增强器 TokenEnhancerChain,循环遍历将其委托给增强器:

TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
tokenEnhancerChain.setTokenEnhancers(
    Arrays.asList(tokenEnhancer(), jwtAccessTokenConverter()));

endpoints.tokenStore(tokenStore())
    .tokenEnhancer(tokenEnhancerChain)
    .authenticationManager(authenticationManager);

security

我们启动来测试一下:

test

这样就完成了增强器!

这就是 jwt 的所有内容啦 ~ 他完全可以和 redis 令牌存储、mysql 令牌存储一起使用!

授权服务器小扩展

代码参见项目模块 spring-security-oauth2-authorization-expansion

模块创建步骤省略

之前我们一直都是 配置如何获取客户端信息令牌的生成与存储,但其实我们还有些小的问题没有解决:

  1. /oauth/check_token 端点的开放
  2. refresh_token 授权类型
  3. 授权码模式登录页面的自定义
  4. 授权码模式授权页面的自定义

为什么叫做小扩展,因为这些问题都是不需要太多的代码就能够实现的。

在这之前我们完成模块的初始化,添加如下依赖:

<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>

配置文件如下:

server:
  port: 8000

复制第一个项目的配置如下:

next

确保能够启动成功且在 8000 端口

/oauth/check_token 端点的开放

这个端点的开放就要用到我们前面一直没有用的第三个方法了,参数为 AuthorizationServerSecurityConfigurer 的方法,只要一句话就可以了:

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

利用权限表达式放行即可,测试:

check

refresh_token 授权类型

如果我们直接去尝试,我们看看回报什么错

token

refresh

我们明明已经把它注册为组件了,但是还是找不到。主要原因是因为授权服务器的这里的安全需要我们自己手动注入一次,我简单看啦一波源码,发现他放在 SharedObject 里面的 UserDetailsService 并不是我们自己构建的,可以尝试 debug org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerSecurityConfiguration 第 83 行,尝试几次不再授权服务器中修改都不行,只有在授权服务器中的配置修改:

@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
    endpoints.authenticationManager(this.authenticationManager)
        .userDetailsService(userDetailsService);
}

修改后请求刷新:

refresh

这样才能成功

授权码模式登录页面的自定义

其实这一部分就是和 spring security 配置自定义登录页面是一样的,如果以前使用过 spring security 应该很快就能明白。

对于授权码模式,我们重定向过去以后会有一个默认的登录页面

login

但是这个登录页面有时候我们想去自定义,其实有两种方式来完成修改:

  • 直接使用静态文件
  • 使用模板引擎

不过我们这里只说静态文件,使用模板引擎放在下面和自定义授权页面一起说。

自定义表单登录

我们分为两步完成:

  1. 配置路径与请求
  2. 填充页面与修改

我们如何配置呢?其实这里使用的就是 spring security 的知识了,spring security 如何配置,这里就如何配置所以应该配置的类是我们之前一直复制下来的 SecurityConfig

@Override
protected void configure(HttpSecurity http) throws Exception {
    http.formLogin()
        // 登录页面名称,他会去寻找 resources 下的 resources 和 static 目录
        .loginPage("/login.html")
        // 登录表单提交的路径
        .loginProcessingUrl("/authorization/form")
        .and()
        // 关闭 csrf 防护,因为对于我们的所有请求来说,都是需要携带身份信息的
        .csrf().disable();
}

这里要关闭 csrf 防护,关于 csrf 防护请看 这篇文章,在里面提到有效防护 csrf 的一种方式是 在请求地址中添加 token 并验证,我们的类似,请求地址中添加了客户端名称和回调地址进行了验证,所以可以不用单行 csrf 攻击问题。

然后我们需要创建一个静态的登录页面,我从网上随便下了一个模板,存放到 resources/static 目录(也可以是 resources/resources 目录,但是路径里面两个 resources 很是奇怪,所以使用 static),如下:

html

然后我们运行试一下,请求授权码模式的地址如下:

http://localhost:8000/oauth/authorize?response_type=code&client_id=oauth2&redirect_uri=http://example.com&scope=all

login

就跳转到了我们自定义的登录页面。登录尝试:

success

登录成功了,但是又是丑得一的授权页面,这个我们,现在我们换一个方式,使用 模板引擎 的方式自定义。同时如何修改授权页面

其他的登录方式

spring security 一样,除了表单处理,还会有其他的方式,比如 basic,也就是对话框登录。只需要配置一步即可:

@Override
protected void configure(HttpSecurity http) throws Exception {
	http.httpBasic();
}

basic

授权码模式授权页面的自定义

对于授权码模式的授权页面,我们必须使用模板引擎,因为他的基础还是 spring security,所以摆脱不论 session 的安全管理机制,使用模板引擎的方式有什么好处呢?

  1. 可以传递模板变量自定义很多地方
  2. 可以自定义认证逻辑

我们使用模板引擎完成两件事

  1. 自定义登录页面
  2. 自定义授权页面

在这之前我们要选择一个模板引擎,我选择 thymeleaf,其他的如 freemarker 同理,添加依赖如下:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

添加唉 templates 目录

登录页面

我们前面说到模板引擎的一个好处就是可以配置模板变量,那么我们就来试一试自定义登录的路径。我们创建一个配置类 SecurityProperties,读取配置文件:

@Data
@Configuration
@ConfigurationProperties("application.security.oauth")
public class SecurityProperties {

    /**
     * 登录请求的路径,默认值 /authorization/form
     */
    private String loginProcessingUrl = "/authorization/form";

}

把配置类放在安全配置之中,然后配置上去,如下:

private final @NonNull SecurityProperties securityProperties;
@Override
protected void configure(HttpSecurity http) throws Exception {

    //        静态登录页面的配置
    http.formLogin()
        // 登录页面名称,他会去寻找 resources 下的 resources 和 static 目录
        // 静态页面
        //.loginPage("/login.html")
        // 模板引擎
        .loginPage("/oauth/login")
        // 登录表单提交的路径
        // 静态页面
        // .loginProcessingUrl("/authorization/form")
        // 模板引擎
        .loginProcessingUrl(securityProperties.getLoginProcessingUrl());
    //                .and()
    // 关闭 csrf 防护,因为对于我们的所有请求来说,都是需要携带身份信息的
    //                .csrf().disable();

}

这次我们选择开启 csrf 防护,因为我们现在可以有效的控制她,当然,不开启其实影响也不大。

然后我们需要一个 OauthController 用来接收请求以及渲染模板

@Controller
@RequestMapping("/oauth")
@RequiredArgsConstructor
public class OauthController {

    private final @NonNull SecurityProperties securityProperties;

    @GetMapping("login")
    public String loginView(Model model) {
        model.addAttribute("action", securityProperties.getLoginProcessingUrl());
        return "form-login";
    }

}

添加登录页面,这里就要用到模板引擎的知识了,这个就靠大家自己下去查查资料什么的了,我的如下:

login

这样其实就配置完成登录页面了,我们配置完授权页面一起测试把

授权页面

我们需要自定义授权的控制器。我们要做的就是写一个相同的端点 /oauth/confirm_access 进行覆盖,所以就需要另外一个 controller,如下:

@Controller
@SessionAttributes("authorizationRequest")  // 重要!
public class AuthorizationController {
    @RequestMapping("/oauth/confirm_access")
    public ModelAndView getAccessConfirmation(Map<String, Object> model, HttpServletRequest request) throws Exception {
        AuthorizationRequest authorizationRequest = (AuthorizationRequest) model.get("authorizationRequest");
        ModelAndView view = new ModelAndView();
        view.setViewName("authorization");
        view.addObject("clientId", authorizationRequest.getClientId());
        return view;
    }
}

controller

添加页面

page

当然你也可以让他使用选择的方式,选择是否授权,我的这里没有提供拒绝的选项,

测试

我们运行测试一下,访问路径:

http://localhost:8000/oauth/authorize?response_type=code&client_id=oauth2&redirect_uri=http://example.com&scope=all

发现上面报错,转化 json 后如下:

{
 	"error" :"access_denied",
    "error_description" : "User denied access"
}

用户拒绝访问,也就是用户没有同意授权,但是明明是确定授权,问题出来哪儿呢?

解决问题

对于授权控制器,它提供了一套默认的实现,具体参见 org.springframework.security.oauth2.provider.endpoint.AuthorizationEndpoint#authorize,有兴趣的小伙伴可以 debug 一下。问题也就是在这里,我们传递的参数过去,但是却没有声明她同意的范围,也就是 scope 字段,那么现在就需要传递一个 scope 同意授权的字段过去了,如下:

@Controller
@SessionAttributes("authorizationRequest")
public class AuthorizationController {
    @RequestMapping("/oauth/confirm_access")
    public ModelAndView getAccessConfirmation(Map<String, Object> model, HttpServletRequest request) throws Exception {
        AuthorizationRequest authorizationRequest = (AuthorizationRequest) model.get("authorizationRequest");
        ModelAndView view = new ModelAndView();
        view.setViewName("authorization");
        view.addObject("clientId", authorizationRequest.getClientId());
        // 传递 scope 过去,Set 集合
        view.addObject("scopes", authorizationRequest.getScope());
        // 拼接一下名字
        view.addObject("scopeName", String.join(",", authorizationRequest.getScope()));
        return view;
    }
}

表单添加

<div class="wrap-input100 validate-input m-b-23">
    <input type="hidden" name="user_oauth_approval" value="true">
    <div style="display: none" th:each="scope : ${scopes}">
        <input type="hidden" th:name="'scope.' + ${scope}" value="true">
    </div>
    <input type="hidden" name="_csrf" th:value="${_csrf.token}"/>
</div>

add

然后再来测试一下

这样就完成啦 ~!

总结

这基本就是授权服务器的所有配置了,当然,只是实践性阶段,并没有涉及太多源码,后面会考虑要不要写一篇源码的说明的,但是担心自己能力不够,所以还是没敢写上去。不过这一节内容挺多的,基本上适合各种场景了,我们需要做的就是按照自己的要求来配置,其实配置文件不多不是很复杂,熟悉了就好。下面一节我们就要进入资源服务器的配置啦 ~!相比来说会简单一点,但是资源服务器是离不开授权服务器的,所以两者是有关系的,慢慢来吧,估计要三天左右才能写完,存货已经没啦 ~

参考资料

  • 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 回帖 • 6 关注

相关帖子

欢迎来到这里!

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

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

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

  • 其他回帖
  • lizhongyue248

    @88250 为啥文章图片在黑客派全 403 了。防盗链配置没有毛病啊。。。而且防盗链很久以前配置的了,以前都可以的。。。

    TIM 截图 20191204125009.png

    1 回复
  • gnng

    我就想问问主题是啥。

    1 回复
  • lizhongyue248

    啊?没有呀。。。

  • 查看全部回帖