Sa-Token实现单点登录

1,755 阅读18分钟

第一章 OAuth 2.0 协议简介

1.1 OAuth 2.0定义

开放授权(OAuth)是一个开放标准,允许用户让第三方应用访问该用户在某一网站上存储的私密的资源(如照片,视频,联系人列表),而无需将用户名和密码提供给第三方应用。 OAuth允许用户提供一个令牌,而不是用户名和密码来访问他们存放在特定服务提供者的数据。参考OAuth 2.0标准文件。datatracker.ietf.org/doc/html/rf…

   The OAuth 2.0 authorization framework enables a third-party
   application to obtain limited access to an HTTP service, either on
   behalf of a resource owner by orchestrating an approval interaction
   between the resource owner and the HTTP service, or by allowing the
   third-party application to obtain access on its own behalf.  

OAuth 2.0授权框架支持第三方应用程序获取对 HTTP 服务的有限访问权限,代表资源所有者编排审批交互在资源所有者和 HTTP 服务之间,或者通过允许第三方应用程序代表自己获取访问权限。简单来讲就是引入授权层,用来分离两种不同的角色:客户端和资源所有者。

1.2 OAuth2.0 涉及角色

  • 资源所有者(Resource Owner):顾名思义,资源的所有者,很多时候其就是我们普通的自然人(但不限于自然人,如某些应用程序也会创建资源),拥有资源的所有权。
  • 资源服务器(Resource Server):保存着受保护的用户资源。
  • 应用程序(Client):准备访问用户资源的应用程序,其可能是一个web应用,或是一个后端web服务应用,或是一个移动端应用,也或是一个桌面可执行程序。
  • 授权服务器(Authorization Server):授权服务器,在获取用户的同意授权后,颁发访问令牌给应用程序,以便其获取用户资源。

OAuth 2.0 规定了四种获得令牌的流程。你可以选择最适合自己的那一种,向第三方应用颁发令牌。注意,不管哪一种授权方式,第三方应用申请令牌之前,都必须先到系统备案,说明自己的身份,然后会拿到两个身份识别码:客户端 ID(client ID)和客户端密钥(client secret)。这是为了防止令牌被滥用,没有备案过的第三方应用,是不会拿到令牌的。

名称定义

(1) Third-party application:第三方应用程序,又称"客户端"(client)。

(2)HTTP service:HTTP服务提供商,简称"服务提供商"

(3)Resource Owner:资源所有者,又称"用户"(user)。

(4)User Agent:用户代理,本文中就是指浏览器。

(5)Authorization server:认证服务器,即服务提供商专门用来处理认证的服务器。

(6)Resource server:资源服务器,即服务提供商存放用户生成的资源的服务器。它与认证服务器,可以是同一台服务器,也可以是不同的服务器。

     +--------+                               +---------------+
     |        |--(A)- Authorization Request ->|   Resource    |
     |        |                               |     Owner     |
     |        |<-(B)-- Authorization Grant ---|               |
     |        |                               +---------------+
     |        |
     |        |                               +---------------+
     |        |--(C)-- Authorization Grant -->| Authorization |
     | Client |                               |     Server    |
     |        |<-(D)----- Access Token -------|               |
     |        |                               +---------------+
     |        |
     |        |                               +---------------+
     |        |--(E)----- Access Token ------>|    Resource   |
     |        |                               |     Server    |
     |        |<-(F)--- Protected Resource ---|               |
     +--------+                               +---------------+

(A)客户端向资源所有者请求授权,可以直接向资源所有者发出授权请求。

(B) 用户同意给客户端授权。

(C)客户端通过身份验证请求访问令牌,授权服务器并提供授权授予。

(D)授权服务器对客户端进行身份验证并进行验证授权授予,如果有效,则发出访问令牌。

(E)客户端从授权服务器提供访问令牌到资源服务器进行身份验证。

(F)资源服务器验证访问令牌,如果有效,则同意向客户端开放资源。

1.3 四种授权类型

1.3.1 授权码模式(Authorization Code)

授权代码授权类型用于获得这两种访问权限令牌和刷新令牌,并针对机密客户端进行了优化。因为这是一个基于重定向的流,所以客户端必须能够与资源所有者的用户代理交互(通常是一个 web浏览器) ,并且能够接收传入请求(通过重定向)从授权服务器。

     +----------+
     | Resource |
     |   Owner  |
     |          |
     +----------+
          ^
          |
         (B)
     +----|-----+          Client Identifier      +---------------+
     |         -+----(A)-- & Redirection URI ---->|               |
     |  User-   |                                 | Authorization |
     |  Agent  -+----(B)-- User authenticates --->|     Server    |
     |          |                                 |               |
     |         -+----(C)-- Authorization Code ---<|               |
     +-|----|---+                                 +---------------+
       |    |                                         ^      v
      (A)  (C)                                        |      |
       |    |                                         |      |
       ^    v                                         |      |
     +---------+                                      |      |
     |         |>---(D)-- Authorization Code ---------'      |
     |  Client |          & Redirection URI                  |
     |         |                                             |
     |         |<---(E)----- Access Token -------------------'
     +---------+       (w/ Optional Refresh Token)

(A)客户端通过指导资源所有者的用户代理访问授权服务器。

(B) 授权服务器对资源所有者进行身份验证 ,并确定资源所有者是否授予客户端的访问请求。

(C)假设资源所有者授予访问权限,则授权服务器将用户代理重定向回客户端提供的重定向 URI,同时附上一个授权码(code)。

(D)客户端收到授权码,附上早先的"重定向URI",向认证服务器申请令牌。这一步是在客户端的后台的服务器上完成的,对用户不可见。

(E)授权服务器验证客户端授权代码,并确保重定向 URI确认无误后,向客户端发送令牌(access token)和刷新令牌(refresh token)

1.3.2 隐式授权模式(Implicit)

隐式授予类型用于获取访问令牌(它不是支持发放刷新令牌) ,并为操作特定重定向 URI 的客户端。与授权码模式不同,授权服务器向客户端在前端直接返回令牌(access_token)。隐式授权类型不包括客户端身份验证,并且依赖于资源所有者,因为访问令牌被编码到重定向 URI 时,它可能会公开给资源所有者和其他位于同一设备上的应用程序。

     +----------+
     | Resource |
     |  Owner   |
     |          |
     +----------+
          ^
          |
         (B)
     +----|-----+          Client Identifier     +---------------+
     |         -+----(A)-- & Redirection URI --->|               |
     |  User-   |                                | Authorization |
     |  Agent  -|----(B)-- User authenticates -->|     Server    |
     |          |                                |               |
     |          |<---(C)--- Redirection URI ----<|               |
     |          |          with Access Token     +---------------+
     |          |            in Fragment
     |          |                                +---------------+
     |          |----(D)--- Redirection URI ---->|   Web-Hosted  |
     |          |          without Fragment      |     Client    |
     |          |                                |    Resource   |
     |     (F)  |<---(E)------- Script ---------<|               |
     |          |                                +---------------+
     +-|--------+
       |    |
      (A)  (G) Access Token
       |    |
       ^    v
     +---------+
     |         |
     |  Client |
     |         |
     +---------+

(A)客户端通过指导资源所有者的浏览器访问授权服务器

(B)授权服务器对资源所有者进行身份验证 ,并确定资源所有者是否授予客户端的访问请求。

(C)假设资源所有者授予访问权限,则授权服务器将用户重定向回客户端提供的 URI,同时附上令牌(access token)。

(D)浏览器跳转到客户端提供的URL,创建一个请求到网络托管的客户端。

(E)网络托管的客户端资源返回一个网页,并提取访问令牌。

(F)浏览器执行网络托管服务提供的脚本,并获取令牌。

(G) 浏览器将访问令牌传递给客户端。

1.3.4 密码模式(Resource Owner Password Credentials Grant)

