自建三方SSO协议及配置说明

本文为您介绍自建标准的三方SSO对接协议及配置说明。

说明

独立部署版本支持对接自建三方SSO协议,可自行完成对接。若需要紧急支持专家指导,请联系Quick BI运营负责人

背景信息

自建标准的三方SSO对接,主要针对于Quick BI与客户的自有登录系统的账户对接认证。

很多客户,账户登录系统并没有提供标准的行业登录规范(例如:OAuth2/SAML/LDAP等协议),还是采用自有的协议。为了降低业务系统与客户的SSO server 的对接成本,因此设计了自有的简单登录规范协议,如下:

  • 降低业务侧与客户SSO server的对接成本(一个协议,支持众多客户)。

  • 降低企业的对接成本(不用开发支持标准的行业登录认证规范协议、提供jar支持)。

应用场景

三方系统根据Quick BI的协议说明提供相关接口或配置,实现以下场景:

  • 支持同根域跨域的登录场景对接。

  • 同域支持同步登录登出。

  • 跨域支持同步登出。

快速开始

1. 接入前准备

1.1 前置依赖

租户侧按照协议对接说明完成自有账号登录系统的开发。

1.2 网络要求

对接前,请确保您的鉴权服务器地址和Quick BI服务地址的网络连通性!

2. 在Quick BI进行对接配置

2.1 开启三方SSO登录

步骤一:使用登录认证超管账号进入超级管理员后台。

image..png

若账号无权限登录时,会出现以下界面,提示“无权限禁止访问”。请联系Quick BI运维人员添加权限。image.png

步骤二: 选择登录系统管理->登录全局开关,开启/关闭指定的三方登录方式,保存后立即生效。

image.png

2.2 标准SSO登录对接配置

步骤一:打开登录认证配置页面

在运维中心->登录策略配置或开放平台->登录认证位置打开。两个位置打开的配置效果是一样的,下边以在运维中心打开对应页面为例,页面如下:

image.png

步骤二:添加登录策略

若您已配置过登录策略,则跳过步骤二。

每个登录策略都允许配置一套域名/IP拦截策略,用来指定访问指定域名或IP时Quick BI支持的登录方式。

登录策略配置请参见自定义企业登录门户

步骤三:选中需要开启标准SSO登录的策略,并点击编辑。

image.png

步骤四:在「编辑策略」页面,选择基础设置。

image.png

步骤五:配置标准SSO三方登录

在您进行标准SSO三方登录配置前,若您未开启Quick BI账号,建议开启,原因为:1)防止三方登录设置错误后,无法登录Quick BI;2)自定义账号配置成功且能正常登录后,可根据需要关闭Quick BI账号。

  • Quick BI账号配置页面如下。

    相关配置项请参见内置账号配置说明

    image.png

  • 开启标准SSO登录

    image.png

  • 在弹出窗口填写标准SSO的配置信息,

image.png

其中,标准SSO协议相关的配置项及说明如下:

具体的接口参数请参见接口参数规范

配置项

是否必填

说明

参考值

系统名称

对接的系统名称,用于在登录项的按钮组标题显示。

三方系统SSO

系统图标

对接的系统图标,用于在登录项中显示图标,最大32KB。

登录态名称

跨域场景下,为登录态参数的名称,其他场景下为 Cookie 名称。

user_ticket

是否跨域

可设置为跨域或不跨域。

跨域或不跨域

登录地址

三方系统登录页全称,必需携带回跳参数,并以 = 结尾。

http://dev.sso.aliyun.test:XXXX/login.htm?redirectUrl=

登出地址

分两种场景:

1:不跨域,为三方登出页的地址,必需携带回跳参数,并以 = 结尾

2:跨域,登出接口的地址,参考跨域登出接口规范

http://dev.sso.aliyun.test:XXXX/logout.do?redirectUrl=

登录态校验接口

