spring oauth2学习笔记

一 主要参考资料

https://echocow.cn/articles/2019/07/14/1563082088646.html

1.1 博客地址:

echocow.cn【重点关注,对应的代码路径为:/Users/leixingbang/GitProject/spring-security-oauth2-demo】

https://blog.csdn.net/lightofmiracle/article/details/79151074

https://segmentfault.com/u/wotrd/articles?page=2

https://segmentfault.com/a/1190000019344734

极客时间相关代码【/Users/leixingbang/GitProject/spring-security-oauth2-demo】

1.2 github相关的代码(极客时间)

https://github.com/spring2go/

https://github.com/spring2go/oauth2lab

二 授权服务器的主要端点

主要提供了几个端点。

(1)授权端点:

/oauth2/authorize

对应授权端点配置的源码eg:

@Configuration
@RequiredArgsConstructor
@EnableAuthorizationServer
public class Oauth2AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

    private final @NonNull AuthenticationManager authenticationManager;
    private final @NonNull UserDetailsService userDetailsService;


    public BCryptPasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    /**
     * 用来配置授权端点:/oauth/authorize,通过次端点跳转到授权服务器进行认证。
     * response_type 对应authorizedGrantTypes 授权类型,由于这里是授权码方式所以填:authorization_code
     * client_id:对应客户端的id
     * scope:申请的授权范围
     * redirect_uri 获取授权码后重定向的地址
     * state 客户端的当前状态,认证服务器会原封返回
     * @param clients
     * @throws Exception
     */

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
       clients.inMemory()
               .withClient("client")
               .secret(passwordEncoder().encode("secret"))
               .authorizedGrantTypes("authorization_code")
               .scopes("app")
               .redirectUris("www.baidu.com");
    }

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

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

}

http://localhost:9000/oauth/authorize?client_id=client&response_type=code

http://localhost:9000/oauth/authorize?client_id=client&response_type=code

对应极客时间的请求获取授权码路径:

http://localhost:8080/oauth/authorize?client_id=clientapp&redirect_uri=http://localhost:9001/callback&response_type=code&scope=read_userinfo

这里的client_id对应的授权客户端id,scope对应的是授权的范围。类似于猎聘网客户端读取用户头像(一个授权范围)、用户名(一个授权范围)

场景:使用微信账号登陆猎聘网,(在猎聘网中获取微信的token)跳转到用户登录页面(eg:qq登录),进行授权.

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ETbbZOtE-1614821714739)(认证服务器.png)]

认证通过后,会跳转到截图中定义的重定向地址,并返回授权码。(相当于猎聘网获取到微信的授权码)

http://101.132.163.122:8848/nacos/?code=9Qb5SM#/login

猎聘网(客户端)得到授权码后,可以使用授权码获取对应的token,拿到token后就可以访问资源服务器中的内容。(eg:访问获得微信头像)

4.1.1对应uac系统的代码

com.ilxb.ipd.uac.config.AuthorizationServerConfig

(2) token端点(令牌端点)获取token的端点

/oauth2/token

这里的token就是对应的授权码。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YdBeFNuI-1614821714750)(/Users/leixingbang/Desktop/spring oauth2学习/客户端使用授权码获取对应的token.png)]

如果报401错误则需要设置:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-46kusVKR-1614821714751)(获取token报401错误.png)]

Authorization:Basic Auth

其username为:client_id

password为:client_secrit

本地示例:http://client:secret@localhost:8000/oauth/token

对应的key值:

grant_type:authorization_code

code:WUCkt1

参数名称是否必填描述
grant_typeREQUIRED使用的授权模式,值固定为"authorization_code"
codeREQUIRED上一步获得的授权码
redirect_uriREQUIRED重定向URI,必须与上一步中的该参数值保持一致
client_idREQUIRED客户端的 id
scopeRECOMMENDED授权范围,必须与第一步相同

选填值:redirect_uri 描述:重定向URI,必须与上一步中的该参数值保持一致

返回的参数:grant_type、token_type、expires_in、scope对应的含义如下:

参数名称是否必有描述是否有实现
access_tokenREQUIRED授权服务器颁发的访问令牌
token_typeREQUIRED令牌类型,该值大小写不敏感,可以是bearer类型或mac类型
expires_inRECOMMENDED过期时间,单位为秒
refresh_tokenOPTIONAL表示更新令牌,用来获取下一次的访问令牌是,需要设置
scopeOPTIONAL权限范围,如果有,则与客户端申请的范围一致

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

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

(3)introspect 校验端点

/oauth2/introspect

(4)吊销端点

/oauth2/revoke

三 Spring security Oauth2 架构

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Hem29h0i-1614821714753)(/Users/leixingbang/Desktop/spring oauth2学习/架构图.png)]

**(1)客户端:**左侧为spring security为服务器提供的客户端框架类。

(2)授权服务器

(3)资源服务器

红色部分是spring security提供的服务。黄色为Spring security Oauth2提供的服务。绿色部分为开发人员需要自己实现的服务。