用户向客户端提供自己的用户名和密码,客户端直接使用这些信息,向认证服务商申请令牌。这种方式认证服务器无法判定用户是否真正授权了,用户名和密码可能是第三方应用盗取而来,存在安全隐患。

     +----------+
     | Resource |
     |  Owner   |
     |          |
     +----------+
          v
          |    Resource Owner
         (A) Password Credentials
          |
          v
     +---------+                                  +---------------+
     |         |>--(B)---- Resource Owner ------->|               |
     |         |         Password Credentials     | Authorization |
     | Client  |                                  |     Server    |
     |         |<--(C)---- Access Token ---------<|               |
     |         |    (w/ Optional Refresh Token)   |               |
     +---------+                                  +---------------+

(A) 资源所有者向客户端提供其用户名和密码。

(B)客户端把用户名和密码提交到授权服务器进行验证,向授权服务器申请令牌。

(C)授权服务器验证用户名和密码,如果验证成功,则向客户端发送令牌。

1.3.4 凭证模式(Client Credentials)

客户端只能使用其客户端请求访问令牌凭据,在这种模式中,用户直接向客户端注册,客户端以自己的名义要求"服务提供商"提供服务,其实不存在授权问题。

     +---------+                                  +---------------+
     |         |                                  |               |
     |         |>--(A)- Client Authentication --->| Authorization |
     | Client  |                                  |     Server    |
     |         |<--(B)---- Access Token ---------<|               |
     |         |                                  |               |
     +---------+                                  +---------------+

(A)客户端向授权服务器进行身份验证,并请求访问令牌。

(B)授权服务器对客户端进行身份验证,如果有效则发出访问令牌。

第二章 单点登录

单点登录(SSO)是一种身份验证解决方案,可让用户通过一次性用户身份验证登录多个应用程序和网站。

2.1 SSO工作原理

SSO 在应用程序或服务与外部服务提供商(也称为身份提供者(IdP))之间建立信任。这是通过在应用程序和集中式 SSO 服务之间执行的一系列身份验证、验证和通信步骤来实现的。

2.1.1 SSO服务

SSO 服务是用户登录时应用程序依赖的中心服务。如果未经身份验证的用户请求访问应用程序,应用程序会将他们重定向到 SSO 服务。然后,该服务对用户进行身份验证并将用户重定向回原始应用程序。该服务通常在专用的 SSO 策略服务器上运行。

2.1.2 SSO令牌

SSO 令牌是包含用户识别信息的数字文件,例如用户名或电子邮件地址。当用户请求访问应用程序时,应用程序会与 SSO 服务交换 SSO 令牌以对用户进行身份验证。

2.1.3 SSO认证流程

  1. 当用户登录应用程序时,应用程序会生成 SSO 令牌并向 SSO 服务发送身份验证请求。
  2. 该服务会检查用户之前是否在系统中进行了身份验证。如果是,它会向应用程序发送一个身份验证确认响应,以授予用户访问权限。
  3. 如果用户没有经过验证的凭证,SSO 服务会将用户重定向到中央登录系统并提示用户提交其用户名和密码。
  4. 提交后,服务会验证用户凭证并将肯定响应发送到应用程序。
  5. 否则,用户会收到错误消息并且必须重新输入凭证。

第三章 使用Sa-Token 实现单点登录

Sa-Token-SSO分为三种模式,解决不同架构下的SSO接入问题。

系统架构采用模式简介
前端同域 + 后端同 Redis模式一共享 Cookie 同步会话
前端不同域 + 后端同 Redis模式二URL重定向传播会话
前端不同域 + 后端不同 Redis模式三Http请求获取会话
  1. 前端同域:就是指多个系统可以部署在同一个主域名之下,如:c1.domain.comc2.domain.comc3.domain.com
  2. 后端同Redis:就是指多个系统可以连接同一个Redis。
  3. 如果既无法做到前端同域,也无法做到后端同Redis,那么只能走模式三,Http请求获取会话。
  • Sa-Token-SSO服务端:/Sa-Token/sa-token-demo/sa-token-demo/sa-token-demo-server/源码连接
  • Sa-Token-SSO模式一客户端:/Sa-Token/sa-token-demo/sa-token-demo-sso/sa-token-demo-sso1-client/源码连接
  • Sa-Token-SSO模式二客户端:/Sa-Token/sa-token-demo/sa-token-demo-sso/sa-token-demo-sso2-client/源码连接
  • Sa-Token-SSO模式三客户端:/Sa-Token/sa-token-demo/sa-token-demo-sso/sa-token-demo-sso3-client/源码连接

3.1 搭建统一认证中心SSO-Server

3.1.1 添加依赖

创建 SpringBoot 项目 sa-token-demo-sso-server,引入依赖

<!-- Sa-Token 权限认证,在线文档:https://sa-token.cc -->
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-spring-boot-starter</artifactId>
    <version>1.35.0.RC</version>
</dependency>

<!-- Sa-Token 插件:整合SSO -->
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-sso</artifactId>
    <version>1.35.0.RC</version>
</dependency>
        
<!-- Sa-Token 整合 Redis (使用 jackson 序列化方式) -->
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-redis-jackson</artifactId>
    <version>1.35.0.RC</version>
</dependency>
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
</dependency>

<!-- 视图引擎(在前后端不分离模式下提供视图支持) -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

<!-- Http请求工具(在模式三的单点注销功能下用到,如不需要可以注释掉) -->
<dependency>
    <groupId>com.dtflys.forest</groupId>
    <artifactId>forest-spring-boot-starter</artifactId>
    <version>1.5.26</version>
</dependency>

除了 sa-token-spring-boot-startersa-token-sso 以外,其它包都是可选的:

  • 在 SSO 模式三时 Redis 相关包是可选的
  • 在前后端分离模式下可以删除 thymeleaf 相关包
  • 在不需要 SSO 模式三单点注销的情况下可以删除 http 工具包

3.1.2 开发认证接口

新建 SsoServerController,用于对外开放接口:

/**
 * Sa-Token-SSO Server端 Controller 
 */
@RestController
public class SsoServerController {

    /*
     * SSO-Server端:处理所有SSO相关请求 (下面的章节我们会详细列出开放的接口) 
     */
    @RequestMapping("/sso/*")
    public Object ssoRequest() {
        return SaSsoProcessor.instance.serverDister();
    }
    
    /**
     * 配置SSO相关参数 
     */
    @Autowired
    private void configSso(SaSsoConfig sso) {
        // 配置:未登录时返回的View 
        sso.setNotLoginView(() -> {
            String msg = "当前会话在SSO-Server端尚未登录,请先访问"
                    + "<a href='/sso/doLogin?name=sa&pwd=123456' target='_blank'> doLogin登录 </a>"
                    + "进行登录之后,刷新页面开始授权";
            return msg;
        });
        
        // 配置:登录处理函数 
        sso.setDoLoginHandle((name, pwd) -> {
            // 此处仅做模拟登录,真实环境应该查询数据进行登录 
            if("sa".equals(name) && "123456".equals(pwd)) {
                StpUtil.login(10001);
                return SaResult.ok("登录成功!").setData(StpUtil.getTokenValue());
            }
            return SaResult.error("登录失败!");
        });
        
        // 配置 Http 请求处理器 (在模式三的单点注销功能下用到,如不需要可以注释掉) 
        sso.setSendHttp(url -> {
            try {
                // 发起 http 请求 
                System.out.println("------ 发起请求:" + url);
                return Forest.get(url).executeAsString();
            } catch (Exception e) {
                e.printStackTrace();
                return null;
            }
        });
    }
    
}

3.1.3 新建全局异常处理

@RestControllerAdvice
public class GlobalExceptionHandler {
    // 全局异常拦截 
    @ExceptionHandler
    public SaResult handlerException(Exception e) {
        e.printStackTrace(); 
        return SaResult.error(e.getMessage());
    }
}

3.1.4 配置yml文件

# 端口
server:
    port: 9000

