自定义接口配置


1 功能简介

SAML 2 是一个标准的 SSO 协议, 并受Windows AD支持, 因此在 SSO 领域有着广泛的应用,但其要求开发者具有一定的背景知识(建议阅读官方文档),对接成本较高, 因此我们补充实现了自定义接口。

自定义接口是简化的SAML协议,企业客户可在现有SSO接口基础上进行修改,按照简道云的要求调用服务和返回参数,并将认证后的用户信息返回给简道云,完成账号关联。


自定义登录页的设置方式参考文档: 「自定义登录页」


2 IdP 配置说明

自定义接口的整体配置与 SAML 2 类似, 只是支持的加密算法与密钥的形式存在差异, 在 SAML 中我们支持 SHA 系列算法, 而自定义接口中我们支持 HMAC-SHA系列算法。在 SAML 中我们要求用户填写包含公钥的基于 X.509 格式的证书, 而在自定义接口中需要填写的是一个普通的密钥字符串, 长度最大为 128

基于配置的 IdP 信息, 客户需要开发自己的 IdP来与简道云对接以实现单点登录. 用户 IdP 的作用可从下图中体现:


即 IdP 服务:

  • 接受来自简道云的认证请求;
  • 基于其在简道云的配置与简道云的开发文档中提到的规范, 验证请求的合法性;
  • 从 Session 中获取当前成员的相关信息, 封装及签发 Token;
  • 将 Token 作为参数, 将用户重定向回简道云。

2.1 参数

IdP 服务与简道云间的 Token 传递基于 JWT 标准, 关于 JWT 标准可以阅读这篇文档(英文)或阮一峰老师的这篇文章(中文)这篇文章(中文), 参数符合以下格式:

来自简道云的认证请求:

参数名 说明
request 认证请求 Token, 详见下文
state 状态值, 此值需要原封不动的返回简道云, 简道云通过此值来避免恶意的断言请求

返回简道云的认证断言:

参数名 说明
response 认证断言 Token, 详见下文
state 状态值, 来自认证请求
redirect_uri 重定向地址, 可选, 需要进行 URL 编码.

2.2 签名内容

认证请求

对于来自简道云的认证请求, IdP 需要自行验证(取决于您的安全级别, 开发环境可以跳过验证):

参数名 说明
iss 常量值 “com.jiandaoyun”, 意指此请求由简道云发起
aud 可选, 管理员在简道云配置的 Issuer
iat 签名签发时间(通常 JWT 第三方库会包含此验证, 无需手动实现验证逻辑)
exp 签名失效时间(通常 JWT 第三方库会包含此验证, 无需手动实现验证逻辑)
type 常量值 “sso_req”, 意指此为一个单点登录认证请求

您实际收到的请求原文及其对应解码后的格式如下示例:


认证断言

简道云要求 IdP 在确认用户身份并将其重定向回简道云时, 携带的 response 参数解码后对应的内容如下:

参数名 说明
aud 常量值 “com.jiandaoyun”, 意指此断言的接收方为简道云
exp 签名失效时间
iat 签名签发时间
iss 可选, 管理员在简道云配置的 Issuer
nbf 可选, 签名生效时间
type 常量值 “sso_res”, 意指此为一个单点登录认证断言
username 成员工号, 此值应当与一个已经在简道云通讯录中激活的成员工号一致
redirect_uri 重定向地址, 可选, 无需进行 URL 编码, 若该参数存在, 将覆盖 URL 中的同名参数.

您实际发送的断言原文及其对应解码后的格式如下示例:


3 示例

绝大多数编程语言都有较为良好的 JWT算法实现, 第三方库列表可在这个页面中查找,下面给出以 PythonGo实现IdP的简单示例:

Golang Demo:


package main
 
import (
   "fmt"
   "github.com/dgrijalva/jwt-go"
   "log"
   "net/http"
   "time"
)
 
const (
   acs      = "https://www.jiandaoyun.com/sso/custom/5cd91fe50e42834f41b7c6ef/acs"
   issuer   = "com.example"
   username = "angelmsger"
   secret   = "jdy"
)
 