如果一开始,直接调用资源服务器的服务,则客户端中OAuth2ResTemplate会报错,然后重定向到授权服务器。授权服务器首先会去授权端点【authrization】进行授权,随后通过AuthorizationServerTokenService获取到授权码。取到授权码后,客户端的OAuthRestTemplate会通过TokenEndPoint获取访问令牌【具体生成仍然是AuthorizationServerTokenService】。访问资源服务器时,资源服务器通过ResourceServerTokenService对token进行校验,并且填充与用户相关的上下文。

四 授权服务器实验

4.1 授权码模式

1. 获取授权码

浏览器请求:

http://localhost:8080/oauth/authorize?client_id=clientapp&redirect_uri=http://localhost:9001/callback&response_type=code&scope=read_userinfo

输入该地址之后,需要输入spring-security中的用户名和密码。相当于QQ用户登录,然后再给客户端授权。

注意:state参数暂忽略

响应案例:

http://localhost:9001/callback?code=8uYpdo

2. 获取访问令牌

curl -X POST --user clientapp:112233 http://localhost:8080/oauth/token -H “content-type: application/x-www-form-urlencoded” -d “code=8uYpdo&grant_type=authorization_code&redirect_uri=http://localhost:9001/callback&scope=read_userinfo”

注意这里的redirect_uri必须与步骤1中的完全一致,否则将会报错。

案例响应:

{
    "access_token": "36cded80-b6f5-43b7-bdfc-594788a24530",
    "token_type": "bearer",
    "expires_in": 43199,
    "scope": "read_userinfo"
}

3. 调用API

curl -X GET http://localhost:8080/api/userinfo -H “authorization: Bearer 36cded80-b6f5-43b7-bdfc-594788a24530”

案例响应:

{
    "name": "bobo",
    "email": "bobo@spring2go.com"
}

4.2 简化[implict]模式

简化模式,直接获取令牌。

授权服务器主要核心代码:

import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;

//授权服务器配置
@Configuration
@EnableAuthorizationServer
public class OAuth2AuthorizationServer extends
        AuthorizationServerConfigurerAdapter {

    @Override
    public void configure(ClientDetailsServiceConfigurer clients)
            throws Exception {
        clients.inMemory()
            .withClient("clientapp")
            .secret("112233")
            .redirectUris("http://localhost:9001/callback")
            // 简化模式
            .authorizedGrantTypes("implicit")
            .accessTokenValiditySeconds(120)//配置失效时间
            .scopes("read_userinfo", "read_contacts");
    }

}

1. 客户端获取访问令牌

浏览器请求:

http://localhost:8080/oauth/authorize?client_id=clientapp&redirect_uri=http://localhost:9001/callback&response_type=token&scope=read_userinfo&state=abc

注意简化模式只是将response_type改为了token【对比授权码模式为code】