# Sa-Token 配置
sa-token: 
    # ------- SSO-模式一相关配置  (非模式一不需要配置) 
    # cookie: 
        # 配置 Cookie 作用域 
        # domain: stp.com 
        
    # ------- SSO-模式二相关配置 
    sso: 
        # Ticket有效期 (单位: 秒),默认五分钟 
        ticket-timeout: 300
        # 所有允许的授权回调地址
        allow-url: "*"
        
        # ------- SSO-模式三相关配置 (下面的配置在使用SSO模式三时打开)
        # 是否打开模式三 
        is-http: true
    sign:
        # API 接口调用秘钥
        secret-key: kQwIOrYvnXmSDkwEiFngrKidMcdrgKor
        # ---- 除了以上配置项,你还需要为 Sa-Token 配置http请求处理器(文档有步骤说明) 
        
spring: 
    # Redis配置 (SSO模式一和模式二使用Redis来同步会话)
    redis:
        # Redis数据库索引(默认为0)
        database: 1
        # Redis服务器地址
        host: 127.0.0.1
        # Redis服务器连接端口
        port: 6379
        # Redis服务器连接密码(默认为空)
        password: 
        
forest: 
    # 关闭 forest 请求日志打印
    log-enabled: false

3.1.5 创建启动类

@SpringBootApplication
public class SaSsoServerApplication {
    public static void main(String[] args) {
        SpringApplication.run(SaSsoServerApplication.class, args);
        System.out.println("\n------ Sa-Token-SSO 认证中心启动成功");
    }
}

3.2 SSO模式一 共享Cookie同步会话

实现该模式的前提必须是前端同域、后端同Redis。

3.2.1 设计思路

首先我们分析一下多个系统之间,为什么无法同步登录状态?

  1. 前端的 Token 无法在多个系统下共享。
  2. 后端的 Session 无法在多个系统间共享。

所以我们就可以使用

  1. 共享Cookie来解决Token共享问题
  2. 使用Redis来解决Session共享问题

共享Cookie就是主域名Cookie在二级域名下的共享。

共享Redis就是并不需要我们把所有项目的数据都放在同一个Redis中,Sa-Token提供了 [权限缓存与业务缓存分离] 的解决方案。详情

模式1.png

3.2.2 修改hosts文件

首先修改hosts文件,添加以下IP映射,方便我们进行测试:

Mac、Linux 输入以下命令

sudo vim /etc/hosts

Windows在C:\windows\system32\drivers\etc\hosts修改

127.0.0.1 sso.stp.com
127.0.0.1 s1.stp.com
127.0.0.1 s2.stp.com
127.0.0.1 s3.stp.com

其中:sso.stp.com为统一认证中心地址,当用户在其它 Client 端发起登录请求时,均将其重定向至认证中心,待到登录成功之后再原路返回到 Client 端。

3.2.3 指定Cookie作用域

sso.stp.com访问服务器,其Cookie也只能写入到sso.stp.com下,为了将Cookie写入到其父级域名stp.com下,我们需要更改 SSO-Server 端的 yml 配置:

sa-token: 
    cookie: 
        # 配置 Cookie 作用域 
        domain: stp.com 

将其注释打开。注意,在SSO模式一测试完成以后需将此配置重新注释,因为模式一与模式二三使用不同的授权流程,这行配置会影响到我们模式二和模式三的正常运行。

3.2.4 搭建Client客户端

  • 新建springboot项目sa-token-demo-sso1-client,并添加以下依赖:
<!-- Sa-Token 权限认证, 在线文档:https://sa-token.cc -->
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-spring-boot-starter</artifactId>
    <version>1.35.0.RC</version>
</dependency>
<!-- Sa-Token 插件:整合SSO -->
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-sso</artifactId>
    <version>1.35.0.RC</version>
</dependency>

<!-- Sa-Token 整合redis (使用jackson序列化方式) -->
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-redis-jackson</artifactId>
    <version>1.35.0.RC</version>
</dependency>
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
</dependency>

<!-- Sa-Token插件:权限缓存与业务缓存分离 -->
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-alone-redis</artifactId>
    <version>1.35.0.RC</version>
</dependency>

  • 新建Controller控制器
/**
 * Sa-Token-SSO Client端 Controller 
 * @author click33
 */
@RestController
public class SsoClientController {

    // SSO-Client端:首页 
    @RequestMapping("/")
    public String index() {
        String authUrl = SaSsoManager.getConfig().splicingAuthUrl();
        String solUrl = SaSsoManager.getConfig().splicingSloUrl();
        String str = "<h2>Sa-Token SSO-Client 应用端</h2>" + 
                    "<p>当前会话是否登录:" + StpUtil.isLogin() + "</p>" + 
                    "<p><a href=\"javascript:location.href='" + authUrl + "?mode=simple&redirect=' + encodeURIComponent(location.href);\">登录</a> " + 
                    "<a href=\"javascript:location.href='" + solUrl + "?back=' + encodeURIComponent(location.href);\">注销</a> </p>";
        return str;
    }
    
    // 全局异常拦截 
    @ExceptionHandler
    public SaResult handlerException(Exception e) {
        e.printStackTrace(); 
        return SaResult.error(e.getMessage());
    }
    
}

  • 配置yml 文件
# 端口
server:
    port: 9001

# Sa-Token 配置 
sa-token: 
    # SSO-相关配置
    sso: 
        # SSO-Server端-单点登录授权地址 
        auth-url: http://sso.stp.com:9000/sso/auth
        # SSO-Server端-单点注销地址
        slo-url: http://sso.stp.com:9000/sso/signout
    
    # 配置 Sa-Token 单独使用的Redis连接 (此处需要和SSO-Server端连接同一个Redis)
    alone-redis: 
        # Redis数据库索引
        database: 1
        # Redis服务器地址
        host: 127.0.0.1
        # Redis服务器连接端口
        port: 6379
        # Redis服务器连接密码(默认为空)
        password: 
        # 连接超时时间
        timeout: 10s

  • 新建启动类
/**
 * SSO模式一,Client端 Demo 
 */
@SpringBootApplication
public class SaSso1ClientApplication {
    public static void main(String[] args) {
        SpringApplication.run(SaSso1ClientApplication.class, args);
        System.out.println("\nSa-Token SSO模式一 Client端启动成功");
    }
}

3.2.5 访问测试

启动项目,依次访问三个应用端:

3.3 SSO模式二 URL重定向传播会话

如果我们的多个系统:部署在不同的域名之下,但是后端可以连接同一个Redis,那么便可以使用 [URL重定向传播会话] 的方式做到单点登录。

3.3.1 设计思路

首先我们复习一下多个系统之间,为什么无法同步登录状态?

  1. 前端的 Token 无法在多个系统下共享。
  2. 后端的 Session 无法在多个系统间共享。

关于第二点,我们已经使用 Alone 独立 Redis 插件做到权限缓存直连 SSO-Redis 数据中心。

而第一点才是我们要解决的关键,在跨域模式下,意味着共享 Cookie 方案失效,我们必须采用新的方案来传递Token。

  1. 用户在子系统点击登录按钮时。
  2. 用户跳转到子系统登录接口/sso/login,并携带 back参数 记录初始页面URL。
形如:http://{sso-client}/sso/login?back=xxx

3. 子系统检测到此用户尚未登录,再次将其重定向到SSO Server 认证中心,并携带redirect参数记录子系统的登录页URL。

形如:http://{sso-server}/sso/auth?redirect=xxx?back=xxx

4. 用户进入SSO Server 认证中心的登录页面,进行登录验证。 5. 用户输入账号密码登录成功后,SSO Server 认证中心再次将用户重定向至子系统的登录接口/sso/login,并携带ticket码参数。

形如:http://{sso-client}/sso/login?back=xxx&ticket=xxxxxxxxx

6. 子系统根据 ticket码SSO-Redis 中获取账号id,并在子系统登录此账号会话。 7. 子系统将用户再次重定向至最初始的 back 页面。

模式二.png

3.3.2 修改hosts文件

127.0.0.1 sa-sso-server.com
127.0.0.1 sa-sso-client1.com
127.0.0.1 sa-sso-client2.com
127.0.0.1 sa-sso-client3.com

3.3.3 搭建Client客户端