func ValidBody(body jwt.MapClaims) bool {
   return body["iss"] == "com.jiandaoyun" && body["aud"] == issuer && body["type"] == "sso_req"
}
 
func ValidToken(query string) bool {
   token, err := jwt.Parse(query, func(token *jwt.Token) (interface{}, error) {
      if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
         return nil, fmt.Errorf("Unexpected Signing Method: %v ", token.Header["alg"])
      }
      return []byte(secret), nil
   })
   if err != nil {
      return false
   }
   claims, ok := token.Claims.(jwt.MapClaims)
   return ok && token.Valid && ValidBody(claims)
}
 
func GetTokenByUsername(username string) (string, error) {
   now := time.Now()
   token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
      "type":     "sso_res",
      "username": username,
      "iss":      issuer,
      "aud":      "com.jiandaoyun",
      "nbf":      now.Unix(),
      "iat":      now.Unix(),
      "exp":      now.Add(1 * time.Minute).Unix(),
   })
   return token.SignedString([]byte(secret))
}
 
func BuildResponseUri(token string, state string) string {
   target := acs + "?response=" + token
   if state != "" {
      target += "&state=" + state
   }
   return target
}
 
func handler(w http.ResponseWriter, r *http.Request) {
   query := r.URL.Query()
   reqToken := query.Get("request")
   if ok := ValidToken(reqToken); ok {
      if resToken, err := GetTokenByUsername(username); err == nil {
         target := BuildResponseUri(resToken, query.Get("state"))
         http.Redirect(w, r, target, http.StatusSeeOther)
      }
      w.WriteHeader(404)
   }
   w.WriteHeader(404)
}
 
func main() {
   http.HandleFunc("/sso", handler)
   log.Fatal(http.ListenAndServe(":8080", nil))
}

Python Demo:

from datetime import datetime, timedelta
from flask import Flask, abort, redirect, request
import jwt
 
from jwt import InvalidTokenError
 
 
class Const:
    ACS = 'https://www.jiandaoyun.com/sso/custom/5cd91fe50e42834f41b7c6ef/acs'
    SECRET = 'jdy'
    ISSUER = 'com.example'
    USERNAME = 'angelmsger'
 
 
app = Flask(__name__)
 
 
def valid_token(query):
    try:
        token = jwt.decode(
            query, Const.SECRET,
            audience=Const.ISSUER,
            issuer='com.jiandaoyun'
        )
        return token.get('type') == 'sso_req'
    except InvalidTokenError:
        return False
 
 
def get_token_from_username(username):
    now = datetime.utcnow()
    return jwt.encode({
        "type": "sso_res",
        'username': username,
        'iss': Const.ISSUER,
        "aud": "com.jiandaoyun",
        "nbf": now,
        "iat": now,
        "exp": now + timedelta(seconds=60),
    }, Const.SECRET, algorithm='HS256').decode('utf-8')
 
 
@app.route('/sso', methods=['GET'])
def handler():
    query = request.args.get('request', default='')
    state = request.args.get('state')
    if valid_token(query):
        token = get_token_from_username(Const.USERNAME)
        stateQuery = "" if not state else f"&state={state}"
        return redirect(f'{Const.ACS}?response={token}{stateQuery}')
    else:
        return abort(404)
 
 
if __name__ == '__main__':
    app.run(port=8080)


4 代码示例

绝大多数编程语言都有较为良好的 JWT 算法实现, 第三方库列表可在这个页面中查找. 这里给出以下编程语言对应的 Demo:


5 单点登出(可选)

如果用户配置了 SLO Endpoint, 当访问简道云的单点登出地址时, 简道云不仅会登出当前成员, 同时还会将成员重定向至 IdP 并携带登出请求参数, IdP 可以销毁与此成员的会话以实现单点登出的效果. 单点登出请求参数格式与认证请求参数一致, 并额外包含 jti 或 nameId 参数, 但不包含 state 字段, 在 Token 中的 type 为常量 “slo_req”.

本文是否对您有帮助?
 有帮助
 没帮助
您是否遇到了以下问题?
如需获取即时帮助,请联系技术支持