根据提示输入用户名密码(在[application.properties](file:///Users/leixingbang/GitProject/oauth2lab/lab01/implicit-server/src/main/resources/application.properties)文件里头)进行认证,并Approve授权。

响应案例:

http://localhost:9001/callback#access_token=0406040a-779e-4b5e-adf1-bf2f02031e83&token_type=bearer&state=abc&expires_in=119

2. 调用API

curl -X GET http://localhost:8080/api/userinfo -H “authorization: Bearer 0406040a-779e-4b5e-adf1-bf2f02031e83”

案例响应:

{
    "name": "bobo",
    "email": "bobo@spring2go.com"
}

4.3 密码模式

密码模式下授权服务器资源配置:

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;

// 授权服务器配置
@Configuration
@EnableAuthorizationServer
public class OAuth2AuthorizationServer extends
        AuthorizationServerConfigurerAdapter {

	// 用户认证
    @Autowired
    private AuthenticationManager authenticationManager;

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

    @Override
    public void configure(ClientDetailsServiceConfigurer clients)
            throws Exception {
        clients.inMemory()
            .withClient("clientapp")
            .secret("112233")
            // 密码模式
            .authorizedGrantTypes("password")
                .accessTokenValiditySeconds(120)//令牌超时时间
            .scopes("read_userinfo", "read_contacts");//授权范围
    }

}

这种模式可以理解成我们普通应用的用户名密码登录,在第三方接入的时候不建议使用这种模式,但是如果是自己的应用,那么这种模式是最为简单方便快捷的了。步骤只有一个:

注意:以下所有请求都必须在请求头中携带上面所说的客户端加密信息!

  • 用户携带用户名密码请求授权服务器,验证通过后下发令牌

他只需要一个请求,所以它只有一个令牌端点:

令牌端点

  • /oauth/token:令牌端点,携带如下参数请求即可:
请求参数是否必填描述
grant_typeREQUIRED使用的密码模式,值固定为"password"
usernameREQUIRED用户名
passwordREQUIRED密码
scopeOPTIONAL请求权限范围

请求成功和失败的响应同授权码模式。

1.通过/oauth/token 令牌端点,获取访问令牌

教程请求参数:

curl -X POST --user clientapp:112233 http://localhost:8080/oauth/token -H "accept: application/json" -H "content-type: application/x-www-form-urlencoded" -d "grant_type=password&username=bobo&password=xyz&scope=read_userinfo"

自测请求参数:

curl --location --request POST 'http://localhost:8080/oauth/token?grant_type=password&username=bobo&password=xyz&scope=read_userinfo' \
--header 'Authorization: Basic Y2xpZW50YXBwOjExMjIzMw=='

请求返回结果:

{
    "access_token": "87f90722-f544-40ee-a63e-afb175a61372",
    "token_type": "bearer",
    "expires_in": 42919,
    "scope": "read_userinfo"
}

2.使用获取的token,调用API

curl --location --request POST 'http://localhost:8080/api/userinfo' \

--header 'Authorization: Bearer 87f90722-f544-40ee-a63e-afb175a61372'

获取返回结果:

{
    "name": "bobo",
    "email": "bobo@spring2go.com"
}

3.uac系统使用客户端来获取token

URL/oauth/token
描述获取access_token
接口协议HTTP POST
接口定义接口参数字段名称是否必须类型备注
username用户名string登录标识,支持用户名、手机号、邮箱
password密码string
client_id应用标识string授权服务器分配,标识client身份采用HTTP basic方式验证可不传client_id、client_secret,添加header"Authorization", “Basic Y2xpZW50X3Rlc3QyOnRlc3Q=”
client_secret应用秘钥string授权服务器分配
grant_type授权类型stringoauth2.0授权模式,此处采用password模式
scope授权作用域string控制应用访问权限,默认all
接口响应

接口响应示例:

{
  "access_token":"6de62e2a-7179-43af-a39f-704054a3df7c",
  "token_type":"bearer",
  "refresh_token":"29196c4a-3752-4ad6-9a90-32677b14dcc1",
  "expires_in":2516585,
  "scope":"all"
}

4.4 客户端模式(client credentials)

配置授权服务器:

// 授权服务器配置
@Configuration
@EnableAuthorizationServer
public class OAuth2AuthorizationServer extends
        AuthorizationServerConfigurerAdapter {

    @Override
    public void configure(ClientDetailsServiceConfigurer clients)
            throws Exception {
        clients.inMemory()
            .withClient("clientdevops")
            // 密码模式
            .secret("789")
            .authorizedGrantTypes("client_credentials")
            .scopes("devops");
    }

}

此时,已经没有用户的概念,因此不需要配置security用户,只要通过客户端即可获取对应的token

1.获取访问令牌

curl -X POST “http://localhost:8080/oauth/token” --user clientdevops:789 -d “grant_type=client_credentials&scope=devops”

响应案例:

{
    "access_token": "776b162a-949e-4dcb-b16b-9985e8171dc0",
    "token_type": "bearer",
    "expires_in": 43188,
    "scope": "devops"
}

2.调用API

curl -X GET http://localhost:8080/api/userlist -H “authorization: Bearer 776b162a-949e-4dcb-b16b-9985e8171dc0”

案例响应:

[
    {
        "name": "bobo",
        "email": "bobo@spring2go.com"
    },
    {
        "name": "eric",
        "email": "eric@spring2go.com"
    },
    {
        "name": "franky",
        "email": "franky@spring2go.com"
    }
]

4.5 refresh token & 与jwt进行交互

详细参考wik:

https://juejin.im/post/6844903549395009544

线上用户登录获取refresh_token&token详细使用方式:

curl --location --request POST 'http://gateway-ipd.online.lxb.qae/uac/oauth/token' \
--header 'Authorization: Basic Y2xpZW50X2RlbGl2ZXJ5X3BsYXRmb3JtOnl0VGwha0hE' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'username=18516879366' \
--data-urlencode 'password=^Y&U*I999lxb' \
--data-urlencode 'grant_type=password' \
--data-urlencode 'scope=all'

客户端用户名:client_delivery_platform

客户端密码:ytTl!kHD

五 Spring boot oauth2自动匹配的实现

相关具体代码路径:

/Users/leixingbang/GitProject/spring-security-oauth2-demo/spring-boot-oauth2-auto-sample

具体对应的wiki:

https://echocow.cn/articles/2019/07/14/1563082247386.html

5.1 快速自定义 通过配置文件来配置客户端

spring boot 最大一个特点就是 约定大于配置,去繁就简 。既然如此,其提供了oauth2 的自动化配置.

logging:
  level:
    org:
      springframework:
        security: DEBUG

security:
  oauth2:
    # 对客户端的配置,客户端可以理解成前端或者用户
    client:
      # 注册客户端的回调地址
      registered-redirect-uri: http://example.com
      # 客户端 id
      client-id: oauth
      # 客户端密钥
      client-secret: oauth
      # 授权范围
      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
    # 资源服务器的配置
    resource:
      # 资源的 id
      id: oauth2
    # 授权服务器的配置
    authorization:
      # 允许使用 /oauth/check_token 端点
      check-token-access: isAuthenticated()

5.2 从springSecurity上下文中所获取当前用户信息

主要参考wiki:

https://segmentfault.com/a/1190000012557493

@RestController
@RequestMapping("/auth")
public class OauthController {

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

}

postman请求:

curl --location --request GET 'localhost:9000/auth/me' \
--header 'Authorization: Bearer 06f432ea-7265-44c5-8df7-b7896878de00'

返回用户信息:

{
    "authorities": [
        {
            "authority": "ROLE_USER"
        }
    ],
    "details": {
        "remoteAddress": "0:0:0:0:0:0:0:1",
        "sessionId": null,
        "tokenValue": "06f432ea-7265-44c5-8df7-b7896878de00",
        "tokenType": "Bearer",
        "decodedDetails": null
    },
    "authenticated": true,
    "userAuthentication": {
        "authorities": [
            {
                "authority": "ROLE_USER"
            }
        ],
        "details": null,
        "authenticated": true,
        "principal": "user",
        "credentials": "N/A",
        "name": "user"
    },
    "credentials": "",
    "clientOnly": false,
    "principal": "user",
    "oauth2Request": {
        "clientId": "oauth2",
        "scope": [
            "all"
        ],
        "requestParameters": {
            "client_id": "oauth2"
        },
        "resourceIds": [
            "oauth2"
        ],
        "authorities": [],
        "approved": true,
        "refresh": false,
        "redirectUri": null,
        "responseTypes": [],
        "extensions": {},
        "grantType": null,
        "refreshTokenRequest": null
    },
    "name": "user"
}

使用代码获取用户示例1:

 Object principal =SecurityContextHolder.getContext().getAuthentication().getPrincipal();
    if (principal instanceof UserDetails) {
        String username = ((UserDetails)principal).getUsername();
    } else {
        String username = principal.toString();
    }

使用代码获取用户示例2:

@GetMapping("/users/current")
    public String getCurrentUser(Principal principal) {
        OAuth2Authentication auth2Authentication = (OAuth2Authentication) principal;//此处已经确定使用OAuth2认证
        OAuth2Request oAuth2Request = auth2Authentication.getOAuth2Request());
        boolean hiddenSensitiveInfo = true;
        String login = auth2Authentication.getName();
        return userService.findOneByUsernameOrPhoneNoOrEmail(login)
                .map(currentUser -> {
                    Map<String, List<String>> permissionMap = Maps.newHashMap();
                    List<String> routerAuth = Lists.newArrayList();
                    List<String> buttonAuth = Lists.newArrayList();
                    List<String> apiAuth = Lists.newArrayList();
                    Collection<GrantedAuthority> authorities = auth2Authentication.getAuthorities();
                    authorities.forEach(grantedAuthority -> {
                        String authority = grantedAuthority.getAuthority();
                        if (authority.startsWith(Permission.PREMISSION_ROUTER_PREFIX)) {
                            routerAuth.add(authority);
                        } else if (authority.startsWith(Permission.PREMISSION_BUTTON_PREFIX)) {
                            buttonAuth.add(authority);
                        } else {
                            apiAuth.add(authority);
                        }
                        // 管理员标识
                        /*if (AdminVoter.ADMIN.equals(authority)) {
                            currentUser.setIsAdmin(Constants.ALL_YES);
                        }*/
                    });
                    permissionMap.put("routerAuth", routerAuth);
                    permissionMap.put("buttonAuth", buttonAuth);
                    permissionMap.put("apiAuth", apiAuth);
                    currentUser.setPermissionMap(permissionMap);
                    Map resultMap = JSON.parseObject(JSON.toJSONString(currentUser), Map.class);
                    if (hiddenSensitiveInfo && StringUtils.isNotBlank(currentUser.getPhoneNo())) {
                        resultMap.put("phoneNo", currentUser.getPhoneNo().replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1xxxx$2"));
                    }
                    return Constants.getResponseStr(Constants.CODE_SUC, resultMap);
                })
                .orElseGet(() -> Constants.getResponseStr(Constants.CODE_FAIL));
    }

六、资源服务器配置

/**
 * @author lxb
 * @date 2019/10/11 16:07
 * @Description: 资源访问配置
 * ResourceServerConfigurerAdapter 为资源服务器配置关键接口,只需要继承其适配器即可
 */
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
    /**
     * 资源服务器的 属性配置,默认值适用于许多应用程序
     * @param resources
     * @throws Exception
     */
    @Override
    public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
        super.configure(resources);
    }

    /**
     * 此项用来配置安全资源的访问规则,默认情况下,不在"/oauth/**”中的所有资源受保护,与spring securirty配置一样
     * @param http
     * @throws Exception
     */
    @Override
    public void configure(HttpSecurity http) throws Exception {
        http
                .requestMatchers()
                .antMatchers("/delivery/**")
        .and()
                .authorizeRequests()
                .antMatchers("/delivery/platform/analyzePackage").permitAll()
                .antMatchers("/delivery/**")
                .authenticated();
    }
}

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