3.3.3.1 去除SSO-Server的Cookie作用域配置
# sa-token: 
    # cookie: 
        # 配置 Cookie 作用域 
        # domain: stp.com 

将其注释掉。

3.3.3.2 创建SSO-Client 项目

创建一个SpringBoot项目sa-token-demo-sso2-client,引入依赖:

<!-- Sa-Token 权限认证, 在线文档:https://sa-token.cc -->
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-spring-boot-starter</artifactId>
    <version>1.35.0.RC</version>
</dependency>
<!-- Sa-Token 插件:整合SSO -->
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-sso</artifactId>
    <version>1.35.0.RC</version>
</dependency>

<!-- Sa-Token 整合redis (使用jackson序列化方式) -->
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-redis-jackson</artifactId>
    <version>1.35.0.RC</version>
</dependency>
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
</dependency>

<!-- Sa-Token插件:权限缓存与业务缓存分离 -->
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-alone-redis</artifactId>
    <version>1.35.0.RC</version>
</dependency>

3.3.3.3 创建SSO-Client端认证接口

同 SSO-Server 一样,Sa-Token 为 SSO-Client 端所需代码也提供了完整的封装,只需提供一个访问入口,接入 Sa-Token 的方法即可。


/**
 * Sa-Token-SSO Client端 Controller 
 */
@RestController
public class SsoClientController {

    // 首页 
    @RequestMapping("/")
    public String index() {
        String str = "<h2>Sa-Token SSO-Client 应用端</h2>" + 
                    "<p>当前会话是否登录:" + StpUtil.isLogin() + "</p>" + 
                    "<p><a href=\"javascript:location.href='/sso/login?back=' + encodeURIComponent(location.href);\">登录</a> " + 
                    "<a href='/sso/logout?back=self'>注销</a></p>";
        return str;
    }
    
    /*
     * SSO-Client端:处理所有SSO相关请求 
     *         http://{host}:{port}/sso/login          -- Client端登录地址,接受参数:back=登录后的跳转地址 
     *         http://{host}:{port}/sso/logout         -- Client端单点注销地址(isSlo=true时打开),接受参数:back=注销后的跳转地址 
     *         http://{host}:{port}/sso/logoutCall     -- Client端单点注销回调地址(isSlo=true时打开),此接口为框架回调,开发者无需关心
     */
    @RequestMapping("/sso/*")
    public Object ssoRequest() {
        return SaSsoProcessor.instance.clientDister();
    }

}

3.3.3.4 配置SSO认证中心地址
# 端口
server:
    port: 9001

# sa-token配置 
sa-token: 
    # SSO-相关配置
    sso: 
        # SSO-Server端 统一认证地址 
        auth-url: http://sa-sso-server.com:9000/sso/auth

    # 配置Sa-Token单独使用的Redis连接 (此处需要和SSO-Server端连接同一个Redis)
    alone-redis: 
        # Redis数据库索引 (默认为0)
        database: 1
        # Redis服务器地址
        host: 127.0.0.1
        # Redis服务器连接端口
        port: 6379
        # Redis服务器连接密码(默认为空)
        password: 
        # 连接超时时间
        timeout: 10s

3.3.3.5 编写启动类
@SpringBootApplication
public class SaSso2ClientApplication {
    public static void main(String[] args) {
        SpringApplication.run(SaSso2ClientApplication.class, args);
        System.out.println("\nSa-Token-SSO Client端启动成功");
    }
}

3.3.4 测试访问

  1. 依次启动 SSO-ServerSSO-Client,然后从浏览器访问:sa-sso-client1.com:9001/。
  2. 首次打开,提示当前未登录,我们点击**登录** 按钮,页面会被重定向到登录中心。
  3. SSO-Server提示我们在认证中心尚未登录,我们点击 **doLogin登录**按钮进行模拟登录。
  4. SSO-Server认证中心登录成功,我们回到刚才的页面刷新页面。
  5. 页面被重定向至Client端首页,并提示登录成功,至此,Client1应用已单点登录成功!
  6. 我们再次访问Client2sa-sso-client2.com:9001/。
  7. 提示未登录,我们点击**登录**按钮,会直接提示登录成功。
  8. 同样的方式,我们打开Client3,也可以直接登录成功:sa-sso-client3.com:9001/,至此,测试完毕!

3.4 SSO模式三 Http请求获取会话

如果既无法做到前端同域,也无法做到后端同Redis,那么可以使用模式三完成单点登录。

3.4.1 设计思路

我们先来分析一下,当后端不使用共享 Redis 时,会对架构产生哪些影响:

  1. Client 端无法直连 Redis 校验 ticket,取出账号id。
  2. Client 端无法与 Server 端共用一套会话,需要自行维护子会话。
  3. 由于不是一套会话,所以无法“一次注销,全端下线”,需要额外编写代码完成单点注销。

3.4.2 在Client 端更改 Ticket 校验方式

3.4.2.1 引入Forest依赖,Forest是一个轻量级http请求工具。
<!-- Http请求工具 -->
<dependency>
    <groupId>com.dtflys.forest</groupId>
    <artifactId>forest-spring-boot-starter</artifactId>
    <version>1.5.26</version>
</dependency>
3.4.2.2 配置http 请求处理器

在SSO-Client端的 SsoClientController 中,新增以下配置

// 配置SSO相关参数 
@Autowired
private void configSso(SaSsoConfig sso) {
    // ... 其他代码
    
    // 配置 Http 请求处理器
    sso.setSendHttp(url -> {
        System.out.println("------ 发起请求:" + url);
        return Forest.get(url).executeAsString();
    });
}

配置yml 文件

sa-token: 
    sso: 
        # 打开模式三(使用Http请求校验ticket)
        is-http: true
        # SSO-Server端 ticket校验地址 
        check-ticket-url: http://sa-sso-server.com:9000/sso/checkTicket
        
forest: 
    # 关闭 forest 请求日志打印
    log-enabled: false

3.4.3 启动项目测试

重启项目,访问测试:

注:如果已测试运行模式二,可先将Redis中的数据清空,以防旧数据对测试造成干扰

3.4.4 获取UserInfo

除了账号id,我们可能还需要将用户的昵称、头像等信息从 Server端 带到 Client端,即:用户资料的拉取。

在模式二中我们只需要将需要同步的资料放到 SaSession 即可,但是在模式三中两端不再连接同一个 Redis,这时候我们需要通过 http 接口来同步信息。

3.4.4.1 首先在 Server 端开放一个查询数据的接口
// 示例:获取数据接口(用于在模式三下,为 client 端开放拉取数据的接口)
@RequestMapping("/sso/getData")
public SaResult getData(String apiType, String loginId) {
    System.out.println("---------------- 获取数据 ----------------");
    System.out.println("apiType=" + apiType);
    System.out.println("loginId=" + loginId);

    // 校验签名:只有拥有正确秘钥发起的请求才能通过校验
    SaSignUtil.checkRequest(SaHolder.getRequest());

    // 自定义返回结果(模拟)
    return SaResult.ok()
            .set("id", loginId)
            .set("name", "LinXiaoYu")
            .set("sex", "女")
            .set("age", 18);
}

3.4.4.2 在 Client 调用接口查询数据
  • 首先在 application.yml 中配置接口地址:
sa-token: 
    sso: 
        # sso-server 端拉取数据地址 
        get-data-url: http://sa-sso-server.com:9000/sso/getData

  • 然后在 SsoClientController 中新增接口
// 查询我的账号信息 
@RequestMapping("/sso/myInfo")
public Object myInfo() {
    // 组织请求参数
    Map<String, Object> map = new HashMap<>();
    map.put("apiType", "userinfo");
    map.put("loginId", StpUtil.getLoginId());

    // 发起请求
    Object resData = SaSsoUtil.getData(map);
    System.out.println("sso-server 返回的信息:" + resData);
    return resData;
}

  • 访问测试

访问测试:sa-sso-client1.com:9001/sso/myInfo

3.4.5 无刷单点注销

Sa-Token-SSO 允许你以 REST API 的形式构建接口,做到页面无刷新单点注销。

单点注销.png