(请参见接口参数规范

三方提供的开放接口,

用于校验登录态的有效性。GET 方法。

http://dev.sso.aliyun.test:XXXX/valid

用户信息接口

(请参见接口参数规范

三方提供的开放接口,

用于获取登录态用户信息。GET 方法。

http://dev.sso.aliyun.test:XXXX/queryUser

签名密钥-公钥ak

(请参见签名校验

接口的签名公钥;如不填写,则接口不携带加签参数。

123xxxxxx

签名密钥-私钥sk

(请参见签名校验

接口的签名密钥;如不填写,则接口不携带加签参数。

abcxxxxhijklmn

Session失效时间

指定登录态Session的失效时间,失效后,重新发起登录校验。单位秒,默认86400。

86400

步骤六: 保存发布

重要
  • 保存发布后立即生效,请慎重操作。

  • 推荐新建无痕模式窗口或者打开其他浏览器测试登录配置是否成功,以防退出登录后由于登录配置错误导致无法登录。

image.png

3.登录验证

在您完成标准SSO登录对接配置后,请访问Quick BI服务地址,并点击标准SSO登录,进行登录验证。

image..png

为了防止其他因素干扰,推荐新建无痕窗口用来测试登录。

需要注意的是所有的无痕窗口共用cookies,通过关闭窗口清空登录态时需要保证所有无痕窗口被关闭。

登录对接成功效果:当您登录后,出现以下页面,则说明已对接成功。 抛错原因是您当前登录的三方账号还未同步到Quick BI组织中,鉴权不通过,此时,您需要进行三方账号同步,请参见独立部署:三方账号同步方案

image..png

4.登录常见问题

4.1 AE0580800018 权限不足禁止访问,请联系组织管理员添加到具体组织

无组织用户默认无法访问Quick BI,需要先将三方账号添加到Quick BI,请参见独立部署:三方账号同步方案

image..png

4.2. Quick BI没有调用登录态校验接口

  1. 跨域场景下,回跳到Quick BI的链接中没有ticket或者ticket参数名/格式错误。

  2. 同域场景下

    1. cookies的域名和Qucik BI的域名没有满足同源。

    2. cookies的名称和策略中配置的[登录态名称]不一致。

    3. cookies的属性设置问题:

      1. HttpOnly开启后http无法获取https的cookie。

      2. SameSite属性设置不为None。

4.3. Quick BI请求三方接口失败,例如获取登录态或者用户信息失败

请联系运维同学查看日志报错。

协议对接说明

1.前提条件

  • 三方在登录时返回的自身的userId,accountName和nick必须保持唯一性

  • Quick BI调用三方接口的默认配置,三方可以按以下方式:

  • Content-Type:application/json

  • 传参方式:url+body同时传参

2.流程图

根据以上说明,提供同域和跨域方案,以供不同场景选择。

2.1同根域方案

业务侧系统与单点登录系统(SSO server) 保持同样的根域名,SSO server将登录态Cookie的域名设置为根域名下,通过浏览器带入到业务侧系统,以实现登录态的共享。

以公共云Quick BI为例,Quick BI域名为bi.aliyun.com,阿里云登录域名为account.aliyun.com;阿里云的登录态Cookie:login_aliyun_ticket的域名为.aliyun.com,子域名都可以获取这个Cookie。流程如下:

1.登录态Cookie的注入和注销清除,统一由三方SSO负责,业务侧不负责,只负责拿到登录态Cookie进行有效性校验和身份校验。

2.登录态Cookie名称自定义,由SSO server自定义,请参见配置标准SSO三方登录

3.因为是同根域,故支持同步登入和登出。

  • 登录流程

    未登录用户访问Quick BI页面,会重定向到三方登录页面,登录并写入Cookie后跳转回Quick BI自动登录。

    1. 三方访问Quick BI页面:http(s)//:xxx.qbi.com/home

  1. 未在cookies中拿到ticket,跳转三方登录链接

image.png

登出流程

业务侧会调用SSO server的登出地址,SSO server负责清理掉登录态Cookie实现登出。

image.png

2.2 跨域方案

存在部分场景,业务侧系统与SSO server根域名不一致,例如,使用IP的方式访问服务(常见于部分国企或者政府机构)。因此,无法通过Cookie的方式做到登录态的共享。跨域方案有如下流程:

  1. 登录态业务侧系统自行维护。身份校验成功之后,登录层维护了自有的登录态Cookie:x_login_ck。x_login_ck的过期时间,以及对应的Session过期时间,支持配置。

  2. 跨域特性,不支持同步登入登出。但预留了接口,可以在登出的时候调用。支持双向的登出接口(业务侧调SSO server/SSO server 掉业务侧登出)。提供登出的安全校验机制,需要SSO server按照规范实现。

登录流程

未登录用户访问Quick BI页面,会重定向到三方登录页面,登录后携带登录态参数重定向回Quick BI自动登录。

image.png

登出流程

因为跨域,登出的方式只能通过接口调用的方式支持,分为以下两种情况

  • 三方系统登出后,调用Qucik BI接口实现同步登出

  • Qucik BI登出后调用三方系统接口进行同步登出

接口的安全校验机制,请参见下图:

image.png

3.接口参数规范

3.1登录态校验接口(必须)

接口描述:通过ticket,校验登录态的有效性,如果有效,返回对应的userId信息。

接口API:接口路径自定义 (GET)

接口参数

参数名

类型

是否必选

说明

ticket

string

登录态ticket的参数。

accessKey

string

/

签名的ak,用于安全校验。

若配置了accessKey, 请求API时自动带上,客户系统决定是否消费。

timestamp

string

/

请求的时间戳,用于安全校验。

若配置了accessKey, 请求API时自动带上,客户系统决定是否消费。

nonce

string

/

随机字符串,用于重放攻击的安全校验。

若配置了accessKey, 请求API时自动带上,客户系统决定是否消费

signature

string

/

签名字符串,用于安全校验。

若配置了accessKey, 请求API时自动带上,客户系统决定是否消费。签名算法参考以下说明

请求示例:

GET
http://bi.douson.com/ticket/valid?ticket=c5f5628-21db-446b-8226-e76291e99380&timestamp=1610703757345&nonce=e76291e99380&signature=LAtufg1ssx-1Addkddl

返回参数

参数名

类型

是否必选

说明

code

string

接口状态码,不实际消费,仅用于日志提示,例如出错时错误码。

message

string

接口状态信息,不实际消费,仅用于日志提示,例如出错时错误码。

success

boolean

标记接口请求状态。

  • true:标识接口请求成功(非登录态校验)

  • false:接口请求失败

data

json

登录态校验信息。

|__isLogin

boolean

登录态是否有效。

  • true:有效。

  • false:无。

|__userId

string

isLogin=true时必需

用户唯一ID。isLogin=true时必须返回。

success=true && isLogin=true时Quick BI消费,用于获取用户信息。

|__redirectUrl

string

重定向地址。isLogin=false有效,用于自定义重定向。

success=true && isLogin=false时,登录层消费(如果有)重定向跳转。

# 登录态校验成功结果返回案例
{
    "code":"200",       
    "message":"获取成功",  
    "success":true,    // 请求成功,
    "data":{             
        "isLogin":true,   // 登录态校验有效
        "userId":"1089987878",   // 登录态校验有效,返回登录态对应的用户ID
        "redirectUrl":""
    }
}

# 登录态校验失败结果返回案例
{
    "code":"400",       
    "message":"校验失败",  
    "success":true,    // 请求成功,
    "data":{             
        "isLogin":false,   // 登录态校验失败
        "userId":"",  
        "redirectUrl":"http://aliyun.com/xxxxx"  // 按照该重定向地址跳转
    }
}
重要

  • isLogin不要序列化成login。

  • Quick BI存量三方SSO对接客户。配置的API存在两种形式,Quick BI直接把参数拼接在配置的URL后。对于这类历史存量客户,参考配置:standard.sso.is.old.version = true

  • 携带URL参数。例如http://aaa.com/valid?userToken=。如果切换,接口新增一个ticket参数即可。

  • 路径参数。例如http://aaa.com/valid/ 。 如果切换,需要新增一个接口,并且从参数ticket中解析。

3.2获取账户登录信息接口(必须)

接口描述:通过userId,获取具体的用户信息。

接口API:接口路径自定义 (GET)

接口参数

参数名

类型

是否必选

说明

userId

string

用户唯一ID。

accessKey

string

/

签名的ak,用于安全校验。

若配置了accessKey,请求API时自动带上,客户系统决定是否消费。

timestamp

string

/

请求的时间戳,用于安全校验。

若配置了accessKey,请求API时自动带上,客户系统决定是否消费。

nonce

string

/

随机字符串,用于重放攻击的安全校验。

若配置了accessKey,请求API时自动带上,客户系统决定是否消费。

signature

string

/

签名字符串,用于安全校验。

若配置了accessKey,请求API时自动带上,客户系统决定是否消费。

示例1:

GET
http://bi.douson.com/query/userinfo?userId=c5f5628-21db-446b-8226-e76291e99380&timestamp=1610703757345&nonce=e76291e99380&signature=LAtufg1ssx-1Addkddl

返回参数

参数名

类型

是否必选

说明

code

string

接口状态码,不实际消费,仅用于日志提示,例如出错时错误码。

message

string

接口状态信息,不实际消费,仅用于日志提示,例如出错时错误码。

success

boolean

标记接口请求状态。

  • true:标识接口请求成功。

  • false:接口请求失败。

data

json

用户账户信息字段。

|__userId

string

用户唯一ID,必需且唯一。

|__userName

string

用户账户名,必需且唯一。

|__nick

string

用户账户的显示名称。必需且唯一。

|__userEmail

string

用户账户邮箱。

|__userPhone

string

用户账户电话。

|__extraInfo

Map<String,String>

其他扩展字段,自定义。

透传到业务系统,登录层不做消费。

{
    "code":"200",
    "message":"获取成功",
    "success":true,
    "data":{
        "userId":"1089987878",
        "userName":"alibaba",
        "nick":"阿里巴巴测试账号",
        "extraInfo":{
            "tag":"自定义扩展字段"
        }
    }
}
重要

  • Quick BI存量三方SSO对接客户。配置的API存在两种形式,Quick BI直接把参数拼接在配置的URL后。

  • 携带URL参数。例如http://aaa.com/getUserInfo?userId=。如果切换,接口新增一个userId参数即可。

  • 路径参数的。例如http://aaa.com/getUserInfo/ 。如果切换,需要新增一个接口或者参数,并且从参数ticket中解析。

3.3 SSO server登出,通知业务同步登出接口(可选)

接口调用方:SSO server

接口描述:用于跨域场景下,业务侧(例如Quick BI)被动接收登出的通知。SSO server在收到登出的通知的时候调用该接口,通知业务侧系统做同步登出。

接口API: /auth_sso/login/crossDomain/logout.do(POST) Content-Type:application/x-www-form-urlencoded;charset-utf8

接口参数

参数名

类型

是否必选

说明

accountId

string

需要登出的账户ID。

accessKey

string

签名的ak,用于安全校验。

timestamp

string

请求的时间戳,用于安全校验。

nonce

string

随机字符串,用于重放攻击的安全校验。

signature

string

签名字符串,用于安全校验。

返回参数

{
    "code":"200",
    "message":"获取成功",
    "success":true,
    "data": true,   // 登出清理是否成功
    "traceId": "xxxxxxxxxxxx" // 请求唯一ID
}

3.4 业务侧登出,通知SSO server 侧同步登出接口规范(可选)

接口调用方:业务侧(例如Quick BI), 接口可选。

接口描述:用于跨域场景。业务侧登出,业务清理掉自身登录态Cookie后,调用配置的SSO server 该接口。SSO server收到通知后,做同步登出操作。该接口需要SSO server 按照标准协议规范开发并提供配置过来。

接口API:接口路径自定义 (POSTContent-Type:application/x-www-form-urlencoded;charset-utf8

接口参数

表单参数

类型

是否必选

说明

userId

string

用户账户的唯一ID。

accessKey

string

/

签名的ak,用于安全校验。

请求API时自动带上,客户系统决定是否消费。

timestamp

string

/

请求的时间戳,用于安全校验。

请求API时自动带上,SSO server决定是否消费。

nonce

string

/

随机字符串,用于重放攻击的安全校验。

请求API时自动带上,SSO server决定是否消费

signature

string

/

签名字符串,用于安全校验。

请求API时自动带上,SSO server决定是否消费。

返回参数

{
    "code":"200",
    "message":"获取成功",
    "success":true,  // 用于判断接口是否调用成功
    "data": true,    // 用于判断登出是否执行成功
}

4.签名校验

4.1 接口安全加签规则 & SDK算法

签名采用行业通用的签名算法,签名算法如下:

image.png

主体签名分成两个步骤:

  1. 构建待签名字符串string_to_sign

  2. 使用密钥(secret_key)对拼接的string_to_sign进行签名操作,加密算法使用HMAC-SHA256base64

4.2 构建待签名字符串(string_to_sign)

拼接待签名字符串string_to_sign的拼接规则如下:

string_to_sign = 
    Request_Method\n
    Request_Uri\n
    Request_QueryString\n

上述各部分,以换行符\n分割连接(非字符串"\n"),各部分说明如下:

表1-1 待签名字符串拼接说明

拼接部分

说明

示例

Request_Method

http方法名:GET | POST | PUT | DELETE

格式为大写。

GET

Request_Uri

原始请求的相对路径,不包含host和URL请求参数。

重要

如果uri中携带符号+,则需要先将+替换成空格。

/

Request_QueryString

主要由请求中所有Query参数、所有表单参数拼接而成。

拼接规则:

  • 对请求参数名按照字典顺序从小到大排序,然后拼接。拼接时,每组键值对用"="链接,键值对之间,用"&"连接。

说明

  • 对于参数键值对,如果参数名或者参数值为空时,不参与拼接。

  • 对于表单参数,可能存在同一个参数有多个值的情况。此时先要将value进行字母排序,中间以英文逗号分割开,拼接成一个value。当做一组参数键值对。

  • signature不参与字符串拼接。

对于请求:http://abc.test/openapi/v2/user?status=3&pageNo=1&pageSize=10&key=

其中,Query参数键值对:

  • status=3

  • pageNo=1

  • pageSize=10

  • key=

按照参数名排序,顺序为:

key->pageNo->pageSize->status

由于key的参数为null,因此不加入拼接,故拼接的Request_QueryString如下:

pageNo=1&pageSize=10&status=3

4.3 生成签名字符串(signature)

生成了待签名字符串后,使用secret_key私钥对待签名字符串进行加密,生成最终的签名(signature)。具体的签名规则如下:

signature = HMAC-SHA256-BASE64(sk, percentURLEncode(string_to_sign))

其中,需要说明的是:

  • percentURLEncode:表示待签名字符串的编码以及特殊字符的处理。编码规则如下:

    • 对于字符 A~Z、a~z、0~9 以及字符、短划线(-)、下划线(_)、半角句号(.)、波浪线(~)不编码。

    • 对于其它字符编码成 %XY 的格式,其中 XY 是字符对应 ASCII 码的 16 进制表示。例如英文的双引号(”)对应的编码为 %22。

    • 对于扩展的 UTF-8 字符,编码成 %XY%ZA… 的格式。

    • 英文空格( )要编码成 %20,而不是加号(+)。

该编码方式和一般采用的application/x-www-form-urlencoded MIME 格式编码算法(比如 Java 标准库中的 java.net.URLEncoder 的实现)相似,但又有所不同。实现时,可以先用标准库的方式进行编码,再把编码后的字符串中加号(+)替换成 %20、星号(*)替换成 %2A%7E替换回波浪线(~),即可得到上述规则描述的编码字符串。这个算法可以用下面的percentEncode方法来实现。

   private  static String percentEncode(String value) throws UnsupportedEncodingException {
        return value != null ? URLEncoder.encode(value, "UTF-8").replace("+", "%20").replace("*", "%2A").replace("%7E", "~") : null;
    }

  • HMAC-SHA256-BASE64:表示先进行SHA256编码,而后采用BASE64对生成的结果进行加密。

4.4 JAVA方式实现签名校验

目前,统一登录层提供了开发jar包,可以直接使用。jar包下载地址:sso-signature-1.1.0-SNAPSHOT.jar

如果是集团域内环境下,可以直接引用maven:

        <dependency>
            <groupId>com.alibaba.quickbi</groupId>
            <artifactId>sso-signature</artifactId>
            <version>1.1.0-SNAPSHOT</version>
        </dependency>

jar包中主要包含了三个类:

  • NonceUtil:随机字符串生成工具类。

  • SignatureUtil:签名生成工具类。主要用于生成签名方式。例如,3.2.3需要调取业务侧的跨域登出接口,涉及签名校验。

  • RequestSignatureUtil:http请求的签名校验工具类。

主要用于解析待签名的请求,例如:3.2.1/3.2.2/3.2.4接口,调用SSO server提供的接口,SSO server通过RequestSignatureUtil.validRequestSignature() 校验请求的合法性。

SignatureUtil类如下:

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;

/**
 * 签名生成组件
 *
 * @author gengnan.wy
 * @date 2021-01-18
 */
public class SignatureUtil {

    /**
     * 构建待签名的字符串
     *
     * @param uri        请求的URI
     * @param method     方法,GET、POST
     * @param parameters 参与签名的请求参数。
     * @return
     */
    public static String buildStringToSign(String uri, String method,
                                           Map<String, String> parameters) {
        if (null == uri || "".equalsIgnoreCase(uri.trim())
            || null == method || "".equalsIgnoreCase(method.trim())) {
            throw new IllegalArgumentException("input parameter error, uri or method can not be null");
        }
        // URL中。按照 原始符号 --> 浏览器URL编码 --> spring web解析接收,对于 加号(+)和空格, 有如下问题:
        // +   -> %2B --> 空格;
        // 空格 -> %20 --> 空格;因此,对于spring web接收到的请求,并不清楚空格的原始对应,是 + 还是空格
        // 因此,此处对于源头所有的+,按照空格处理
        uri = uri.replace("+", " ");

        // method
        StringBuilder sb = new StringBuilder();
        sb.append(method.toUpperCase());
        sb.append("\n");

        // uri
        sb.append(uri);
        sb.append("\n");

        // paramters
        if (null != parameters && parameters.size() > 0) {
            String queryString = buildSortedString(parameters, "=", "&");
            if (null != queryString) {
                sb.append(queryString);
                sb.append("\n");
            }
        }
        return sb.toString();
    }

    /**
     * 签名函数
     *
     * @param stringToSign :待签名的字符串
     * @param secretKey    签名加密的密钥
     * @return
     */
    public static String sign(String stringToSign, String secretKey) {
        if (null == stringToSign || null == secretKey) {
            throw new IllegalArgumentException("input parameter error");
        }
        try {
            String encodeString = percentEncode(stringToSign);
            return sha256(encodeString, secretKey);
        } catch (Exception e) {
            e.printStackTrace();
            throw new IllegalStateException("error in encode string");
        }

    }

    private static String percentEncode(String value) throws UnsupportedEncodingException {
        return value != null ? URLEncoder.encode(value, "utf-8")
            .replace("+", "%20")
            .replace("*", "%2A")
            .replace("%7E", "~") : null;
    }

    /**
     * 将map中的元素,按照key的字母顺序,进行排序
     *
     * @param maps
     * @return
     */
    private static String buildSortedString(Map<String, String> maps, String symbol1, String symbol2) {
        StringBuilder sb = new StringBuilder();
        List<String> keys = new LinkedList<String>();
        for (String key : maps.keySet()) {
            keys.add(key);
        }
        Collections.sort(keys);

        for (int i = 0; i < keys.size(); i++) {
            String key = keys.get(i);
            String value = maps.get(key);
            // key或者value为空或null,不加入字符串的拼接
            if (null == key || key.trim().length() == 0
                || null == value || value.trim().length() == 0) {
                continue;
            }
            sb.append(key);
            sb.append(symbol1);
            sb.append(value);
            if (i != keys.size() - 1) {
                sb.append(symbol2);
            }
        }
        return sb.toString();
    }

    /**
     * sha256加密处理
     *
     * @param content 待加密字符串
     * @param secret  密钥
     * @return
     */
    public static String sha256(String content, String secret) throws NoSuchAlgorithmException,
        UnsupportedEncodingException, InvalidKeyException {
        Mac hamcSha256 = Mac.getInstance("HmacSHA256");
        byte[] keyBytes = secret.getBytes("UTF-8");
        SecretKeySpec secretKey = new SecretKeySpec(keyBytes, 0, keyBytes.length, "HmacSHA256");
        hamcSha256.init(secretKey);
        byte[] result = hamcSha256.doFinal(content.getBytes("UTF-8"));
        return new String(Base64.getEncoder().encode(result));
    }
}

RequestSignatureUtil如下:

import javax.servlet.http.HttpServletRequest;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * Http请求的签名校验工具类
 *
 * @author gengnan.wy
 * @date 2021-01-18
 */
public class RequestSignatureUtil {

    private static final String SIGNATURE_PARAM_NAME = "signature";
    private static final int NONCE_SIZE = 16;

    /**
     * 校验request请求的合法性。
     *
     * @param request
     * @return
     */
    public static boolean validRequestSignature(HttpServletRequest request, String sk) {
        if (null == request || null == sk) {
            return false;
        }

        String uri  = request.getRequestURI();
        String method = request.getMethod();
        Map<String, String> parameters = extractQueryParameters(request);
        String requestSign = "";
        if (!parameters.containsKey(SIGNATURE_PARAM_NAME)) {
            return false;
        } else {
            requestSign = parameters.get(SIGNATURE_PARAM_NAME);
            parameters.remove(SIGNATURE_PARAM_NAME);
        }
        String stringToSign = SignatureUtil.buildStringToSign(uri, method, parameters);
        String sign = SignatureUtil.sign(stringToSign, sk);
        if (sign.equalsIgnoreCase(requestSign)) {
            return true;
        }
        return false;
    }

    /**
     * 提取请求参数信息;如果一个参数具有多个参数值,则将多个参数值按照字母顺序,从小到大排序,以英文逗号连接;
     *
     * @param request
     * @return
     */
    private static Map<String, String> extractQueryParameters(HttpServletRequest request) {
        Map<String, String[]> parameterMap = request.getParameterMap();
        Map<String, String> result = new HashMap<String, String>();
        if (null == parameterMap || parameterMap.size() == 0) {
            return result;
        }
        for (Map.Entry<String, String[]> entry : parameterMap.entrySet()) {
            String key = entry.getKey();
            String[] values = entry.getValue();
            // 参数为空的,不加入签名计算
            if (null == values || values.length == 0) {
                continue;
            }
            if (values.length == 1) {
                result.put(key, values[0]);
            } else {
                String value = sortArraysToString(values, ",");
                result.put(key, value);
            }
        }
        return result;
    }

    /**
     * 按照字典顺序排序
     *
     * @param arrays
     * @return
     */
    private static String sortArraysToString(String[] arrays, String sep) {
        List<String> list = Arrays.asList(arrays);
        Collections.sort(list);
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < list.size(); i++) {
            sb.append(list.get(i));
            if (i != list.size() - 1) {
                sb.append(sep);
            }
        }
        return sb.toString();
    }
}