数据库存储oauth表结构:

3.授权服务器相关的配置:

import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.oauth2.config.annotation.builders.ClientDetailsServiceBuilder;
import org.springframework.security.oauth2.config.annotation.builders.InMemoryClientDetailsServiceBuilder;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;
import org.springframework.security.oauth2.provider.client.BaseClientDetails;

import java.time.Duration;

/**
 * oauth2 授权服务器配置
 * 自定义授权服务器
 *
 *spring-security-oauth2会在@ConditonalOnMissingBean(AuthorizationServerConfigure.class)如果该类已经被用户自营一实现,
 * OAuth2AuthorziationServerConfiguration相关的代码将不再会执行。
 * 这里只要继承这个类即可。
 * @author <a href="https://echocow.cn">EchoCow</a>
 * @date 19-7-13 下午4:11
 */
@Configuration
@RequiredArgsConstructor
@EnableAuthorizationServer
public class Oauth2AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

    private final @NonNull AuthenticationManager authenticationManager;
    private final @NonNull ClientDetails clientDetails;

    /**
     * 配置客户端的 service,也就是应用怎么获取到客户端的信息,一般来说是从内存或者数据库中获取,已经提供了他们的默认实现,你也可以自定义。
     * @param clients
     * @throws Exception
     */
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
//        InMemoryClientDetailsServiceBuilder builder = clients.inMemory();
//        builder
//                .withClient("oauth2")
//                .secret("$2a$10$wlgcx61faSJ8O5I4nLiovO9T36HBQgh4RhOQAYNORCzvANlInVlw2")
//                .resourceIds("oauth2")
//                .authorizedGrantTypes("password", "authorization_code", "refresh_token")
//                .authorities("ROLE_ADMIN", "ROLE_USER")
//                .scopes("all")
//                .accessTokenValiditySeconds(Math.toIntExact(Duration.ofHours(1).getSeconds()))
//                .refreshTokenValiditySeconds(Math.toIntExact(Duration.ofHours(1).getSeconds()))
//                .redirectUris("http://example.com");
//        builder
//                .withClient("test")
//                .secret("$2a$10$wlgcx61faSJ8O5I4nLiovO9T36HBQgh4RhOQAYNORCzvANlInVlw2")
//                .resourceIds("test")
//                .authorizedGrantTypes("password", "authorization_code", "refresh_token")
//                .authorities("ROLE_ADMIN", "ROLE_USER")
//                .scopes("all")
//                .accessTokenValiditySeconds(Math.toIntExact(Duration.ofHours(1).getSeconds()))
//                .refreshTokenValiditySeconds(Math.toIntExact(Duration.ofHours(1).getSeconds()))
//                .redirectUris("http://example.com");

        clients.inMemory()
                .withClient("oauth2")
                    .secret("$2a$10$wlgcx61faSJ8O5I4nLiovO9T36HBQgh4RhOQAYNORCzvANlInVlw2")
                    .resourceIds("oauth2")
                    .authorizedGrantTypes("password", "authorization_code", "refresh_token")
                    .authorities("ROLE_ADMIN", "ROLE_USER")
                    .scopes("all")
                    .accessTokenValiditySeconds(Math.toIntExact(Duration.ofHours(1).getSeconds()))
                    .refreshTokenValiditySeconds(Math.toIntExact(Duration.ofHours(1).getSeconds()))
                    .redirectUris("http://example.com")
                .and()
                .withClient("test")
                    .secret("$2a$10$wlgcx61faSJ8O5I4nLiovO9T36HBQgh4RhOQAYNORCzvANlInVlw2")
                    .resourceIds("oauth2")
                    .authorizedGrantTypes("password", "authorization_code", "refresh_token")
                    .authorities("ROLE_ADMIN", "ROLE_USER")
                    .scopes("all")
                    .accessTokenValiditySeconds(Math.toIntExact(Duration.ofHours(1).getSeconds()))
                    .refreshTokenValiditySeconds(Math.toIntExact(Duration.ofHours(1).getSeconds()))
                    .redirectUris("http://example.com");

