==========================
mge-sso-认证服务接入指南
==========================
材料基因工程(以下简称MGE)统一平台,目前包括 mgedata、matcloud、ocpmdm、ipd 四个子平台,项目要求以 mgedata 为主平台,提供其他子平台入口。目前统一平台部署于北京计算中心,我们负责运维。
项目要求所有子平台采用统一登录的方式,以给用户统一平台的感觉,也避免用户在使用过程中需要多次输入用户名、密码进行登录操作。以前平台部署于北科大时,采用 BaseURL 的方式区分各个子平台,统一登录也是由我们在前端采用 js 发送请求来实现,**这种方式是不安全、不稳定的,用户体验无法保证,也容易遭受攻击**。现在平台部署于计算中心,各子平台采用二级域名区分,原来的方式也无法使用了,因为涉及到跨域请求的问题。为此,我们在 mgedata 平台中开发了**单点登录 (Single Sign-On,SSO)*认证服务***,以保证统一登录的安全与稳定,提升用户体验。
目前,由我们负责的原子势库(ipd)子系统已经接入 SSO 服务,可访问 [IPD](https://ipd.mgedata.cn/) 和 [MGEDATA](https://www.mgedata.cn/) 体验。
MGE SSO简单说明
-------------------
MGE SSO 认证机制跟市面上常见的 SSO 服务类似,主要包含三个接口,访问这三个接口可以获取用户信息、进行用户认证。
加密算法
--------------
通信消息采用 **AES(GCM)**加密算法加密,加密后的数据格式为 **nonce + cihpertext + tag** ,如果是 post 请求,则这些数据直接放在 body 部分,如果为 get 请求,则进行一次 base64 url safe 编码后,放置在对应参数位置。
加密使用的 **key** (即下方的 **accessSecret**) 需要向我们申请,下面 示例代码 部分提供了 Python 和 Java 的加密工具类示例代码,可以参考使用。
接口调用步骤
----------------
MGE SSO 包含三个接口,访问这三个接口可以获取用户信息、进行用户认证。具体接入步骤如下:
1. 向我们申请接入的**公钥 *accessKey***、*私钥 **accessSecret*** 及 *SSO 服务访问地址 **serverUrl***。**serverUrl** 目前为 https://www.mgedata.cn/sso/。
2. 将 accessKey 放在 http header 部分,header 名为 **==X-MGE-SSO-ACCESS-KEY== ,每次请求都必须携**带,否则无法识别。
3. SSO client 与 server 进行交互的数据应该进行二次封装,因为要提供时间戳以防重入。封装格式为 `{"_": 1528588646.452032, "data": <实际的数据>}`,其中 \_ 字段为 utc 时间戳(秒数),可以是整数或浮点数,有效期为 60s,**data** 字段为实际的数据。**下述的步骤中提到的 raw 的数据发送之前都应该按照上述步骤进行包装,收到数据之后也应该对应的进行拆包**。
4. client 向 SSO server 请求 token,请求地址为 **serverUrl + request-token/** ,请求方法为 post,携带数据为 `{"redirect_to": "<下一跳地址>"}`,下一跳地址应该为客户端接收 request-token 的回调地址,如 https://client.mgedta.cn/sso/client/authorization/?next=/index/ 。
5. sevrver 端接收到 token 请求后,会生成 **request\_token** 返回(**request\_token** 字段)。client 拆包得到 **request\_token** 后,应向 server 请求授权,请求地址为 serverUrl + authorize/ ,请求方法为 **get**,参数名为 **token**,值为request_token ,**这里的 token 不需要加密**。(可以直接跳转到对应地址)
6. server 端收到授权请求后,如果 request\_token 有效,则会生成 **access\_token** (**access\_token** 字段,Base64 Url Safe Encoded),跳转到上面的 **redirect\_to** 地址。
7. client (对应上面 /sso/client/authorization/ 接口)接收到 **access\_token** 之后,应该向 SSO server 验证得到的 token,请求地址为 **serverUrl + verify/**,请求方法为 **post**,携带数据为 `{"access_token": "<刚刚获取到的access_token>"}`。
8. server 端收到这个验证请求,如果 **access\_token** 无效或过期,则 server 返回对应错误。如果 access_token 有效,但是这个请求对应的用户不存在或者尚未登录,则会跳转到统一登录界面,用户登录后,后续步骤跟下面的类似。
9. 如果 **access\_token** 有效,则 server 将返回对应的用户的数据(请求的用户已经登录或者在上一步登录成功)。用户数据包括 **username**, **nickname**, **email**, **is\_active**, **avatar**, **sex**, **tel**, **institution**,其中 **username**, **email** 都是唯一的,***client*** 可以用其标识用户,**nickname** 为昵称,**is\_active** 表示该用户是否有效/合法,**avatar** 为头像地址,**sex** 为用户性别(M, F, U,分别代表男、女、未设置),**tel** 为手机号,**institution** 为所属机构。
10. client 收到用户信息之后,应该进行查找用户/创建用户操作,然后登录该用户。
- **注意事项**
1. 拼接请求地址时,注意 serverUrl 末尾的 / 及请求地址前缀 /,否则容易出错。如 *https://www.mgedata.cn/sso/request-token/*,一不小心就会拼接成 *https://www.mgedata.cn/sso//request-token/*。
2. 要保证 client 所在主机的时间正确,否则时间戳不对,会得到签名过期的错误。
3. client 应记录用户登录状态,不需每次都向 server 进行认证。
4. 采用统一用户身份,但是各个 client 站点内的用户权限应该与 server 站点独立,所以 client 站点如果有权限控制应该另外设置。
交互流程图
------------
.. image:: https://s1.ax1x.com/2018/07/22/PGwG8S.png
示例项目
------------
这里提供 spring-boot 和 django 的具体实现。使用其它框架的可以参考实现
此外,如果你的项目是使用 Django 开发,则可以直接下载使用我们已经开发测试过的 django app。
[sso_client.django-app.rar](https://drive.google.com/open?id=1qPPvMSk3hqr6bER_S2OhyEIYgmkjfzpG)
如果你的项目是使用 Spring-boot 开发,也可以下载使用下面的 demo 项目。
[sso-client-demo.spring-boot.rar](https://drive.google.com/open?id=17cgTCm0daDRmaVYYy2rTaOsFrz8t98VH)
示例代码
------------
这里提供一下集中 的加密算法工具类,使用这写语言的可以直接使用,使用其它语言的可以参考其实现。
**Python**
::
class AesGcmUtil:
DEFAULT_ENCODING = 'utf-8'
GCM_NONCE_LENGTH = 16
GCM_TAG_LENGTH = 16
def __init__(self, private_key: str):
self.private_key = private_key.encode(self.DEFAULT_ENCODING)
self.cipher = None
self.nonce = None
self.tag = None
self.result = None
def encrypt(self, data: (str, bytes)):
if isinstance(data, str):
data = data.encode(self.DEFAULT_ENCODING)
self.cipher = AES.new(self.private_key, AES.MODE_GCM)
self.nonce = self.cipher.nonce
self.result, self.tag = self.cipher.encrypt_and_digest(data)
return self.result
def decrypt(self, nonce, data, tag) -> bytes:
# self.nonce = nonce
# self.tag = tag
self.cipher = AES.new(self.private_key, AES.MODE_GCM, nonce=nonce)
self.result = self.cipher.decrypt_and_verify(data, tag)
return self.result
def decrypt_wrapped_data(self, data):
nonce, data, tag = self.unwrap(data)
return self.decrypt(nonce, data, tag)
def wrap(self):
if not self.tag or not self.nonce or not self.result:
raise ValueError('wrap can only be used after encrypt!')
return self.nonce + self.result + self.tag
def unwrap(self, data):
nonce = data[:self.GCM_NONCE_LENGTH]
tag = data[-self.GCM_TAG_LENGTH:]
data = data[self.GCM_NONCE_LENGTH:-self.GCM_TAG_LENGTH]
return nonce, data, tag
**Java**
::
/**
* @author yuvv
* @date 2018/6/7
*/
public class AesGcmUtils {
public static final Charset CHARSET = StandardCharsets.UTF_8;
private final String TRANSFORMATION = "AES/GCM/NoPadding";
/**
* 认证 tag 长度(字节)
*/
private static final int GCM_TAG_LENGTH = 16;
/**
* nonce 长度(字节).
*/
private static final int GCM_NONCE_LENGTH = 16;
/**
* 密钥
*/
private String secretKey;
/**
* 认证 tag
*/
private byte[] authTag;
/**
* 加密 nonce
*/
private byte[] nonce;
/**
* 密文串(不包含 tag)
*/
private byte[] cipherText;
public byte[] getAuthTag() {
return authTag;
}
public byte[] getNonce() {
return nonce;
}
public byte[] getCipherText() {
return cipherText;
}
public String getCipherTextString() {
return new String(cipherText, CHARSET);
}
public AesGcmUtils(String secretKey) {
this.secretKey = secretKey;
nonce = new byte[GCM_NONCE_LENGTH];
authTag = new byte[GCM_TAG_LENGTH];
}
/**
* 加密文本
*
* @param text 待加密字符串
* @return 返回加密后的字节数组(注意末尾接了 tag)
* @throws GeneralSecurityException 找不到对应算法或算法参数错误
*/
public byte[] encrypt(String text) throws GeneralSecurityException {
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
SecretKey key = new SecretKeySpec(secretKey.getBytes(CHARSET), "AES");
SecureRandom random = SecureRandom.getInstanceStrong();
random.nextBytes(nonce);
GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH * 8, nonce);
cipher.init(Cipher.ENCRYPT_MODE, key, spec);
byte[] result = cipher.doFinal(text.getBytes(CHARSET));
cipherText = new byte[result.length - GCM_TAG_LENGTH];
System.arraycopy(result, 0, cipherText, 0, result.length - GCM_TAG_LENGTH);
System.arraycopy(result, result.length - GCM_TAG_LENGTH, authTag, 0, GCM_TAG_LENGTH);
return result;
}
/**
* 解密数据
* @param nonce 加密所用 nonce 值
* @param cipherText 待解密数据(包括 tag)
* @return 解密之后的字符串
* @throws GeneralSecurityException 找不到对应算法或算法参数错误
*/
public String decrypt(byte[] nonce, byte[] cipherText) throws GeneralSecurityException {
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
SecretKey key = new SecretKeySpec(secretKey.getBytes(CHARSET), "AES");
GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH * 8, nonce);
cipher.init(Cipher.DECRYPT_MODE, key, spec);
byte[] result = cipher.doFinal(cipherText);
return new String(result, CHARSET);
}
public String decryptWrappedData(byte[] data) throws GeneralSecurityException {
System.arraycopy(data, 0, nonce, 0, GCM_NONCE_LENGTH);
cipherText = new byte[data.length - GCM_TAG_LENGTH];
System.arraycopy(data, GCM_NONCE_LENGTH, cipherText, 0, cipherText.length);
return decrypt(nonce, cipherText);
}
/**
* 加密之后封装 nonce + cipherText + authTag
* @return 返回封装后结果
*/
public byte[] wrapData() {
byte[] wrappedData = new byte[nonce.length + cipherText.length + authTag.length];
System.arraycopy(nonce, 0, wrappedData, 0, GCM_NONCE_LENGTH);
System.arraycopy(cipherText, 0, wrappedData, GCM_NONCE_LENGTH, cipherText.length);
System.arraycopy(authTag, 0, wrappedData, GCM_NONCE_LENGTH + cipherText.length, GCM_TAG_LENGTH);
return wrappedData;
}
/**
* 将封装好的数据拆开
* @param data 待拆数据
*/
public void unwrapData(byte[] data) {
cipherText = new byte[data.length - GCM_NONCE_LENGTH - GCM_TAG_LENGTH];
System.arraycopy(data, 0, nonce, 0, GCM_NONCE_LENGTH);
System.arraycopy(data, GCM_NONCE_LENGTH, cipherText, 0,
data.length - GCM_NONCE_LENGTH - GCM_TAG_LENGTH);
System.arraycopy(data, data.length - GCM_TAG_LENGTH, authTag, 0, GCM_TAG_LENGTH);
}
}
**C \#**
c# 需要安装 BouncyCastle 包,vs 中使用 NuGet 安装即可。
也可以到官网下载,http://www.bouncycastle.org/csharp/index.html
使用 .net core 的同学移步 https://www.nuget.org/packages/Portable.BouncyCastle/1.8.2
::
using System;
using System.Text;
using System.IO;
using Org.BouncyCastle.Crypto;
using Org.BouncyCastle.Crypto.Engines;
using Org.BouncyCastle.Crypto.Modes;
using Org.BouncyCastle.Crypto.Parameters;
using Org.BouncyCastle.Security;
namespace Encryption {
class AesGcm256 {
private readonly SecureRandom Random = new SecureRandom();
// 默认编码 utf8
public readonly Encoding DEFAULT_ENCODING = Encoding.UTF8;
// nonce 长度(字节)
public readonly int GCM_NONCE_LENGTH = 16;
// auth tag 长度(字节)
public readonly int GCM_TAG_LENGTH = 16;
private byte[] nonce;
private byte[] authTag;
private byte[] cipherText;
private byte[] key;
public AesGcm256(string key) {
this.key = DEFAULT_ENCODING.GetBytes(key);
nonce = new byte[GCM_NONCE_LENGTH];
authTag = new byte[GCM_TAG_LENGTH];
}
public byte[] getNonce() {
return nonce;
}
public byte[] getAuthTag() {
return authTag;
}
public byte[] getCipherText() {
return cipherText;
}
///
/// 加密字符串,会更新 nonce,authTag,cipherText
///
/// 待加密字符串
/// 返回加密后的结果。注意返回结果是 cipherText + authTag
public byte[] encrypt(string msg) {
var secretMessage = DEFAULT_ENCODING.GetBytes(msg);
Random.NextBytes(nonce, 0, GCM_NONCE_LENGTH);
var cipher = new GcmBlockCipher(new AesEngine());
var parameters = new AeadParameters(new KeyParameter(key), GCM_TAG_LENGTH * 8, nonce);
cipher.Init(true, parameters);
//Generate Cipher Text With Auth Tag
var result = new byte[cipher.GetOutputSize(secretMessage.Length)];
var len = cipher.ProcessBytes(secretMessage, 0, secretMessage.Length, cipherText, 0);
cipher.DoFinal(result, len);
cipherText = new byte[result.Length - GCM_TAG_LENGTH];
Array.Copy(result, 0, cipherText, 0, cipherText.Length);
Array.Copy(result, cipherText.Length, authTag, 0, GCM_TAG_LENGTH);
return result;
}
///
/// 验证并解密密文(解密并不会更新 nonce,authTag,cihperText 的值)
///
/// nonce 值
/// 密文(cihperText + authTag)
/// 返回解密后的字符串
public String decrypt(byte[] nonce, byte[] cipherText) {
using (var cipherStream = new MemoryStream(cipherText))
using (var cipherReader = new BinaryReader(cipherStream)) {
var cipher = new GcmBlockCipher(new AesEngine());
var parameters = new AeadParameters(new KeyParameter(key), GCM_TAG_LENGTH * 8, nonce);
cipher.Init(false, parameters);
var plainText = new byte[cipher.GetOutputSize(cipherText.Length)];
try {
var len = cipher.ProcessBytes(cipherText, 0, cipherText.Length, plainText, 0);
cipher.DoFinal(plainText, len);
} catch (InvalidCipherTextException) {
return null;
}
return DEFAULT_ENCODING.GetString(plainText);
}
}
///
/// 封包,封为 nonce + authTag + cihperText 形式
///
/// 返回封包之后结果
public byte[] wrapData() {
byte[] result = new byte[GCM_NONCE_LENGTH + cipherText.Length + GCM_TAG_LENGTH];
nonce.CopyTo(result, 0);
Array.Copy(cipherText, 0, result, GCM_NONCE_LENGTH, cipherText.Length);
Array.Copy(authTag, 0, result, GCM_NONCE_LENGTH + cipherText.Length, GCM_TAG_LENGTH);
return result;
}
///
/// 拆包,将 nonce + authTag + cihperText,拆开并设置到字段
///
/// 待拆数据
public void unwrapData(byte[] data) {
cipherText = new byte[data.Length - GCM_TAG_LENGTH - GCM_NONCE_LENGTH];
Array.Copy(data, 0, nonce, 0, GCM_NONCE_LENGTH);
Array.Copy(data, GCM_NONCE_LENGTH, cipherText, 0, cipherText.Length);
Array.Copy(data, data.Length - GCM_TAG_LENGTH, authTag, 0, GCM_TAG_LENGTH);
}
}
}