SSO-Client 端新增配置

application.yml 增加配置:API调用秘钥单点注销接口URL

sa-token: 
    sso: 
        # 单点注销地址 
        slo-url: http://sa-sso-server.com:9000/sso/signout
    sign:
        # API 接口调用秘钥
        secret-key: kQwIOrYvnXmSDkwEiFngrKidMcdrgKor

启动测试

重启项目,访问测试:sa-sso-client1.com:9001/, 我们主要的测试点在于 单点注销,正常登录即可。

第四章 Sa-Token-OAuth 2.0单点登录

基于不同的使用场景,OAuth2.0设计了四种模式:

  1. 授权码(Authorization Code):OAuth2.0标准授权步骤,Server端向Client端下放Code码,Client端再用Code码换取授权Token。
  2. 隐藏式(Implicit):无法使用授权码模式时的备用选择,Server端使用URL重定向方式直接将Token下放到Client端页面。
  3. 密码式(Password):Client直接拿着用户的账号密码换取授权Token。
  4. 客户端凭证(Client Credentials):Server端针对Client级别的Token,代表应用自身的资源授权。

第一章描述四种登录模式详情。

单点注销.png

  • OAuth2-Server端: /sa-token-demo/sa-token-demo-oauth2/sa-token-demo-oauth2-server/ 源码链接
  • OAuth2-Client端: /sa-token-demo/sa-token-demo-oauth2/sa-token-demo-oauth2-client/ 源码链接

4.1搭建OAuth 2.0 认证服务器

4.1.2 准备工作

修改hosts文件

127.0.0.1 sa-oauth-server.com
127.0.0.1 sa-oauth-client.com

4.1.2 创建OAuth-Server

创建SpringBoot项目 sa-token-demo-oauth2-server,添加pom依赖:

<!-- Sa-Token 权限认证, 在线文档:https://sa-token.cc -->
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-spring-boot-starter</artifactId>
    <version>1.35.0.RC</version>
</dependency>

<!-- Sa-Token-OAuth2.0 模块 -->
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-oauth2</artifactId>
    <version>1.35.0.RC</version>
</dependency>

4.1.3 开放服务

  1. 新建 SaOAuth2TemplateImpl
/**
 * Sa-Token OAuth2.0 整合实现 
 */
@Component
public class SaOAuth2TemplateImpl extends SaOAuth2Template {
    
    // 根据 id 获取 Client 信息 
    @Override
    public SaClientModel getClientModel(String clientId) {
        // 此为模拟数据,真实环境需要从数据库查询 
        if("1001".equals(clientId)) {
            return new SaClientModel()
                    .setClientId("1001")
                    .setClientSecret("aaaa-bbbb-cccc-dddd-eeee")
                    .setAllowUrl("*")
                    .setContractScope("userinfo")
                    .setIsAutoMode(true);
        }
        return null;
    }
    
    // 根据ClientId 和 LoginId 获取openid 
    @Override
    public String getOpenid(String clientId, Object loginId) {
        // 此为模拟数据,真实环境需要从数据库查询 
        return "gr_SwoIN0MC1ewxHX_vfCW3BothWDZMMtx__";
    }
    
}

2 . 新建SaOAuth2ServerController

/**
 * Sa-OAuth2 Server端 控制器 
 */
@RestController
public class SaOAuth2ServerController {

    // 处理所有OAuth相关请求 
    @RequestMapping("/oauth2/*")
    public Object request() {
        System.out.println("------- 进入请求: " + SaHolder.getRequest().getUrl());
        return SaOAuth2Handle.serverRequest();
    }
    
    // Sa-OAuth2 定制化配置 
    @Autowired
    public void setSaOAuth2Config(SaOAuth2Config cfg) {
        cfg.
            // 配置:未登录时返回的View 
            setNotLoginView(() -> {
                String msg = "当前会话在OAuth-Server端尚未登录,请先访问"
                            + "<a href='/oauth2/doLogin?name=sa&pwd=123456' target='_blank'> doLogin登录 </a>"
                            + "进行登录之后,刷新页面开始授权";
                return msg;
            }).
            // 配置:登录处理函数 
            setDoLoginHandle((name, pwd) -> {
                if("sa".equals(name) && "123456".equals(pwd)) {
                    StpUtil.login(10001);
                    return SaResult.ok();
                }
                return SaResult.error("账号名或密码错误");
            }).
            // 配置:确认授权时返回的View 
            setConfirmView((clientId, scope) -> {
                String msg = "<p>应用 " + clientId + " 请求授权:" + scope + "</p>"
                        + "<p>请确认:<a href='/oauth2/doConfirm?client_id=" + clientId + "&scope=" + scope + "' target='_blank'> 确认授权 </a></p>"
                        + "<p>确认之后刷新页面</p>";
                return msg;
            })
            ;
    }

    // 全局异常拦截  
    @ExceptionHandler
    public SaResult handlerException(Exception e) {
        e.printStackTrace(); 
        return SaResult.error(e.getMessage());
    }
    
}

  1. 新建启动类
/**
 * 启动:Sa-OAuth2 Server端 
 */
@SpringBootApplication 
public class SaOAuth2ServerApplication {
    public static void main(String[] args) {
        SpringApplication.run(SaOAuth2ServerApplication.class, args);
        System.out.println("\nSa-Token-OAuth Server 端启动成功");
    }
}

启动OAuth2 认证服务器。

4.2 搭建OAuth 2.0 客户端服务器

4.2.1 创建OAuth2-Client

创建SpringBoot项目 sa-token-demo-oauth2-client,添加pom依赖:

		<!-- Sa-Token 权限认证, 在线文档:https://sa-token.cc/ -->
		<dependency>
		    <groupId>cn.dev33</groupId>
		    <artifactId>sa-token-spring-boot-starter</artifactId>
            <version>${sa-token.version}</version>
		</dependency>

		<!-- thymeleaf 视图引擎 -->
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-thymeleaf</artifactId>
		</dependency>
        
        <!-- OkHttps网络请求库: http://okhttps.ejlchina.com/ -->
        <dependency>
			<groupId>com.ejlchina</groupId>
			<artifactId>okhttps</artifactId>
			<version>3.1.1</version>
		</dependency>
		
		<!-- 热刷新 -->
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-devtools</artifactId>
			<scope>provided</scope>
		</dependency>
		
		<!-- ConfigurationProperties -->
        <dependency>
        	<groupId>org.springframework.boot</groupId>
        	<artifactId>spring-boot-configuration-processor</artifactId>
        	<optional>true</optional>
        </dependency>

4.2.2 创建SoMap工具类


/**
 * Map< String, Object> 是最常用的一种Map类型,但是它写着麻烦 
 * <p>所以特封装此类,继承Map,进行一些扩展,可以让Map更灵活使用 
 * <p>最新:2020-12-10 新增部分构造方法
 * @author click33
 */
public class SoMap extends LinkedHashMap<String, Object> {

	private static final long serialVersionUID = 1L;

	public SoMap() {
	}
	
	/** 以下元素会在isNull函数中被判定为Null, */
	public static final Object[] NULL_ELEMENT_ARRAY = {null, ""};
	public static final List<Object> NULL_ELEMENT_LIST;

	static {
		NULL_ELEMENT_LIST = Arrays.asList(NULL_ELEMENT_ARRAY);
	}

	// ============================= 读值 =============================

	/** 获取一个值 */
	@Override
	public Object get(Object key) {
		if("this".equals(key)) {
			return this;
		}
		return super.get(key);
	}

	/** 如果为空,则返回默认值 */
	public Object get(Object key, Object defaultValue) {
		Object value = get(key);
		if(valueIsNull(value)) {
			return defaultValue;
		}
		return value;
	}
	
	/** 转为String并返回 */
	public String getString(String key) {
		Object value = get(key);
		if(value == null) {
			return null;
		}
		return String.valueOf(value);
	}

	/** 如果为空,则返回默认值 */
	public String getString(String key, String defaultValue) {
		Object value = get(key);
		if(valueIsNull(value)) {
			return defaultValue;
		}
		return String.valueOf(value);
	}