//        configClient(clients);
    }

    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]));
            }
        }
    }

    /**
     * 配置授权服务器各个端点的非安全功能,如令牌存储,令牌自定义,用户批准和授权类型。如果需要密码授权模式,需要提供 `AuthenticationManager` 的 bean。
     * @param endpoints
     */

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
        endpoints.authenticationManager(this.authenticationManager);
        // 自定义 请求 路径
        // endpoints.pathMapping("/oauth/token", "/my/token")
        //        .pathMapping("", "");
    }

    /**
     * 配置已经授权服务器的端点:check_token 通过该端点,资源服务器可以验证token
     * 示例eg:交付平台项目配置信息
     * security:
     *     oauth2:
     *         client:
     *             client-id: client_delivery_platform
     *             client-secret: ytTl!kHD
     *             access-token-uri: http://10.16.85.153:8088/oauth/token
     *             user-authorization-uri: http://10.16.85.153:8088/oauth/authorize
     *         resource:
     *             token-info-uri: http://10.16.85.153:8088/oauth/check_token
     * 配置授权服务器的安全信息,比如 ssl 配置、checktoken 是否允许访问,是否允许客户端的表单身份验证等。
     * 资源服务器所需,后面会讲
     * 具体作用见本系列的第二篇文章授权服务器最后一部分
     * 具体原因见本系列的第三篇文章资源服务器
     *
     * @param security security
     */
    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) {
        security
                .checkTokenAccess("isAuthenticated()");
    }

}

