接入新产品
做完后,新产品(product)的用户通过登录客户端(client)用 BES 账号登录;产品带服务账号(service account)的证书,请 Control 按功能(feature)和角色(role)检查每个请求。
| 环境 | 登录地址 | 带证书调用的接口地址 |
|---|---|---|
local | https://id.local.globalbes.sg | https://id.local.globalbes.sg:19443 |
test | https://id.test.globalbes.sg | https://id.test.globalbes.sg:9443 |
staging | 待定 | 待定 |
prod | 待定 | 待定 |
Sign in to send requests to see which of these steps are done. Sign in
创建产品
在哪里做:测试台
创建产品,得到 product_id。后面登记客户端、服务账号、功能和角色都要填它。
在测试台点本步的 Product Create 标签,按下面的值填表(id 用前面步骤返回的),点 Send request。
填表的值:
{
"request_ref": "create-bim",
"code": "bim",
"scope_level": "project",
"display_name": "BIM"
}
返回:
{
"product_id": "b41d9a70-6c2e-4a0f-8e7d-52a9c3f0d614",
"code": "bim",
"scope_level": "project",
"sellable": true,
"display_name": "BIM",
"status": "active",
"version": 0
}
code:小写字母开头,只用小写字母、数字和_.-,登记后不再改。scope_level:按租户(tenant)卖写tenant,按项目(project)卖写project;project的产品只能在项目里进入,角色和开通记录(entitlement)也按项目算。status不写时是active;写成inactive的产品要先用product.set-status改成active才能登录。
错误:
| HTTP | 错误码 | 处理 |
|---|---|---|
| 409 | PRODUCT_CODE_CONFLICT |
这个 code 已被占用,换一个。 |
登记登录客户端
在哪里做:测试台
登记网页登录客户端,得到 client_secret。程序用 code 换令牌时带 client_id 和 client_secret。
在测试台点本步的 Client Register 标签,按下面的值填表(id 用前面步骤返回的),点 Send request。
填表的值:
{
"request_ref": "register-bim-web",
"client_id": "bim-web",
"product_id": "b41d9a70-6c2e-4a0f-8e7d-52a9c3f0d614",
"deployment_version": "v1",
"display_name": "BIM",
"kind": "confidential_web",
"auth_method": "client_secret_basic",
"redirect_uris": [
"https://bim.example.com/login/oauth2/code/bes",
"https://bim.example.com/tenant/chosen"
],
"post_logout_redirect_uris": [
"https://bim.example.com/"
],
"scopes": [
"openid",
"profile",
"email"
],
"token_profile": "web",
"dpop_required": false
}
返回:
{
"client_id": "bim-web",
"display_name": "BIM",
"kind": "confidential_web",
"auth_method": "client_secret_basic",
"redirect_uris": [
"https://bim.example.com/login/oauth2/code/bes",
"https://bim.example.com/tenant/chosen"
],
"post_logout_redirect_uris": [
"https://bim.example.com/"
],
"backchannel_logout_uri": null,
"scopes": [
"openid",
"profile",
"email"
],
"dpop_required": false,
"token_profile": "web",
"service_account_id": null,
"status": "pending",
"version": 0,
"product_id": "b41d9a70-6c2e-4a0f-8e7d-52a9c3f0d614",
"public_key_jwk": null,
"deployment_version": "v1",
"client_secret": "<client_secret>"
}
登录客户端的取值:
redirect_uris、post_logout_redirect_uris:程序实际用的 HTTPS 地址,逐字符一致,包括路径和结尾的斜杠。scopes:从openid、profile、email里选,必须有openid。client_secret只在这次返回里出现,放进部署的密钥存储。- 返回的
status是pending,下一步启用。
错误:
| HTTP | 错误码 | 处理 |
|---|---|---|
| 409 | CLIENT_ID_CONFLICT |
这个 client_id 已被占用,换一个。 |
| 400 | CLIENT_CONFIGURATION_INVALID |
按上面的取值规则改参数后重发。 |
启用登录客户端
在哪里做:测试台
把登录客户端改成 active,得到可用的登录客户端。用户从此能通过它登录。
在测试台点本步的 Client Set Status 标签,按下面的值填表(id 用前面步骤返回的),点 Send request。
填表的值:
{
"client_id": "bim-web",
"request_ref": "activate-bim-web",
"product_id": "b41d9a70-6c2e-4a0f-8e7d-52a9c3f0d614",
"status": "active"
}
返回:
{
"client_id": "bim-web",
"display_name": "BIM",
"kind": "confidential_web",
"auth_method": "client_secret_basic",
"redirect_uris": [
"https://bim.example.com/login/oauth2/code/bes",
"https://bim.example.com/tenant/chosen"
],
"post_logout_redirect_uris": [
"https://bim.example.com/"
],
"backchannel_logout_uri": null,
"scopes": [
"openid",
"profile",
"email"
],
"dpop_required": false,
"token_profile": "web",
"service_account_id": null,
"status": "active",
"version": 1,
"product_id": "b41d9a70-6c2e-4a0f-8e7d-52a9c3f0d614",
"public_key_jwk": null,
"deployment_version": "v1"
}
创建服务账号
在哪里做:测试台
创建服务账号,得到 service_account_id。生成证书请求时写进证书。
在测试台点本步的 Service Account Create 标签,按下面的值填表(id 用前面步骤返回的),点 Send request。
填表的值:
{
"request_ref": "create-bim-server-account",
"product_id": "b41d9a70-6c2e-4a0f-8e7d-52a9c3f0d614",
"display_name": "BIM server"
}
返回:
{
"service_account_id": "9d2c4b7e-1f35-4a68-b0c9-6e8a3f5d2c17",
"registered_client_id": "svc-9d2c4b7e-1f35-4a68-b0c9-6e8a3f5d2c17",
"display_name": "BIM server",
"certificate_fingerprint": null,
"not_before": null,
"not_after": null,
"previous_fingerprint": null,
"previous_fingerprint_valid_until": null,
"status": "pending",
"version": 0,
"product_id": "b41d9a70-6c2e-4a0f-8e7d-52a9c3f0d614",
"control_role_id": "2e7f9a04-5c1b-4d83-a6f2-0b9c8d7e1a35",
"role_id": "c58a1d63-9f02-47be-8e14-3a6d5b0f7c29"
}
- 产品必须是
active。 - 服务账号带 Control 的
product-runtime角色,只能调验证登录状态这一类接口,不能调管理接口。 - 返回的
status是pending,登记证书后再启用。
生成私钥和证书请求
在哪里做:产品的服务器
跑一条 openssl 命令,得到私钥 bim-service-key.pem 和证书请求 bim-service.csr。证书请求拿去签发;私钥留在产品的服务器上,不发给任何人。
CERT_DIR="$HOME/bes-certs/bim" mkdir -p "$CERT_DIR" SERVICE_ACCOUNT_ID=9d2c4b7e-1f35-4a68-b0c9-6e8a3f5d2c17 # 换成上一步返回的 service_account_id openssl req -new -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes \ -subj "/CN=BIM server" \ -addext "subjectAltName=URI:urn:bes:service:$SERVICE_ACCOUNT_ID" \ -addext "basicConstraints=critical,CA:FALSE" \ -addext "keyUsage=critical,digitalSignature" \ -addext "extendedKeyUsage=clientAuth" \ -keyout "$CERT_DIR/bim-service-key.pem" -out "$CERT_DIR/bim-service.csr"
- 证书唯一的 URI SAN 是
urn:bes:service:<service_account_id>。 - 私钥
bim-service-key.pem放进部署的密钥存储。
签发证书
在哪里做:客户端 CA 所在的机器
用客户端 CA 给证书请求签名,得到证书 bim-service.pem。证书拷回产品的服务器,和私钥一起用来调 Control。
前提:OpenSSL 3.0 及以上,openssl version 显示 OpenSSL 3.x。Mac 自带的 /usr/bin/openssl 是 LibreSSL,不认 -copy_extensions:先执行 brew install openssl@3,再执行 export PATH="$(brew --prefix openssl@3)/bin:$PATH"。
本环境(test)
CA_DIR:待定,test 环境的客户端 CA 位置未定。- 签发前把
bim-service.csr拷到客户端 CA 所在机器的~/bes-certs/bim/;签发后把bim-service.pem拷回产品的服务器的同一目录,和私钥放在一起。
CERT_DIR="$HOME/bes-certs/bim" mkdir -p "$CERT_DIR" CA_DIR="待定" openssl x509 -req -days 825 -sha256 -in "$CERT_DIR/bim-service.csr" \ -CA "$CA_DIR/client-ca.pem" -CAkey "$CA_DIR/client-ca-key.pem" \ -CAserial "$CERT_DIR/bim-service.srl" -CAcreateserial -copy_extensions copy \ -out "$CERT_DIR/bim-service.pem"
其他环境
local
本地的客户端 CA 在本机,证书请求不用拷,在本机执行:
CERT_DIR="$HOME/bes-certs/bim"
mkdir -p "$CERT_DIR"
CA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/bes/local/stack/secrets/control"
openssl x509 -req -days 825 -sha256 -in "$CERT_DIR/bim-service.csr" \
-CA "$CA_DIR/client-ca.pem" -CAkey "$CA_DIR/client-ca-key.pem" \
-CAserial "$CERT_DIR/bim-service.srl" -CAcreateserial -copy_extensions copy \
-out "$CERT_DIR/bim-service.pem"
staging
CA_DIR:待定,staging 环境的客户端 CA 位置未定。- 签发前把
bim-service.csr拷到客户端 CA 所在机器的~/bes-certs/bim/;签发后把bim-service.pem拷回产品的服务器的同一目录,和私钥放在一起。
CERT_DIR="$HOME/bes-certs/bim" mkdir -p "$CERT_DIR" CA_DIR="待定" openssl x509 -req -days 825 -sha256 -in "$CERT_DIR/bim-service.csr" \ -CA "$CA_DIR/client-ca.pem" -CAkey "$CA_DIR/client-ca-key.pem" \ -CAserial "$CERT_DIR/bim-service.srl" -CAcreateserial -copy_extensions copy \ -out "$CERT_DIR/bim-service.pem"
prod
CA_DIR:待定,prod 环境的客户端 CA 位置未定。- 签发前把
bim-service.csr拷到客户端 CA 所在机器的~/bes-certs/bim/;签发后把bim-service.pem拷回产品的服务器的同一目录,和私钥放在一起。
CERT_DIR="$HOME/bes-certs/bim" mkdir -p "$CERT_DIR" CA_DIR="待定" openssl x509 -req -days 825 -sha256 -in "$CERT_DIR/bim-service.csr" \ -CA "$CA_DIR/client-ca.pem" -CAkey "$CA_DIR/client-ca-key.pem" \ -CAserial "$CERT_DIR/bim-service.srl" -CAcreateserial -copy_extensions copy \ -out "$CERT_DIR/bim-service.pem"
算指纹和有效期
在哪里做:产品的服务器
从证书算出指纹和起止时间,得到三个值。下一步登记证书时填进 certificate_fingerprint、certificate_not_before、certificate_expires_at。
CERT_DIR="$HOME/bes-certs/bim"
openssl x509 -in "$CERT_DIR/bim-service.pem" -outform DER | openssl dgst -sha256 | awk '{print $NF}'
openssl x509 -in "$CERT_DIR/bim-service.pem" -noout -startdate -enddate
- 第一行输出是
certificate_fingerprint:证书 DER 编码的 SHA-256,64 位十六进制。 notBefore=后的时间是certificate_not_before,notAfter=后的是certificate_expires_at,改写成 RFC 3339 UTC,例如Jan 1 08:00:00 2026 GMT写成2026-01-01T08:00:00Z。
登记证书
在哪里做:测试台
把指纹和有效期登记到服务账号上,得到登记了证书的账号。之后 Control 认得这张证书。
POSTService Account Register在测试台点本步的 Service Account Register 标签,按下面的值填表(id 用前面步骤返回的),点 Send request。
填表的值:
{
"service_account_id": "9d2c4b7e-1f35-4a68-b0c9-6e8a3f5d2c17",
"request_ref": "register-bim-server-certificate",
"product_id": "b41d9a70-6c2e-4a0f-8e7d-52a9c3f0d614",
"display_name": "BIM server",
"certificate_fingerprint": "<certificate_fingerprint>",
"certificate_not_before": "2026-01-01T08:00:00Z",
"certificate_expires_at": "2028-04-05T08:00:00Z"
}
返回:
{
"service_account_id": "9d2c4b7e-1f35-4a68-b0c9-6e8a3f5d2c17",
"registered_client_id": "svc-9d2c4b7e-1f35-4a68-b0c9-6e8a3f5d2c17",
"display_name": "BIM server",
"certificate_fingerprint": "<certificate_fingerprint>",
"not_before": "2026-01-01T08:00:00+00:00",
"not_after": "2028-04-05T08:00:00+00:00",
"previous_fingerprint": null,
"previous_fingerprint_valid_until": null,
"status": "pending",
"version": 1,
"product_id": "b41d9a70-6c2e-4a0f-8e7d-52a9c3f0d614",
"control_role_id": "2e7f9a04-5c1b-4d83-a6f2-0b9c8d7e1a35",
"tenant_id": null,
"project_id": null,
"role_id": "c58a1d63-9f02-47be-8e14-3a6d5b0f7c29"
}
product_id、display_name与创建服务账号时相同。certificate_fingerprint、certificate_not_before、certificate_expires_at取上一步算出的值。
错误:
| HTTP | 错误码 | 处理 |
|---|---|---|
| 400 | CERTIFICATE_FINGERPRINT_INVALID |
指纹要 64 位十六进制,重新计算。 |
| 409 | CERTIFICATE_FINGERPRINT_CONFLICT |
这张证书已登记给别的账号,重新生成证书。 |
启用服务账号
在哪里做:测试台
把服务账号改成 active,得到可用的服务账号。程序从此能带证书调 Control。
在测试台点本步的 Service Account Set Status 标签,按下面的值填表(id 用前面步骤返回的),点 Send request。
填表的值:
{
"service_account_id": "9d2c4b7e-1f35-4a68-b0c9-6e8a3f5d2c17",
"request_ref": "activate-bim-server-account",
"product_id": "b41d9a70-6c2e-4a0f-8e7d-52a9c3f0d614",
"status": "active"
}
返回:
{
"service_account_id": "9d2c4b7e-1f35-4a68-b0c9-6e8a3f5d2c17",
"registered_client_id": "svc-9d2c4b7e-1f35-4a68-b0c9-6e8a3f5d2c17",
"display_name": "BIM server",
"certificate_fingerprint": "<certificate_fingerprint>",
"not_before": "2026-01-01T08:00:00+00:00",
"not_after": "2028-04-05T08:00:00+00:00",
"previous_fingerprint": null,
"previous_fingerprint_valid_until": null,
"status": "active",
"version": 2,
"product_id": "b41d9a70-6c2e-4a0f-8e7d-52a9c3f0d614",
"control_role_id": "2e7f9a04-5c1b-4d83-a6f2-0b9c8d7e1a35",
"tenant_id": null,
"project_id": null,
"role_id": "c58a1d63-9f02-47be-8e14-3a6d5b0f7c29"
}
- 没登记证书或证书已过期时不能启用。
status: disabled停用账号;停用的账号不能再启用,要新建一个。
做完这一部分,手里有:product_id、client_secret、证书和私钥。client_secret 让用户能登录,证书和私钥用来调 Control。
登记功能
在哪里做:测试台
把程序的全部路由登记成功能,得到功能清单。验证请求时 Control 按它找到功能码。
PUTFeature Register在测试台点本步的 Feature Register 标签,按下面的值填表(id 用前面步骤返回的),点 Send request。
填表的值:
{
"product_id": "b41d9a70-6c2e-4a0f-8e7d-52a9c3f0d614",
"deployment_version": "v1",
"request_ref": "register-bim-features-v1",
"features": [
{
"code": "document.read",
"route_template": "/documents/{id}",
"method": "GET",
"risk": "low",
"display_name": "Read documents"
},
{
"code": "document.write",
"route_template": "/documents",
"method": "POST",
"risk": "low",
"display_name": "Write documents"
},
{
"code": "payment.approve",
"route_template": "/payments/{id}/approve",
"method": "POST",
"risk": "high",
"display_name": "Approve payments"
}
]
}
返回:
[
{
"feature_id": "0a5d8e3c-9b14-4f72-a6c1-e3b7d2f05948",
"product_id": "b41d9a70-6c2e-4a0f-8e7d-52a9c3f0d614",
"code": "document.read",
"route_template": "/documents/{id}",
"method": "GET",
"risk": "low",
"display_name": "Read documents",
"deployment_version": "v1",
"status": "active"
},
{
"feature_id": "1b6e9f4d-a025-4083-b7d2-f4c8e3a16a59",
"product_id": "b41d9a70-6c2e-4a0f-8e7d-52a9c3f0d614",
"code": "document.write",
"route_template": "/documents",
"method": "POST",
"risk": "low",
"display_name": "Write documents",
"deployment_version": "v1",
"status": "active"
},
{
"feature_id": "2c7fa05e-b136-4194-88e3-05d9f4b27b6a",
"product_id": "b41d9a70-6c2e-4a0f-8e7d-52a9c3f0d614",
"code": "payment.approve",
"route_template": "/payments/{id}/approve",
"method": "POST",
"risk": "high",
"display_name": "Approve payments",
"deployment_version": "v1",
"status": "active"
}
]
- 一个功能是一组
method+route_template,code登记后不再改。 - 每次调用替换这个
deployment_version的整份清单,所以每次都发全部功能。 risk: high的路由,权限结果不走缓存。- 程序验证时发的
deployment_version下没有的路由,返回TARGET_UNKNOWN。
定义角色
在哪里做:测试台
把几个功能码组成一个角色,得到 role_id。邀请人、分配角色时填它。
在测试台点本步的 Role Define 标签,按下面的值填表(id 用前面步骤返回的),点 Send request。
填表的值:
{
"product_id": "b41d9a70-6c2e-4a0f-8e7d-52a9c3f0d614",
"code": "document-controller",
"request_ref": "define-document-controller",
"display_name": "Document controller",
"feature_codes": [
"document.read",
"document.write"
]
}
返回:
{
"role_id": "5c2f8a1d-6e93-4b07-8c4a-d1e6f3b92a75",
"product_id": "b41d9a70-6c2e-4a0f-8e7d-52a9c3f0d614",
"code": "document-controller",
"display_name": "Document controller",
"scope_level": "project",
"feature_codes": [
"document.read",
"document.write"
],
"status": "active",
"tenant_id": null,
"project_id": null,
"version": 0
}
- 按
code创建或整体替换一个角色。 feature_codes只能写本产品已登记且active的功能码。
错误:
| HTTP | 错误码 | 处理 |
|---|---|---|
| 400 | ROLE_CONFIGURATION_INVALID |
feature_codes 有没登记的功能码,先登记功能。 |
做完这一部分,手里有:功能清单和 role_id。邀请人、给人分配角色时填 role_id。
用证书调第一个接口
在哪里做:产品的服务器
用证书和私钥发一次不带用户令牌的验证请求,得到 403。说明证书和服务账号已被认出,之后验证请求都带这张证书。
- 返回
403,响应头X-BES-Auth-Reason: access_denied,没有响应体:证书已通过(Control 认过证书后,因为没有用户令牌和租户而拒绝)。 - 返回
401,没有响应体(Control 的错误码CALLER_AUTHENTICATION_FAILED):证书不是登记的那张,或服务账号没启用。 - 地址:带证书的接口发到
https://id.test.globalbes.sg:9443,只带用户令牌的接口发到https://id.test.globalbes.sg。 - 证书:TLS 客户端证书
bim-service.pem、私钥bim-service-key.pem,用服务端 CA 验证 Control 的证书。 - 写操作:请求体带
request_ref,同一个request_ref重发返回第一次的结果。 X-BES-Operator:可选,写代表哪个人操作的person_id。- 出错时返回
{"error": {"code": …, "reference": …, "message": …}},程序按code分支。
本环境(test)
SERVER_CA:待定,test 环境验证 Control 服务端证书的 CA 文件未定。
在产品的服务器上执行:
CERT_DIR="$HOME/bes-certs/bim" PRODUCT_ID=b41d9a70-6c2e-4a0f-8e7d-52a9c3f0d614 # 换成创建产品时返回的 product_id SERVER_CA="待定" curl -i --cert "$CERT_DIR/bim-service.pem" --key "$CERT_DIR/bim-service-key.pem" \ --cacert "$SERVER_CA" \ -H "X-BES-Product: $PRODUCT_ID" \ -H "X-Original-Method: GET" \ -H "X-Original-URI: /documents/42" \ -H "X-BES-Deployment-Version: v1" \ "https://id.test.globalbes.sg:9443/internal/auth/validate"
其他环境
local
在本机执行:
CERT_DIR="$HOME/bes-certs/bim"
PRODUCT_ID=b41d9a70-6c2e-4a0f-8e7d-52a9c3f0d614 # 换成创建产品时返回的 product_id
SERVER_CA="${XDG_DATA_HOME:-$HOME/.local/share}/bes/local/stack/secrets/stack/server-ca.pem"
curl -i --cert "$CERT_DIR/bim-service.pem" --key "$CERT_DIR/bim-service-key.pem" \
--cacert "$SERVER_CA" \
-H "X-BES-Product: $PRODUCT_ID" \
-H "X-Original-Method: GET" \
-H "X-Original-URI: /documents/42" \
-H "X-BES-Deployment-Version: v1" \
"https://id.local.globalbes.sg:19443/internal/auth/validate"
staging
SERVER_CA:待定,staging 环境验证 Control 服务端证书的 CA 文件未定。
在产品的服务器上执行:
CERT_DIR="$HOME/bes-certs/bim" PRODUCT_ID=b41d9a70-6c2e-4a0f-8e7d-52a9c3f0d614 # 换成创建产品时返回的 product_id SERVER_CA="待定" curl -i --cert "$CERT_DIR/bim-service.pem" --key "$CERT_DIR/bim-service-key.pem" \ --cacert "$SERVER_CA" \ -H "X-BES-Product: $PRODUCT_ID" \ -H "X-Original-Method: GET" \ -H "X-Original-URI: /documents/42" \ -H "X-BES-Deployment-Version: v1" \ "待定/internal/auth/validate"
prod
SERVER_CA:待定,prod 环境验证 Control 服务端证书的 CA 文件未定。
在产品的服务器上执行:
CERT_DIR="$HOME/bes-certs/bim" PRODUCT_ID=b41d9a70-6c2e-4a0f-8e7d-52a9c3f0d614 # 换成创建产品时返回的 product_id SERVER_CA="待定" curl -i --cert "$CERT_DIR/bim-service.pem" --key "$CERT_DIR/bim-service-key.pem" \ --cacert "$SERVER_CA" \ -H "X-BES-Product: $PRODUCT_ID" \ -H "X-Original-Method: GET" \ -H "X-Original-URI: /documents/42" \ -H "X-BES-Deployment-Version: v1" \ "待定/internal/auth/validate"
做完这一部分,手里有:确认证书已被 Control 认出,之后验证请求都带这张证书。
把用户跳到登录页
在哪里做:产品代码
用户没登录时,程序把浏览器跳到 BES 登录页。登录完,浏览器带着 code 回到程序,下一步用它换令牌。
GET https://id.test.globalbes.sg/authorize?response_type=code&client_id=bim-web&redirect_uri=https%3A%2F%2Fbim.example.com%2Flogin%2Foauth2%2Fcode%2Fbes&scope=openid%20profile%20email&state=<state>&nonce=<nonce>&code_challenge=<code_challenge>&code_challenge_method=S256
- 端点地址从
GET https://id.test.globalbes.sg/.well-known/openid-configuration读。 state、nonce每次随机生成,存在用户会话里。code_challenge= BASE64URL(SHA256(code_verifier)),只支持S256;以上参数缺一个就被拒。- 回调收到
?code=…&state=…;code60 秒内有效,只能用一次。
Java(Spring Boot)
spring:
security:
oauth2:
client:
registration:
bes:
client-id: ${BES_CLIENT_ID}
client-secret: ${BES_CLIENT_SECRET}
authorization-grant-type: authorization_code
client-authentication-method: client_secret_basic
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
scope: openid, profile, email
provider:
bes:
issuer-uri: ${BES_ISSUER}
@Configuration
@EnableWebSecurity
class SecurityConfig {
@Bean
SecurityFilterChain security(HttpSecurity http, ClientRegistrationRepository clients) throws Exception {
// BES requires PKCE from every client; Spring only adds it for public clients by default.
var resolver = new DefaultOAuth2AuthorizationRequestResolver(clients, "/oauth2/authorization");
resolver.setAuthorizationRequestCustomizer(OAuth2AuthorizationRequestCustomizers.withPkce());
http.authorizeHttpRequests(a -> a.anyRequest().authenticated())
.oauth2Login(o -> o.authorizationEndpoint(e -> e.authorizationRequestResolver(resolver)))
.oauth2Client(Customizer.withDefaults());
return http.build();
}
}
用 code 换令牌
在哪里做:产品代码
用 code 和 client_secret 换令牌,得到 access_token 和 refresh_token。存在服务端会话里,之后每个请求和刷新都要用。
POST https://id.test.globalbes.sg/oauth/token Authorization: Basic <base64(client_id:client_secret)> Content-Type: application/x-www-form-urlencoded grant_type=authorization_code&code=<code>&code_verifier=<code_verifier>&redirect_uri=https%3A%2F%2Fbim.example.com%2Flogin%2Foauth2%2Fcode%2Fbes
HTTP/1.1 200 OK
{
"access_token": "<access_token>",
"id_token": "<id_token>",
"refresh_token": "<refresh_token>",
"token_type": "Bearer",
"expires_in": 300,
"scope": "openid profile email"
}
access_token、refresh_token:存在服务端的用户会话里,浏览器只拿会话 cookie。access_token有效 5 分钟。id_token里的sub是这个人的person_id,长期不变,用作关联自己用户数据的键。
让用户选租户
在哪里做:产品代码
带用户的 access_token 发起选租户,用户选好后程序得到 context_id。存进会话,每次验证请求时带上。
程序里发这个请求(id 用前面步骤返回的):
带:证书 + 用户的 access_token
请求:
POST https://id.test.globalbes.sg:9443/api/sessions/tenant-selection
Authorization: Bearer <access_token>
Content-Type: application/json
{
"continue_url": "https://bim.example.com/tenant/chosen"
}
返回:
HTTP/1.1 201 Created
{
"selection_id": "9c4e1b7a-2d58-4f3c-b6a0-e81d5f2c7a94",
"selection_url": "/tenants?selection=9c4e1b7a-2d58-4f3c-b6a0-e81d5f2c7a94",
"expires_at": "2026-01-01T08:05:00+00:00"
}
continue_url必须与登录客户端登记的redirect_uris或post_logout_redirect_uris之一完全一致。- 把浏览器跳到
https://id.test.globalbes.sg+selection_url。 - 用户选好租户后,浏览器回到
https://bim.example.com/tenant/chosen?context_id=0f6b8d2e-4a17-4c93-9e5b-a1d27c4f3b68;把context_id按 UUID 解析后存进服务端会话。 - 回调带
error=login_required时,重新登录再发起选择。 - 选择 5 分钟内有效;同一会话再发起一次会取消上一次。
- 切换租户:再调一次这个接口。
错误:
| HTTP | 错误码 | 处理 |
|---|---|---|
| 401 | LOGIN_REQUIRED |
BES 登录已结束,重新登录后再发起。 |
Java(Spring Boot)
spring:
ssl:
bundle:
pem:
bes-control:
keystore:
certificate: file:/run/secrets/bes-service-cert.pem
private-key: file:/run/secrets/bes-service-key.pem
truststore:
certificate: file:/run/secrets/bes-gateway-ca.pem
bes:
issuer: ${BES_ISSUER}
control-gateway: ${BES_CONTROL_GATEWAY} # the mTLS address of Control, not the issuer
@Bean
RestClient besControl(RestClient.Builder builder, SslBundles bundles,
@Value("${bes.control-gateway}") String gateway) {
var settings = ClientHttpRequestFactorySettings.ofSslBundle(bundles.getBundle("bes-control"));
return builder.baseUrl(gateway)
.requestFactory(ClientHttpRequestFactoryBuilder.detect().build(settings))
.build();
}
record SelectionStart(String continue_url) {}
record Selection(String selection_id, String selection_url, String expires_at) {}
String startTenantSelection(String accessToken) {
Selection selection = besControl.post().uri("/api/sessions/tenant-selection")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
.body(new SelectionStart(appBaseUrl + "/tenant/chosen"))
.retrieve().body(Selection.class);
return issuer + selection.selection_url(); // redirect the browser here
}
@GetMapping("/tenant/chosen")
String tenantChosen(@RequestParam("context_id") UUID contextId, HttpSession session) {
session.setAttribute("bes.context_id", contextId.toString());
return "redirect:/";
}
验证每个请求
在哪里做:产品代码
之后用户每发一个请求,程序带证书、access_token、context_id 问 Control,返回这个人的权限列表。程序按它放行或拒绝。
程序里发这个请求(id 用前面步骤返回的):
带:证书 + 用户的 access_token
请求:
POST https://id.test.globalbes.sg:9443/api/sessions/validate
Authorization: Bearer <access_token>
Content-Type: application/json
{
"context_id": "0f6b8d2e-4a17-4c93-9e5b-a1d27c4f3b68",
"target": {
"method": "GET",
"path": "/documents/42",
"deployment_version": "v1"
}
}
返回:
HTTP/1.1 200 OK
{
"context": "<signed bes-context+jwt>",
"checked_at": "2026-01-01T08:00:00.123456+00:00",
"hard_expiry": "2026-01-01T08:00:30.123456+00:00",
"action": "document.read",
"risk": "low"
}
channel_assurance说明身份怎么建立;是否按它限制动作由产品决定。- 每个受保护的请求在业务代码之前调一次。
target.method、target.path:程序收到的请求,不含查询串;deployment_version:登记功能清单时用的版本。- 有
context_id时发context_id;没有时改发target.tenant_id(项目级产品再加target.project_id),两者不同时发。 - 平台级服务账号还要发
product_id。 context是 Control 签名的 JWT(ES256,类型bes-context+jwt),用https://id.test.globalbes.sg/.well-known/jwks.json验证,aud是本产品的product_id。context的 claim:sub(person_id)、tenant_id、project_id、action(本次请求对应的功能码)、risk、features(此人在这个范围的全部功能码)。- 资源是否属于
tenant_id/project_id,程序自己核对。 - 同一用户、同一目标和动作可复用
context到hard_expiry(低风险 30 秒,高风险 5 秒)。 - Control 不可达、超时或返回
5xx时拒绝请求。
错误:
| HTTP | 错误码 | 处理 |
|---|---|---|
| 401 | INVALID_TOKEN |
刷新令牌后重试;刷新失败就重新登录。 |
| 401 | LOGIN_REQUIRED |
重新登录。 |
| 403 | ACCESS_DENIED |
返回 403。 |
| 403 | ENTITLEMENT_MISSING |
返回 403:租户没有有效的开通记录。 |
| 403 | TARGET_UNKNOWN |
把这条路由登记进这个版本的功能清单。 |
Java(Spring Boot)
record Target(String method, String path, String deployment_version) {}
record ValidateRequest(String context_id, Target target) {}
record Validated(String context, String checked_at, String hard_expiry, String action, String risk) {}
@Component
class BesAuthorizationFilter extends OncePerRequestFilter {
private final RestClient besControl;
private final ConfigurableJWTProcessor<SecurityContext> contexts; // see below
// constructor omitted
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String accessToken = currentAccessToken(request); // from the OAuth2AuthorizedClient
String contextId = (String) request.getSession().getAttribute("bes.context_id");
try {
Validated validated = besControl.post().uri("/api/sessions/validate")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
.body(new ValidateRequest(contextId,
new Target(request.getMethod(), request.getRequestURI(), "v1")))
.retrieve().body(Validated.class);
JWTClaimsSet claims = contexts.process(validated.context(), null);
request.setAttribute("bes.claims", claims);
chain.doFilter(request, response);
} catch (HttpClientErrorException.Unauthorized e) {
response.sendRedirect("/oauth2/authorization/bes"); // sign in again
} catch (HttpClientErrorException e) {
response.sendError(HttpServletResponse.SC_FORBIDDEN); // fail closed
} catch (Exception e) {
response.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE);
}
}
}
@Bean
ConfigurableJWTProcessor<SecurityContext> besContexts(@Value("${bes.issuer}") String issuer,
@Value("${bes.product-id}") String productId)
throws MalformedURLException {
var processor = new DefaultJWTProcessor<SecurityContext>();
processor.setJWSTypeVerifier(new DefaultJOSEObjectTypeVerifier<>(new JOSEObjectType("bes-context+jwt")));
processor.setJWSKeySelector(new JWSVerificationKeySelector<>(JWSAlgorithm.ES256,
JWKSourceBuilder.create(new URL(issuer + "/.well-known/jwks.json")).build()));
processor.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>(productId,
new JWTClaimsSet.Builder().issuer(issuer).build(), Set.of("sub", "exp", "features")));
return processor;
}
boolean canWrite = claims.getStringListClaim("features").contains("document.write");
nginx(gateway.validate)
nginx 用 auth_request 调 gateway.validate,产品从 X-BES-Context 读签名的 context,验证方法同上。Control 返回 200 和 X-BES-Context、401(重新登录)或带 X-BES-Auth-Reason(access_denied、target_unknown、entitlement_missing)的 403;其他状态和超时一律拒绝。
location = /_bes_validate {
internal;
proxy_pass ${CONTROL_INTERNAL_ORIGIN}/internal/auth/validate;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header Cookie "";
proxy_set_header Authorization $http_authorization;
proxy_set_header X-BES-TLS-Verified "";
proxy_set_header X-BES-Client-Cert "";
proxy_set_header X-BES-Operator "";
proxy_set_header X-BES-Product "${PRODUCT_ID}";
proxy_set_header X-BES-Context-Id $cookie_bes_context;
proxy_set_header X-BES-Tenant "";
proxy_set_header X-BES-Project "";
proxy_set_header X-Original-Method $request_method;
proxy_set_header X-Original-URI $request_uri;
proxy_set_header X-Original-Host $host;
proxy_set_header X-BES-Deployment-Version v1;
proxy_ssl_server_name on;
proxy_ssl_name ${CONTROL_HOST};
proxy_ssl_verify on;
proxy_ssl_trusted_certificate /run/secrets/control-ca;
proxy_ssl_certificate /run/secrets/gateway-cert;
proxy_ssl_certificate_key /run/secrets/gateway-key;
}
location / {
auth_request /_bes_validate;
auth_request_set $bes_context $upstream_http_x_bes_context;
proxy_set_header X-BES-Context $bes_context;
proxy_pass ${PRODUCT_UPSTREAM};
}
刷新令牌
在哪里做:产品代码
access_token 过期前用 refresh_token 换一对新令牌。两个都存下,下次请求和刷新用新的。
POST https://id.test.globalbes.sg/oauth/token Authorization: Basic <base64(client_id:client_secret)> Content-Type: application/x-www-form-urlencoded grant_type=refresh_token&refresh_token=<refresh_token>
- 返回新的
access_token和refresh_token,两个都存下。 - 每个
refresh_token只能用一次;旧的再用一次,整个会话被撤销。同一会话的刷新要串行。 - 网页会话空闲 2 小时或总共 12 小时后结束,刷新也不延长。
做完这一部分,手里有:用户登录后的令牌和 context_id,每个请求都有 Control 签名的权限列表。
发起再确认
在哪里做:产品代码
用户要做高风险动作时,程序发起再确认,得到 challenge_id 和确认页地址。把浏览器跳过去让用户再确认身份。
程序里发这个请求(id 用前面步骤返回的):
带:证书 + 用户的 access_token
请求:
POST https://id.test.globalbes.sg:9443/api/sessions/step-up
Authorization: Bearer <access_token>
Content-Type: application/json
{
"request_ref": "payment-7731-approve",
"purpose": "remote_action",
"action_digest": "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=",
"context_id": "0f6b8d2e-4a17-4c93-9e5b-a1d27c4f3b68"
}
返回:
HTTP/1.1 201 Created
{
"challenge_id": "d3a61f08-9b2c-4e75-8f04-6c1e7a5b2d39",
"completion_url": "/step-up?challenge=d3a61f08-9b2c-4e75-8f04-6c1e7a5b2d39",
"expires_at": "2026-01-01T08:05:00+00:00"
}
action_digest:动作规范描述的 SHA-256,标准 base64,例如对approve-payment|7731|12500.00|SGD计算。request_ref:原请求的编号;同一个request_ref重发返回同一个 challenge。- 把浏览器跳到
https://id.test.globalbes.sg+completion_url;确认页不跳回,程序页面保持打开,用户回来后点「继续」。
换取许可
在哪里做:产品代码
用户确认后,程序用 challenge_id 换许可,得到 5 秒内有效的 permit_id。在执行动作的同一个事务里核对它。
程序里发这个请求(id 用前面步骤返回的):
带:证书 + 用户的 access_token
请求:
POST https://id.test.globalbes.sg:9443/api/sessions/step-up/consume
Authorization: Bearer <access_token>
Content-Type: application/json
{
"request_ref": "payment-7731-approve",
"challenge_id": "d3a61f08-9b2c-4e75-8f04-6c1e7a5b2d39",
"action_digest": "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg="
}
返回:
HTTP/1.1 200 OK
{
"permit_id": "a8f2c6d1-5e39-4b07-9d4c-2f7b1e6a3c85",
"challenge_id": "d3a61f08-9b2c-4e75-8f04-6c1e7a5b2d39",
"purpose": "remote_action",
"action_digest": "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=",
"assurance": "password",
"amr": [
"pwd"
],
"authenticated_at": "2026-01-01T08:01:10+00:00",
"issued_at": "2026-01-01T08:01:12+00:00",
"expires_at": "2026-01-01T08:01:17+00:00"
}
request_ref、action_digest与发起时相同;action_digest按即将修改的数据重新计算。- 紧挨着执行动作之前调用。
- 在执行动作的同一个数据库事务里:核对
expires_at未到、action_digest等于本次动作的摘要,把permit_id插进有唯一约束的表,一个许可只做一次动作。 - 重复调用返回同一个许可。
错误:
| HTTP | 错误码 | 处理 |
|---|---|---|
| 403 | STEP_UP_INVALID |
值与发起时不一致:重新发起再确认。 |
| 410 | STEP_UP_EXPIRED |
重新发起再确认。 |
Java(Spring Boot)
record StepUpRequest(String request_ref, String purpose, String action_digest, String context_id) {}
record Challenge(String challenge_id, String completion_url, String expires_at) {}
record StepUpConsume(String request_ref, String challenge_id, String action_digest) {}
record Permit(String permit_id, String action_digest, String assurance, String expires_at) {}
String digest(String canonicalAction) throws NoSuchAlgorithmException {
byte[] hash = MessageDigest.getInstance("SHA-256").digest(canonicalAction.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(hash);
}
Permit consume(String accessToken, String requestRef, String challengeId, String digest) {
return besControl.post().uri("/api/sessions/step-up/consume")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
.body(new StepUpConsume(requestRef, challengeId, digest))
.retrieve().body(Permit.class);
}
做完这一部分,手里有:一个 5 秒内有效、只能用一次的 permit_id,执行这一个高风险动作时核对。
结束登录
在哪里做:产品代码
用户退出时,程序带 access_token 结束在 Control 里的登录,手里的令牌失效。接着结束浏览器里的登录。
程序里发这个请求(id 用前面步骤返回的):
带:用户的 access_token
请求:
POST https://id.test.globalbes.sg/api/sessions/logout
Authorization: Bearer <access_token>
Content-Type: application/json
{
"login_scope": "product"
}
返回:
HTTP/1.1 204 No Content
login_scope: product(默认)登出本产品所有登录客户端,client只登出这一个。- 先调这个接口,再清掉自己的会话。
access_token过期时先刷新;刷新不了就直接做下一步。- 登录客户端登记了
backchannel_logout_uri时,别处结束会话后 Control 向它 POST 签名的logout_token(表单字段,claimsid);收到后结束这个sid的本地会话。
Java(Spring Boot)
@PostMapping("/logout")
String logout(HttpSession session, @RegisteredOAuth2AuthorizedClient("bes") OAuth2AuthorizedClient client) {
RestClient.create(issuer).post().uri("/api/sessions/logout")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + client.getAccessToken().getTokenValue())
.body(Map.of("login_scope", "product"))
.retrieve().toBodilessEntity();
session.invalidate();
return "redirect:" + issuer + "/end_session?client_id=" + clientId
+ "&post_logout_redirect_uri=" + URLEncoder.encode(appBaseUrl + "/", StandardCharsets.UTF_8)
+ "&state=" + UUID.randomUUID();
}
结束浏览器登录
在哪里做:产品代码
把浏览器跳到 BES 的退出地址,结束 BES 在浏览器里的登录,然后回到程序的首页。
GETEnd sessionGET https://id.test.globalbes.sg/end_session?client_id=bim-web&post_logout_redirect_uri=https%3A%2F%2Fbim.example.com%2F&state=<state>
- BES 显示确认页,然后回到
post_logout_redirect_uri;这个地址必须登记在登录客户端上,逐字符一致。
做完这一部分,手里有:Control 和浏览器里的登录都已结束,手里的令牌失效。
登记接收地址
在哪里做:Control 服务器
在 Control 的部署配置里写上程序的接收地址和要收的事件,得到一个登记的接收方。Control 之后把事件发到这个地址。
没有接口:写进 Control 部署配置的 event_consumers(环境变量 BES_CONTROL_EVENT_CONSUMERS),JSON 对象,键是接收方名字:
{
"bim": {
"endpoint": "https://bim.example.com/bes/events",
"audience": "bim-api",
"event_types": [
"membership.revoked",
"role_assignment.changed"
]
}
}
event_types取契约仓events/control/下的事件名:membership.revoked、role.changed、role_assignment.changed、entitlement.changed、person.status_changed等。
本环境(test)
- Control 部署配置的位置和改完后怎样生效:待定,test 环境的 Control 部署方式未定。
其他环境
local
本地 Control 的配置在 bes-infra 仓 envs/local/compose.yaml:BES_CONTROL_EVENT_CONSUMERS 里已有 ./envs/local/dev stack up 配好的接收方 event-consumer-stub。用编辑器把 bim 加进同一个 JSON 对象,再起一次栈让 Control 读到新配置:
BES_INFRA="$HOME/Projects/bes-infra" # bes-infra 仓的路径 "$BES_INFRA/envs/local/dev" stack up
- 本地 Control 只信任栈的服务端 CA(
~/.local/share/bes/local/stack/secrets/stack/server-ca.pem),接收地址要能从栈的容器里访问,证书由这张 CA 签发。
staging
- Control 部署配置的位置和改完后怎样生效:待定,staging 环境的 Control 部署方式未定。
prod
- Control 部署配置的位置和改完后怎样生效:待定,prod 环境的 Control 部署方式未定。
验证收到的事件
在哪里做:产品代码
程序收到事件后验证签名,按 event_id 存下再回 2xx,得到自己表里的事件。之后从表里处理。
Control 每个事件发一个请求:
POST https://bim.example.com/bes/events Content-Type: application/jwt Idempotency-Key: 3e9a7c51-0b46-4d2f-9e83-a1c5f7d2b064 <event_jws>
- 请求体是 ES256 签名的 JWT,类型
bes-event+jwt,用https://id.test.globalbes.sg/.well-known/jwks.json验证;aud是登记的audience,5 分钟后过期。 - claim 是
common/event-envelope.json的信封:event_id、event_type、aggregate_type、aggregate_id、tenant_id、product_id、sequence、occurred_at、trace_id、payload。 - 先按
event_id(主键)存下再回2xx,之后从自己的表里处理;重复投递按event_id去重。 - 同一接收方的事件严格按顺序到达。没回
2xx时 Control 从 30 秒起重试到 24 小时、共 10 次;之后这次投递保持失败,后面的事件都停住。
Java(Spring Boot)
@PostMapping(path = "/bes/events", consumes = "application/jwt")
ResponseEntity<Void> receive(@RequestBody String jws) throws Exception {
JWTClaimsSet event = events.process(jws, null); // same setup as the context processor,
// type "bes-event+jwt", audience "my-product-api"
try {
jdbc.update("insert into bes_event_inbox(event_id, event_type, body) values (?, ?, ?::jsonb)",
event.getStringClaim("event_id"), event.getStringClaim("event_type"), event.toString());
} catch (DuplicateKeyException alreadyStored) {
// delivered before: acknowledge again
}
return ResponseEntity.noContent().build();
}
做完这一部分,手里有:Control 发来的事件存进程序自己的表,按 event_id 去重后处理。
加「Account settings」入口
在哪里做:产品代码
在用户菜单里加「Account settings」链接,得到打开 BES 账户页的入口。用户在那里改密码、两步验证等设置。
- 在产品的用户菜单里加「Account settings」链接,在新标签页或约 560×760 的小窗口打开
https://id.test.globalbes.sg/account。 - 不能放进 iframe:BES 页面拒绝被嵌入。
- 密码、两步验证、恢复码、已登录的浏览器、手机和邮箱、语言都在那里管理,产品不做这些页面。
- 用户菜单只留:当前用户、切换公司(
session.tenant-selection-start)、Account settings、登出(end_session)。
做完这一部分,手里有:用户菜单里的「Account settings」链接,打开 BES 的账户页。
登记助手访问目标
在哪里做:产品代码
在每个嵌挂件产品的完整功能目录里加入 assistant.chat,并授给允许使用助手的角色。
PUTFeature Register使用 feature.register 提交完整目录,保留原有功能,并加入 {code: assistant.chat, method: POST, route_template: /assistant/chat, risk: low, display_name: Assistant chat}。deployment_version 必须与挂件 validate 的 target 一致。
为宿主登记唯一有效的产品服务客户端,配置其部署版本和可委托的业务 scopes。Assistant 委托 scope 不能超出此客户端登记值或人员当前功能权限。
Assistant 授权参数 host_product_id 指向本产品,用户须先登录本产品;普通产品调用 session.validate / tenant-list 不传 product_id。
产品退出关闭本产品的 product_login;Control 挂件绑定检查状态与 generation,因此同一 SSO 的重新登录也不能恢复旧挂件 sid。
做完这一部分,手里有:产品目录与角色允许用户使用助手,产品退出使挂件失效。