	/** 转为int并返回 */
	public int getInt(String key) {
		Object value = get(key);
		if(valueIsNull(value)) {
			return 0;
		}
		return Integer.valueOf(String.valueOf(value));
	}
	/** 转为int并返回,同时指定默认值 */
	public int getInt(String key, int defaultValue) {
		Object value = get(key);
		if(valueIsNull(value)) {
			return defaultValue;
		}
		return Integer.valueOf(String.valueOf(value));
	}

	/** 转为long并返回 */
	public long getLong(String key) {
		Object value = get(key);
		if(valueIsNull(value)) {
			return 0;
		}
		return Long.valueOf(String.valueOf(value));
	}

	/** 转为double并返回 */
	public double getDouble(String key) {
		Object value = get(key);
		if(valueIsNull(value)) {
			return 0.0;
		}
		return Double.valueOf(String.valueOf(value));
	}

	/** 转为boolean并返回 */
	public boolean getBoolean(String key) {
		Object value = get(key);
		if(valueIsNull(value)) {
			return false;
		}
		return Boolean.valueOf(String.valueOf(value));
	}

	/** 转为Date并返回,根据自定义格式 */
	public Date getDateByFormat(String key, String format) {
		try {
			return new SimpleDateFormat(format).parse(getString(key));
		} catch (Exception e) {
			throw new RuntimeException(e);
		}
	}

	/** 转为Date并返回,根据格式: yyyy-MM-dd */
	public Date getDate(String key) {
		return getDateByFormat(key, "yyyy-MM-dd");
	}

	/** 转为Date并返回,根据格式: yyyy-MM-dd HH:mm:ss */
	public Date getDateTime(String key) {
		return getDateByFormat(key, "yyyy-MM-dd HH:mm:ss");
	}

	/** 转为Map并返回 */
	@SuppressWarnings({ "unchecked", "rawtypes" })
	public SoMap getMap(String key) {
		Object value = get(key);
		if(value == null) {
			return SoMap.getSoMap();
		}
		if(value instanceof Map) {
			return SoMap.getSoMap((Map)value);
		}
		if(value instanceof String) {
			return SoMap.getSoMap().setJsonString((String)value);
		}
		throw new RuntimeException("值无法转化为SoMap: " + value);
	}

	/** 获取集合(必须原先就是个集合,否则会创建个新集合并返回) */
	@SuppressWarnings("unchecked")
	public List<Object> getList(String key) {
		Object value = get(key);
		List<Object> list = null;
		if(value == null || value.equals("")) {
			list = new ArrayList<Object>();
		}
		else if(value instanceof List) {
			list = (List<Object>)value;
		} else {
			list = new ArrayList<Object>();
			list.add(value);
		}
		return list;
	}

	/** 获取集合 (指定泛型类型) */
	public <T> List<T> getList(String key, Class<T> cs) {
		List<Object> list = getList(key);
		List<T> list2 = new ArrayList<T>();
		for (Object obj : list) {
			T objC = getValueByClass(obj, cs);
			list2.add(objC);
		}
		return list2;
	}

	/** 获取集合(逗号分隔式),(指定类型) */
	public <T> List<T> getListByComma(String key, Class<T> cs) {
		String listStr = getString(key);
		if(listStr == null || listStr.equals("")) {
			return new ArrayList<>();
		}
		// 开始转化
		String [] arr = listStr.split(",");
		List<T> list = new ArrayList<T>();
		for (String str : arr) {
			if(cs == int.class || cs == Integer.class || cs == long.class || cs == Long.class) {
				str = str.trim();
			}
			T objC = getValueByClass(str, cs);
			list.add(objC);
		}
		return list;
	}


	/** 根据指定类型从map中取值,返回实体对象 */
	public <T> T getModel(Class<T> cs) {
		try {
			return getModelByObject(cs.newInstance());
		} catch (Exception e) {
			throw new RuntimeException(e);
		}
	}
	
	/** 从map中取值,塞到一个对象中 */
	public <T> T getModelByObject(T obj) {
		// 获取类型 
		Class<?> cs = obj.getClass();
		// 循环复制  
		for (Field field : cs.getDeclaredFields()) {
			try {
				// 获取对象 
				Object value = this.get(field.getName());	
				if(value == null) {
					continue;
				}
				field.setAccessible(true);	
				Object valueConvert = getValueByClass(value, field.getType());
				field.set(obj, valueConvert);
			} catch (IllegalArgumentException | IllegalAccessException e) {
				throw new RuntimeException("属性取值出错:" + field.getName(), e);
			}
		}
		return obj;
	}

	

	/**
	 * 将指定值转化为指定类型并返回
	 * @param obj
	 * @param cs
	 * @param <T>
	 * @return
	 */
	@SuppressWarnings("unchecked")
	public static <T> T getValueByClass(Object obj, Class<T> cs) {
		String obj2 = String.valueOf(obj);
		Object obj3 = null;
		if (cs.equals(String.class)) {
			obj3 = obj2;
		} else if (cs.equals(int.class) || cs.equals(Integer.class)) {
			obj3 = Integer.valueOf(obj2);
		} else if (cs.equals(long.class) || cs.equals(Long.class)) {
			obj3 = Long.valueOf(obj2);
		} else if (cs.equals(short.class) || cs.equals(Short.class)) {
			obj3 = Short.valueOf(obj2);
		} else if (cs.equals(byte.class) || cs.equals(Byte.class)) {
			obj3 = Byte.valueOf(obj2);
		} else if (cs.equals(float.class) || cs.equals(Float.class)) {
			obj3 = Float.valueOf(obj2);
		} else if (cs.equals(double.class) || cs.equals(Double.class)) {
			obj3 = Double.valueOf(obj2);
		} else if (cs.equals(boolean.class) || cs.equals(Boolean.class)) {
			obj3 = Boolean.valueOf(obj2);
		} else {
			obj3 = (T)obj;
		}
		return (T)obj3;
	}

	
	// ============================= 写值 =============================

	/**
	 * 给指定key添加一个默认值(只有在这个key原来无值的情况先才会set进去)
	 */
	public void setDefaultValue(String key, Object defaultValue) {
		if(isNull(key)) {
			set(key, defaultValue);
		}
	}

	/** set一个值,连缀风格 */
	public SoMap set(String key, Object value) {
		// 防止敏感key 
		if(key.toLowerCase().equals("this")) {		
			return this;
		}
		put(key, value);
		return this;
	}

	/** 将一个Map塞进SoMap */
	public SoMap setMap(Map<String, ?> map) {
		if(map != null) {
			for (String key : map.keySet()) {
				this.set(key, map.get(key));
			}
		}
		return this;
	}

	/** 将一个对象解析塞进SoMap */
	public SoMap setModel(Object model) {
		if(model == null) {
			return this;
		}
		Field[] fields = model.getClass().getDeclaredFields();
	    for (Field field : fields) {
	        try{
	            field.setAccessible(true);
	            boolean isStatic = Modifier.isStatic(field.getModifiers());
	            if(!isStatic) {
		            this.set(field.getName(), field.get(model));
	            }
	        }catch (Exception e){
	        	throw new RuntimeException(e);
	        }
	    }
		return this;
	}

	/** 将json字符串解析后塞进SoMap */
	public SoMap setJsonString(String jsonString) {
		try {
			@SuppressWarnings("unchecked")
			Map<String, Object> map = new ObjectMapper().readValue(jsonString, Map.class);
			return this.setMap(map);
		} catch (JsonProcessingException e) {
			throw new RuntimeException(e);
		}
	}

	
	// ============================= 删值 =============================

	/** delete一个值,连缀风格 */
	public SoMap delete(String key) {
		remove(key);
		return this;
	}