七、自定义验证码手机、邮箱验证相关的配置&uac短信验证实例

7.1 主要参考wiki&对应的代码路径

7.1.1 wiki

https://echocow.cn/articles/2019/07/30/1564498598952.html

7.1.2 对应的代码路径
/Users/leixingbang/GitProject/spring-security-oauth2-demo/spring-security-oauth2-authorization-more-grant-type/

对应的github代码路径:

https://github.com/54dabang/spring-security-oauth2-authorization-more-grant-type

7.2 两种方式实现自定义授权模式(短信、验证码)

大体的实现思路:

a.添加自定义的授权模式sms

b.添加过滤器或者controller,实现发送短信的功能,并将短信存储于redis中

c.添加过滤器,对验证码进行验证

d.由自定义端点生成对应的accesstoken

7.2.1 自定义端点方式

整体流程:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-W2QqGyba-1614821714754)(/Users/leixingbang/Desktop/spring oauth2学习/springsecurity自定义自定义验证码-自定义端点模式.png)]

1.添加对redis的maven依赖
<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>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
</dependencies>
	
2.添加授权模式

在原来的端点:/oauth/token上,我们需要添加新的授权类型,即grant_type参数应该要多一个sms或者email

对于自定义的sms或者email应:

  • grant_type —— 必须为 sms 或者 email
  • code
  • client_id
  • scope

对应代码块:Oauth2AuthorizationServerConfig

@Configuration
@RequiredArgsConstructor
@EnableAuthorizationServer
public class Oauth2AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

    private final @NonNull AuthenticationManager authenticationManager;
    private final @NonNull UserDetailsService userDetailsService;
    private final @NonNull PasswordEncoder passwordEncoder;

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
                .withClient("oauth2")
                    .secret("$2a$10$uLCAqDwHD9SpYlYSnjtrXemXtlgSvZCNlOwbW/Egh0wufp93QjBUC")
                    .resourceIds("oauth2")
                    .authorizedGrantTypes("password", "authorization_code", "refresh_token", "sms")
                    .authorities("ROLE_ADMIN", "ROLE_USER")
                    .scopes("all")
                    .accessTokenValiditySeconds(Math.toIntExact(Duration.ofHours(1).getSeconds()))
                    .refreshTokenValiditySeconds(Math.toIntExact(Duration.ofHours(1).getSeconds()))
                    .redirectUris("http://example.com")
                .and()
                .withClient("test")
                    .secret(passwordEncoder.encode("1234"))
                    .resourceIds("oauth2")
                    //注意这里自定义的授权类型 sms
                    .authorizedGrantTypes("password", "authorization_code", "refresh_token", "sms")
                    .authorities("ROLE_ADMIN", "ROLE_USER")
                    .scopes("all")
                    .accessTokenValiditySeconds(Math.toIntExact(Duration.ofHours(1).getSeconds()))
                    .refreshTokenValiditySeconds(Math.toIntExact(Duration.ofHours(1).getSeconds()))
                    .redirectUris("http://example.com");
    }

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

    /**
     * 重点
     * 先获取已经有的五种授权,然后添加我们自己的进去
     *
     * @param endpoints AuthorizationServerEndpointsConfigurer
     * @return TokenGranter
     */
    private TokenGranter tokenGranter(final AuthorizationServerEndpointsConfigurer endpoints) {
        List<TokenGranter> granters = new ArrayList<>(Collections.singletonList(endpoints.getTokenGranter()));
        granters.add(new SmsTokenGranter(endpoints.getTokenServices(), endpoints.getClientDetailsService(),
                endpoints.getOAuth2RequestFactory(), userDetailsService));
        return new CompositeTokenGranter(granters);
    }

    /**
     * 资源服务器所需,后面会讲
     * 具体作用见本系列的第二篇文章授权服务器最后一部分
     * 具体原因见本系列的第三篇文章资源服务器
     *
     * @param security security
     */
    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) {
        security
                .checkTokenAccess("isAuthenticated()");
    }

}

3.配置对应的用户,并允许对应路径的代码路径不经过授权访问
package cn.lxb.oauth.authorization.config;

import cn.lxb.oauth.authorization.auth.sms.SmsAuthenticationSecurityConfig;
import cn.lxb.oauth.authorization.validate.ValidateCodeFilter;
import cn.lxb.oauth.authorization.validate.ValidateCodeGranterFilter;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
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;
import org.springframework.security.web.authentication.preauth.AbstractPreAuthenticatedProcessingFilter;

