CIBA 后端通道认证
CIBA(Client Initiated Backchannel Authentication)用于“发起方拿不到用户浏览器”的场景:例如柜台系统要为客户办理业务、呼叫中心要确认来电者身份。客户端在后端调用 wicket,wicket 记录一条待确认请求,宿主应用负责通知用户并记录用户的确认结果,客户端轮询令牌端点拿到令牌。
本页所有请求与响应均为 v0.1.4 上实际运行的结果,Go 片段均已编译并在实验程序中跑通。
支持范围
| 项目 | v0.1.4 状态 |
|---|---|
端点 POST /connect/ciba | 已注册 |
| 令牌投递模式 | 仅 poll(发现文档 backchannel_token_delivery_modes_supported: ["poll"]) |
ping / push | 不支持(ping 有通知代码但不在允许列表中且未经验证;push 未实现) |
| 用户确认 | 没有内置的交互服务,由宿主直接更新请求存储(见下文) |
id_token | 不签发:即使请求含 openid,令牌响应中 id_token 也为空字符串 |
| 刷新令牌 | 客户端 AllowOfflineAccess 且请求含 offline_access 时签发 |
1. 配置客户端
ciba := models.NewClient()
ciba.ClientId = "ciba-client"
secret := models.NewSecret()
secret.Value = utils.Sha256("ciba-secret")
ciba.ClientSecrets = []*models.Secret{secret}
ciba.AllowedScopes = []string{"openid", "orders.read"}
ciba.AllowOfflineAccess = true // 可选
if err := ciba.SetAllowedGrantTypes(wicket.GrantTypesBackchannelAuthentication); err != nil {
log.Fatal(err)
}
// 可选:覆盖全局默认值
// interval := 10; ciba.PollingInterval = &interval
// lifetime := 120; ciba.BackchannelAuthenticationLifetime = &lifetimeBackchannelTokenDeliveryMode 留空即为 poll。全局默认值在 WicketOptions.BackchannelAuthentication 中:
| 选项 | 默认值 | 说明 |
|---|---|---|
AllowedDeliveryModes | ["poll"] | 允许的投递模式 |
DefaultRequestLifetime | 300 秒 | 请求有效期;客户端的 requested_expiry 只能缩短它 |
DefaultPollingInterval | 5 秒 | 返回给客户端的 interval |
SlowDownIntervalIncrement | 5 秒 | 每次触发 slow_down 后该请求的轮询间隔增加量 |
BackchannelAuthentication.Enabled 在 v0.1.4 中没有任何代码读取,不能用它关闭 CIBA。
2. 替换默认的用户校验器
默认校验器不能用于生产
未注入时 wicket 使用的默认 BackchannelAuthenticationUserValidator 仅供开发联调:它不会到宿主用户库中解析或核验用户提示。生产环境请务必注入自己的 BackchannelAuthenticationUserValidator,只接受能解析为真实用户的提示;id_token_hint、login_hint_token 须先验签再使用。
注入自己的实现,把提示解析为宿主用户库中的真实用户:
// userValidator 把 login_hint 解析为宿主用户库中的真实用户。
type userValidator struct {
lookup func(ctx context.Context, hint string) (subjectID string, ok bool)
}
func (v *userValidator) Validate(ctx context.Context, c *validators_models.BackchannelAuthenticationUserValidationContext) error {
if c.LoginHint == "" {
// 本例不接受 id_token_hint / login_hint_token;要支持须先验签再取 sub
return errors.New("login_hint required")
}
sub, ok := v.lookup(ctx, c.LoginHint)
if !ok {
return errors.New("unknown user")
}
p, err := wicketmodels.NewLocalUser(wicketmodels.WithLocalUserSubjectId(sub)).CreatePrincipal()
if err != nil {
return err
}
c.Subject = p
return nil
}
cfg.SetBackchannelAuthenticationUserValidator(&userValidator{lookup: findUserByHint})Validate 返回任意错误时,端点统一回 invalid_request(实测对未知用户返回 {"error":"invalid_request","error_description":""})。
以 Subject 为准
v0.1.4 中入库记录的 SubjectId 字段不一定等于用户的 sub,请不要依赖它。Subject 才是校验器解析出的用户主体,令牌中的 sub 取自 Subject;宿主通知用户时请从 Subject 读取 sub。
3. 发起请求
curl -s -u ciba-client:ciba-secret \
-d "scope=openid orders.read" \
-d login_hint=alice@example.com \
-d "binding_message=Pay 42" \
http://localhost:5000/connect/ciba{"auth_req_id":"zUzdxlNeR9ql20ix3rJQ8W29kEw","expires_in":300,"interval":5}| 参数 | 说明 |
|---|---|
scope | 必需;缺失时返回 invalid_scope |
login_hint / login_hint_token / id_token_hint | 至少一个;全部缺失返回 invalid_request |
binding_message | 展示给用户、用于核对的短文本 |
user_code | 用户预先设置的口令,原样保存,是否校验由宿主决定 |
requested_expiry | 秒;只能比默认值短 |
resource | 资源指示器,可多个 |
客户端认证与令牌端点相同(client_secret_basic / client_secret_post)。未被允许 CIBA 授权类型的客户端返回 unauthorized_client。注意此端点的错误体没有令牌端点那样的 custom 字段。
4. 通知用户:包装请求存储
wicket 在请求校验通过后调用 BackchannelAuthenticationRequestStore.StoreBackchannelAuthenticationRequest 入库,此外不会通知任何人。最直接的接入点是包装这个存储,在入库成功后推送给用户:
type notifyingRequests struct {
storage.BackchannelAuthenticationRequestStore
notify func(req *models.BackchannelAuthenticationRequest)
}
func (s *notifyingRequests) StoreBackchannelAuthenticationRequest(ctx context.Context, id string, req *models.BackchannelAuthenticationRequest) error {
if err := s.BackchannelAuthenticationRequestStore.StoreBackchannelAuthenticationRequest(ctx, id, req); err != nil {
return err
}
s.notify(req) // req.RequestId、req.Subject、req.BindingMessage、req.RequestedScopes 可用
return nil
}
requests := ¬ifyingRequests{
BackchannelAuthenticationRequestStore: myStore, // 开发时可用 memory.NewMemoryBackchannelAuthenticationRequestStore()
notify: pushToUserDevice,
}
cfg.SetBackchannelAuthenticationRequestStore(requests)notify 在请求处理路径上同步执行,耗时操作请投递到宿主自己的队列。
5. 记录用户的确认结果
用户在 App 或站内页面上点击“同意 / 拒绝”后,宿主直接修改请求状态:
func decide(ctx context.Context, store storage.BackchannelAuthenticationRequestStore, requestID string, approve bool) error {
req, err := store.FindBackchannelAuthenticationRequest(ctx, requestID)
if err != nil {
return err
}
if req.Status != models.BackchannelAuthenticationRequestStatusPending {
return errors.New("request is no longer pending")
}
if approve {
req.Status = models.BackchannelAuthenticationRequestStatusApproved
} else {
req.Status = models.BackchannelAuthenticationRequestStatusDenied
}
return store.UpdateBackchannelAuthenticationRequest(ctx, requestID, req)
}确认页必须先验证“当前登录的用户就是 req.Subject 对应的用户”,再调用 decide。状态取值为 pending、approved、denied、consumed;令牌签发后 wicket 会把状态改为 consumed。
6. 客户端轮询
curl -s -u ciba-client:ciba-secret \
-d grant_type=urn:openid:params:grant-type:ciba \
-d auth_req_id=$AUTH_REQ_ID \
http://localhost:5000/connect/token各阶段的响应(“属于其他客户端”一项依据源码,其余均为实测):
| 时机 | 响应 |
|---|---|
| 用户尚未确认 | {"custom":{},"error":"authorization_pending","error_description":""} |
距上次轮询不足 interval 秒 | slow_down(该请求的间隔随之增加 SlowDownIntervalIncrement) |
| 用户已确认 | 200,含 access_token(sub 为确认的用户),id_token 为空字符串 |
| 用户已拒绝 | access_denied |
| 超过有效期 | expired_token |
已兑换过 / auth_req_id 属于其他客户端 / 不存在 | invalid_grant |
客户端应遵守返回的 interval,收到 slow_down 后把间隔加大 5 秒再试。
相关阅读
- 如何选择:CIBA 与设备授权的区别
- 存储端口:
BackchannelAuthenticationRequestStore约定 - ServerConfig:
SetBackchannelAuthenticationUserValidator等注入方法 - 刷新令牌