	/** 清理所有value为null的字段 */
	public SoMap clearNull() {
		Iterator<String> iterator = this.keySet().iterator();
		while(iterator.hasNext()) {
			String key = iterator.next();
			if(this.isNull(key)) {
				iterator.remove();
				this.remove(key);
			}

		}
		return this;
	}
	/** 清理指定key */
	public SoMap clearIn(String ...keys) {
		List<String> keys2 = Arrays.asList(keys);
		Iterator<String> iterator = this.keySet().iterator();
		while(iterator.hasNext()) {
			String key = iterator.next();
			if(keys2.contains(key) == true) {
				iterator.remove();
				this.remove(key);
			}
		}
		return this;
	}
	/** 清理掉不在列表中的key */
	public SoMap clearNotIn(String ...keys) {
		List<String> keys2 = Arrays.asList(keys);
		Iterator<String> iterator = this.keySet().iterator();
		while(iterator.hasNext()) {
			String key = iterator.next();
			if(keys2.contains(key) == false) {
				iterator.remove();
				this.remove(key);
			}

		}
		return this;
	}
	/** 清理掉所有key */
	public SoMap clearAll() {
		clear();
		return this;
	}
	

	// ============================= 快速构建 ============================= 

	/** 构建一个SoMap并返回 */
	public static SoMap getSoMap() {
		return new SoMap();
	}
	/** 构建一个SoMap并返回 */
	public static SoMap getSoMap(String key, Object value) {
		return new SoMap().set(key, value);
	}
	/** 构建一个SoMap并返回 */
	public static SoMap getSoMap(Map<String, ?> map) {
		return new SoMap().setMap(map);
	}

	/** 将一个对象集合解析成为SoMap */
	public static SoMap getSoMapByModel(Object model) {
		return SoMap.getSoMap().setModel(model);
	}
	
	/** 将一个对象集合解析成为SoMap集合 */
	public static List<SoMap> getSoMapByList(List<?> list) {
		List<SoMap> listMap = new ArrayList<SoMap>();
		for (Object model : list) {
			listMap.add(getSoMapByModel(model));
		}
		return listMap;
	}
	
	/** 克隆指定key,返回一个新的SoMap */
	public SoMap cloneKeys(String... keys) {
		SoMap so = new SoMap();
		for (String key : keys) {
			so.set(key, this.get(key));
		}
		return so;
	}
	/** 克隆所有key,返回一个新的SoMap */
	public SoMap cloneSoMap() {
		SoMap so = new SoMap();
		for (String key : this.keySet()) {
			so.set(key, this.get(key));
		}
		return so;
	}

	/** 将所有key转为大写 */
	public SoMap toUpperCase() {
		SoMap so = new SoMap();
		for (String key : this.keySet()) {
			so.set(key.toUpperCase(), this.get(key));
		}
		this.clearAll().setMap(so);
		return this;
	}
	/** 将所有key转为小写 */
	public SoMap toLowerCase() {
		SoMap so = new SoMap();
		for (String key : this.keySet()) {
			so.set(key.toLowerCase(), this.get(key));
		}
		this.clearAll().setMap(so);
		return this;
	}
	/** 将所有key中下划线转为中划线模式 (kebab-case风格) */
	public SoMap toKebabCase() {
		SoMap so = new SoMap();
		for (String key : this.keySet()) {
			so.set(wordEachKebabCase(key), this.get(key));
		}
		this.clearAll().setMap(so);
		return this;
	}
	/** 将所有key中下划线转为小驼峰模式 */
	public SoMap toHumpCase() {
		SoMap so = new SoMap();
		for (String key : this.keySet()) {
			so.set(wordEachBigFs(key), this.get(key));
		}
		this.clearAll().setMap(so);
		return this;
	}
	/** 将所有key中小驼峰转为下划线模式 */
	public SoMap humpToLineCase() {
		SoMap so = new SoMap();
		for (String key : this.keySet()) {
			so.set(wordHumpToLine(key), this.get(key));
		}
		this.clearAll().setMap(so);
		return this;
	}
	
	
	
	
	// ============================= 辅助方法 =============================


	/** 指定key是否为null,判定标准为 NULL_ELEMENT_ARRAY 中的元素  */
	public boolean isNull(String key) {
		return valueIsNull(get(key));
	}

	/** 指定key列表中是否包含value为null的元素,只要有一个为null,就会返回true */
	public boolean isContainNull(String ...keys) {
		for (String key : keys) {
			if(this.isNull(key)) {
				return true;
			}
		}
		return false;
	}
	
	/** 与isNull()相反 */
	public boolean isNotNull(String key) {
		return !isNull(key);
	}
	/** 指定key的value是否为null,作用同isNotNull() */
	public boolean has(String key) {
		return !isNull(key);
	}
	
	/** 指定value在此SoMap的判断标准中是否为null */
	public boolean valueIsNull(Object value) {
		return NULL_ELEMENT_LIST.contains(value);
	}
	
	/** 验证指定key不为空,为空则抛出异常 */
	public SoMap checkNull(String ...keys) {
		for (String key : keys) {
			if(this.isNull(key)) {
				throw new RuntimeException("参数" + key + "不能为空");
			}
		}
		return this;
	}

	static Pattern patternNumber = Pattern.compile("[0-9]*");
	/** 指定key是否为数字 */
	public boolean isNumber(String key) {
		String value = getString(key);
		if(value == null) {
			return false;
		}
	    return patternNumber.matcher(value).matches();   
	}

	
	
	
	/**
	 * 转为JSON字符串
	 */
	public String toJsonString() {
		try {
//			SoMap so = SoMap.getSoMap(this);
			return new ObjectMapper().writeValueAsString(this);
		} catch (Exception e) {
			throw new RuntimeException(e);
		}
	}

//	/**
//	 * 转为JSON字符串, 带格式的 
//	 */
//	public String toJsonFormatString() {
//		try {
//			return JSON.toJSONString(this, true); 
//		} catch (Exception e) {
//			throw new RuntimeException(e);
//		}
//	}

	// ============================= web辅助 =============================


	/**
	 * 返回当前request请求的的所有参数 
	 * @return
	 */
	public static SoMap getRequestSoMap() {
		// 大善人SpringMVC提供的封装 
		ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
		if(servletRequestAttributes == null) {
			throw new RuntimeException("当前线程非JavaWeb环境");
		}
		// 当前request
		HttpServletRequest request = servletRequestAttributes.getRequest(); 
		if (request.getAttribute("currentSoMap") == null || request.getAttribute("currentSoMap") instanceof SoMap == false ) {
			initRequestSoMap(request);
		}
		return (SoMap)request.getAttribute("currentSoMap");
	}

	/** 初始化当前request的 SoMap */
	private static void initRequestSoMap(HttpServletRequest request) {
		SoMap soMap = new SoMap();
		Map<String, String[]> parameterMap = request.getParameterMap();	// 获取所有参数 
		for (String key : parameterMap.keySet()) {
			try {
				String[] values = parameterMap.get(key); // 获得values 
				if(values.length == 1) {
					soMap.set(key, values[0]);
				} else {
					List<String> list = new ArrayList<String>();
					for (String v : values) {
						list.add(v);
					}
					soMap.set(key, list);
				}
			} catch (Exception e) {
				throw new RuntimeException(e);
			}
		}
		request.setAttribute("currentSoMap", soMap);
	}
	
	/**
	 * 验证返回当前线程是否为JavaWeb环境 
	 * @return
	 */
	public static boolean isJavaWeb() {
		ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();// 大善人SpringMVC提供的封装 
		if(servletRequestAttributes == null) {
			return false;
		}
		return true;
	}
	


	// ============================= 常见key (以下key经常用,所以封装以下,方便写代码) =============================

	/** get 当前页  */
	public int getKeyPageNo() {
		int pageNo = getInt("pageNo", 1);
		if(pageNo <= 0) {
			pageNo = 1;
		}
		return pageNo;
	}
	/** get 页大小  */
	public int getKeyPageSize() {
		int pageSize = getInt("pageSize", 10);
		if(pageSize <= 0 || pageSize > 1000) {
			pageSize = 10;
		}
		return pageSize;
	}

	/** get 排序方式 */
	public int getKeySortType() {
		return getInt("sortType");
	}




	

	// ============================= 工具方法 =============================
	