/**
 * spring security
 *
 * @author <a href="https://echocow.cn">EchoCow</a>
 * @date 19-7-27 下午9:54
 */
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final @NonNull ValidateCodeFilter validateCodeFilter;
    private final @NonNull ValidateCodeGranterFilter validateCodeGranterFilter;
    private final @NonNull SmsAuthenticationSecurityConfig smsAuthenticationSecurityConfig;

    /**
     * 密码加密方式,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("18811753918")
                .password(passwordEncoder().encode("123456"))
                .authorities("ROLE_USER").build());
        manager.createUser(User.withUsername("admin")
                .password(passwordEncoder().encode("admin"))
                .authorities("ROLE_ADMIN").build());
        manager.createUser(User.withUsername("13712341234")
                .password(passwordEncoder().encode("123456"))
                .authorities("ROLE_ADMIN").build());
        return manager;
    }

    /**
     * 认证管理
     *
     * @return 认证管理对象
     * @throws Exception 认证异常信息
     */
    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
       /* http
                .apply(smsAuthenticationSecurityConfig)
                .and()
                .authorizeRequests()
                .antMatchers("/code/*").permitAll()
                .antMatchers("/auth/sms").permitAll()
                .antMatchers("/custom/*").permitAll()
                .anyRequest().authenticated()
                .and()
                .csrf().disable()
                .formLogin()
                .and()
                .httpBasic();


        http
                .addFilterBefore(validateCodeFilter, AbstractPreAuthenticatedProcessingFilter.class)
                .addFilterBefore(validateCodeGranterFilter, AbstractPreAuthenticatedProcessingFilter.class);*/
        http
                .authorizeRequests()
                // 添加路径
                .antMatchers("/oauth/sms").access("permitAll()")
                .antMatchers("/oauth/email").access("permitAll()")
                .antMatchers("/code/*").permitAll()
                .antMatchers("/custom/*").permitAll()
                .anyRequest()
                .authenticated()
                // 务必关闭 csrf,否则除了 get 请求,都会报 403 错误
                .and()
                .csrf().disable();

        // 添加过滤器
        http
                .addFilterBefore(validateCodeFilter, AbstractPreAuthenticatedProcessingFilter.class);

    }
}

4.添加对应的过滤器

对指定的验证路径进行拦截,sms、email

package cn.echocow.oauth.authorization.validate;

import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.StringUtils;
import org.springframework.web.context.request.ServletWebRequest;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.*;

/**
 * 验证码过滤器。
 *
 * <p>继承于 {@link OncePerRequestFilter} 确保在一次请求只通过一次filter</p>
 * <p>需要配置指定拦截路径,默认拦截 POST 请求</p>
 *
 * @author <a href="https://echocow.cn">EchoCow</a>
 * @date 2019/7/28 下午11:15
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class ValidateCodeFilter extends OncePerRequestFilter {

    private final @NonNull ValidateCodeProcessorHolder validateCodeProcessorHolder;
    private Map<String, String> urlMap = new HashMap<>();
    private AntPathMatcher antPathMatcher = new AntPathMatcher();

    @Override
    public void afterPropertiesSet() throws ServletException {
        super.afterPropertiesSet();
        // 路径拦截
        urlMap.put("/auth/sms", "sms");
        //urlMap.put("/custom/sms", "sms");
        //urlMap.put("/oauth/sms", "sms");
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String validateCodeType = getValidateCodeType(request);
        if (!StringUtils.isEmpty(validateCodeType)) {
            try {
                log.info("请求需要验证!验证请求:" + request.getRequestURI() + " 验证类型:" + validateCodeType);
                validateCodeProcessorHolder.findValidateCodeProcessor(validateCodeType)
                        .validate(new ServletWebRequest(request, response));
            } catch (Exception e) {
                e.printStackTrace();
                return;
            }
        }
        filterChain.doFilter(request, response);
    }

    private String getValidateCodeType(HttpServletRequest request) {
        if (HttpMethod.POST.matches(request.getMethod())) {
            Set<String> urls = urlMap.keySet();
            for (String url : urls) {
                // 如果路径匹配,就回去他的类型,也就是 map 的 value
                if (antPathMatcher.match(url, request.getRequestURI())) {
                    return urlMap.get(url);
                }
            }
        }
        return null;
    }
}

5.通过拦截后,生成对应的token

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YhkYWVpi-1614821714755)(/Users/leixingbang/Desktop/spring oauth2学习/springsecurity自定义自定义验证码-自定义端点模式.png)]


import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpEntity;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.common.exceptions.UnapprovedClientAuthenticationException;
import org.springframework.security.oauth2.common.exceptions.UnsupportedGrantTypeException;
import org.springframework.security.oauth2.provider.*;
import org.springframework.security.oauth2.provider.token.AuthorizationServerTokenServices;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.servlet.http.HttpServletRequest;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.HashMap;

/**
 * 自定义 controller 授权端点
 *
 * @author <a href="https://echocow.cn">EchoCow</a>
 * @date 2019/7/29 下午6:40
 */
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/custom")
public class CustomToken {

    private final @NonNull UserDetailsService userDetailsService;
    private final @NonNull ClientDetailsService clientDetailsService;
    private final @NonNull PasswordEncoder passwordEncoder;
    private final @NonNull AuthorizationServerTokenServices authorizationServerTokenServices;

    @PostMapping("/{type}")
    public HttpEntity<?> auth(HttpServletRequest request, @PathVariable String type) {

        // 判断是否是我们自定义的授权类型
        if (!"sms".equalsIgnoreCase(type) && !"email".equalsIgnoreCase(type)) {
            throw new UnsupportedGrantTypeException("Unsupported grant type: " + type);
        }

        log.info(type + " login succeed!");
        // 1. 获取客户端认证信息
        String header = request.getHeader("Authorization");
        if (header == null || !header.toLowerCase().startsWith("basic ")) {
            throw new UnapprovedClientAuthenticationException("请求头中无客户端信息");
        }

        // 解密请求头
        String[] client = extractAndDecodeHeader(header);
        if (client.length != 2) {
            throw new BadCredentialsException("Invalid basic authentication token");
        }
        String clientId = client[0];
        String clientSecret = client[1];

        // 获取客户端信息进行对比判断
        ClientDetails clientDetails = clientDetailsService.loadClientByClientId(clientId);
        if (clientDetails == null) {
            throw new UnapprovedClientAuthenticationException("客户端信息不存在:" + clientId);
        } else if (!passwordEncoder.matches(clientSecret, clientDetails.getClientSecret())) {
            throw new UnapprovedClientAuthenticationException("客户端密钥不匹配" + clientSecret);
        }
        // 2. 构建令牌请求
        TokenRequest tokenRequest = new TokenRequest(new HashMap<>(0), clientId, clientDetails.getScope(), "custom");
        // 3. 创建 oauth2 令牌请求
        OAuth2Request oAuth2Request = tokenRequest.createOAuth2Request(clientDetails);
        // 4. 获取当前用户信息
        UserDetails userDetails = userDetailsService.loadUserByUsername(request.getParameter(type));
        // 5. 构建用户授权令牌
        Authentication authentication = new UsernamePasswordAuthenticationToken(
                userDetails.getUsername(), userDetails.getPassword(), userDetails.getAuthorities());
        // 6. 构建 oauth2 身份验证令牌
        OAuth2Authentication oAuth2Authentication = new OAuth2Authentication(oAuth2Request, authentication);
        // 7. 创建令牌
        OAuth2AccessToken accessToken = authorizationServerTokenServices.createAccessToken(oAuth2Authentication);
        return ResponseEntity.ok(accessToken);
    }


    /**
     * 对请求头进行解密以及解析
     *
     * @param header 请求头
     * @return 客户端信息
     */
    private String[] extractAndDecodeHeader(String header) {
        byte[] base64Token = header.substring(6).getBytes(StandardCharsets.UTF_8);
        byte[] decoded;
        try {
            decoded = Base64.getDecoder().decode(base64Token);
        } catch (IllegalArgumentException e) {
            throw new BadCredentialsException(
                    "Failed to decode basic authentication token");
        }
        String token = new String(decoded, StandardCharsets.UTF_8);
        int delimiter = token.indexOf(":");

        if (delimiter == -1) {
            throw new BadCredentialsException("Invalid basic authentication token");
        }
        return new String[]{token.substring(0, delimiter), token.substring(delimiter + 1)};
    }


}

6.post man发送实际请求

  • 获取对应的code
curl --location --request GET 'http://localhost:8000/code/sms?sms=18811753918' \
--header 'Authorization: Basic b2F1dGgyOm9hdXRoMg==' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--header 'Cookie: JSESSIONID=B339A118FCC66F33069CEEF9E8C8D7E2' \
--data-urlencode 'grant_type=sms' \
--data-urlencode 'code=1962' \
--data-urlencode 'client_id=oauth2' \
--data-urlencode 'scope=all' \
--data-urlencode 'sms=13712341234'
  • 使用code和手机号获取对应的token

    curl --location --request POST 'http://localhost:8000/custom/sms?sms=18811753918&code=2346' \
    --header 'Authorization: Basic b2F1dGgyOm9hdXRoMg==' \
    --header 'Content-Type: application/x-www-form-urlencoded' \
    --header 'Cookie: JSESSIONID=B339A118FCC66F33069CEEF9E8C8D7E2' \
    --data-urlencode 'grant_type=sms' \
    --data-urlencode 'code=1962' \
    --data-urlencode 'client_id=oauth2' \
    --data-urlencode 'scope=all' \
    --data-urlencode 'sms=13712341234'
    

    注意:需要在header中添加username【clientId】和password【secret】对应的Base64 编码 Authorization选择 【Basic Auth】

7.2.2 spring security标准流程

八、 springboot集成Spring security认证流程

8.1相关参考博客

https://blog.csdn.net/yuanlaijike/article/details/84703690

https://blog.csdn.net/yuanlaijike/article/details/86164160

spring security整合cas单点登录

https://blog.csdn.net/u010475041/article/details/79592661

https://www.jianshu.com/p/2ba25bd3a5cb【重点学习】

Spring security源码分析:

https://juejin.cn/post/6844903841490550798

8.2认证流程图

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述


版权声明:本文为leixingbang1989原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。