	/**
	 * 将一个一维集合转换为树形集合 
	 * @param list         集合
	 * @param idKey        id标识key
	 * @param parentIdKey  父id标识key
	 * @param childListKey 子节点标识key
	 * @return 转换后的tree集合 
	 */
	public static List<SoMap> listToTree(List<SoMap> list, String idKey, String parentIdKey, String childListKey) {
		// 声明新的集合,存储tree形数据 
		List<SoMap> newTreeList = new ArrayList<SoMap>();
		// 声明hash-Map,方便查找数据 
		SoMap hash = new SoMap();
		// 将数组转为Object的形式,key为数组中的id 
		for (int i = 0; i < list.size(); i++) {
			SoMap json = (SoMap) list.get(i);
			hash.put(json.getString(idKey), json);
		}
		// 遍历结果集
		for (int j = 0; j < list.size(); j++) {
			// 单条记录
			SoMap aVal = (SoMap) list.get(j);
			// 在hash中取出key为单条记录中pid的值
			SoMap hashVp = (SoMap) hash.get(aVal.get(parentIdKey, "").toString());
			// 如果记录的pid存在,则说明它有父节点,将她添加到孩子节点的集合中
			if (hashVp != null) {
				// 检查是否有child属性,有则添加,没有则新建 
				if (hashVp.get(childListKey) != null) {
					@SuppressWarnings("unchecked")
					List<SoMap> ch = (List<SoMap>) hashVp.get(childListKey);
					ch.add(aVal);
					hashVp.put(childListKey, ch);
				} else {
					List<SoMap> ch = new ArrayList<SoMap>();
					ch.add(aVal);
					hashVp.put(childListKey, ch);
				}
			} else {
				newTreeList.add(aVal);
			}
		}
		return newTreeList;
	}
	
	

	/** 指定字符串的字符串下划线转大写模式 */
	private static String wordEachBig(String str){
		String newStr = "";
		for (String s : str.split("_")) {
			newStr += wordFirstBig(s);
		}
		return newStr;
	}
	/** 返回下划线转小驼峰形式 */
	private static String wordEachBigFs(String str){
		return wordFirstSmall(wordEachBig(str));
	}

	/** 将指定单词首字母大写 */
	private static String wordFirstBig(String str) {
		return str.substring(0, 1).toUpperCase() + str.substring(1, str.length());
	}

	/** 将指定单词首字母小写 */
	private static String wordFirstSmall(String str) {
		return str.substring(0, 1).toLowerCase() + str.substring(1, str.length());
	}

	/** 下划线转中划线 */
	private static String wordEachKebabCase(String str) {
		return str.replaceAll("_", "-");
	}

	/** 驼峰转下划线  */
	private static String wordHumpToLine(String str) {
		return str.replaceAll("[A-Z]", "_$0").toLowerCase();
	}
	

}

4.2.3 创建SaOAuth2ClientApplication客户端控制类

@SpringBootApplication 
public class SaOAuth2ClientApplication {

	public static void main(String[] args) {
		SpringApplication.run(SaOAuth2ClientApplication.class, args);
		System.out.println("\nSa-Token-OAuth Client端启动成功\n\n" + str);
	}

	static String str = "-------------------- Sa-Token-OAuth2 示例 --------------------\n\n" + 
			"首先在host文件 (C:\\windows\\system32\\drivers\\etc\\hosts) 添加以下内容: \r\n" + 
			"	127.0.0.1 sa-oauth-server.com \r\n" + 
			"	127.0.0.1 sa-oauth-client.com \r\n" + 
			"再从浏览器访问:\r\n" + 
			"	http://sa-oauth-client.com:8002";
	
}

依次启动依次启动OAuth2-ServerOAuth2-Client,然后从浏览器访问:sa-oauth-client.com:8002

4.3 Sa-Token-OAuth2 Server端 API列表

基于官方仓库的搭建示例,OAuth2-Server端会暴露出以下API,OAuth2-Client端可据此文档进行对接。

4.3.1 授权码模式(Authorization Code)

4.3.1.1 获取授权码
参数是否必填说明
response_type返回类型:code
client_id应用ID
redirect_uri用户确认授权后,重定向的url地址
scope具体请求的权限,多个用逗号隔开
state随机值,此参数会在重定向时追加到url末尾,不填不追加

根据以下格式构建URL,引导用户访问。

http://sa-oauth-server.com:8001/oauth2/authorize
    ?response_type=code
    &client_id={value}
    &redirect_uri={value}
    &scope={value}
    &state={value}

注意:如果scope参数为空,或者请求的权限用户近期已确认过,则无需用户再次确认,达到静默授权的效果,否则需要用户手动确认,服务器才可以下放code授权码。

用户确认授权之后,会被重定向至redirect_uri,并追加code参数与state参数,形如:

redirect_uri?code={code}&state={state}

Code授权码具有以下特点:

  1. 每次授权产生的Code码都不一样。
  2. Code码用完即废,不能二次使用。
  3. 一个Code的有效期默认为五分钟,超时自动作废。
  4. 每次授权产生新Code码,会导致旧Code码立即作废,即使旧Code码尚未使用。
4.3.1.2 根据授权码获取令牌(Access_Token)
参数是否必填说明
grant_type授权类型,这里请填写:authorization_code
client_id应用id
client_secret应用秘钥
code步骤4.3.1.1中获取到的授权码

获得Code码后,我们可以通过以下接口,获取到用户的Access-TokenRefresh-Tokenopenid等关键信息。

http://sa-oauth-server.com:8001/oauth2/token
    ?grant_type=authorization_code
    &client_id={value}
    &client_secret={value}
    &code={value}

4.3.1.3 根据Refresh-Token刷新Access_Token(可选)
参数是否必填说明
grant_type授权类型,这里请填写:refresh_token
client_id应用id
client_secret应用秘钥
refresh_token步骤1.2中获取到的Refresh-Token

Access-Token的有效期较短,如果每次过期都需要重新授权的话,会比较影响用户体验,因此我们可以在后台通过Refresh-Token 刷新 Access-Token

http://sa-oauth-server.com:8001/oauth2/refresh
    ?grant_type=refresh_token
    &client_id={value}
    &client_secret={value}
    &refresh_token={value}

4.3.2 隐式授权模式(Implicit)

参数是否必填说明
response_type返回类型,这里请填写:token
client_id应用id
redirect_uri用户确认授权后,重定向的url地址
scope具体请求的权限,多个用逗号隔开
state随机值,此参数会在重定向时追加到url末尾,不填不追加

根据以下格式构建URL,引导用户访问:

http://sa-oauth-server.com:8001/oauth2/authorize
    ?response_type=token
    &client_id={value}
    &redirect_uri={value}
    &scope={value}
    $state={value}

此模式会越过授权码的步骤,直接返回Access-Token到前端页面,形如:

redirect_uri#token=xxxx-xxxx-xxxx-xxxx

4.3.3 密码模式(Password)

参数是否必填说明
grant_type返回类型,这里请填写:password
client_id应用id
client_secret应用秘钥
username用户的Server端账号
password用户的Server端密码
scope具体请求的权限,多个用逗号隔开

首先在Client端构建表单,让用户输入Server端的账号和密码,然后在Client端访问接口

http://sa-oauth-server.com:8001/oauth2/token
    ?grant_type=password
    &client_id={value}
    &client_secret={value}
    &username={value}
    &password={value}

4.3.4 凭证模式(Client Credentials)

参数是否必填说明
grant_type返回类型,这里请填写:client_credentials
client_id应用id
client_secret应用秘钥
scope申请权限

以上三种模式获取的都是用户的 Access-Token,代表用户对第三方应用的授权, 在OAuth2.0中还有一种针对 Client级别的授权, 即:Client-Token,代表应用自身的资源授权。

http://sa-oauth-server.com:8001/oauth2/client_token
    ?grant_type=client_credentials
    &client_id={value}
    &client_secret={value}

参考文章:

理解OAuth 2.0

OAuth 2.0官方文档

亚马逊:什么是SSO

Sa-Token官方文档