[开发]OIDC 双插件开发方案(oidc_provider / oidc_client)

👑Lv.11 元老 🌞 骨干会员
2026-10-26 00:23:33

本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:合并篇(见正文内 merged_from 列表)。

OIDC 双插件开发方案(oidc_provider / oidc_client)

本篇由 2 篇原始文档合并去重而成:开发方案 + 对外发布帖。

  • 状态:方案已冻结(D1–D6 已拍板,未开发,不含代码改动)
  • 目标:FlintHub 既能当 OIDC 服务端(IdP),也能当 OIDC 客户端(RP);两个独立插件,按需安装
  • 依据:docs/插件开发规范.md + 本仓 index.php / app/Core/Router.php / nginx-server.conf / .htaccess / plugins/third_party_login/ 实读结论

一、方案定位

维度`oidc_provider`(IdP 服务端)`oidc_client`(RP 客户端)
方向别人来 FlintHub 登录FlintHub 去外部 IdP 登录
出网无(不需要 net:fetch)有:discovery/token/jwks/userinfo,必须 net:fetch
入站被外部后端调用(公开端点,无会话)被用户浏览器调用(有会话 + state)
密钥自持 RSA 私钥(签名 id_token)只存第三方 client_secret
主要风险重定向注入、code 重放、密钥泄露SSRF、id_token 验签绕过、账号错误关联
依赖插件无可选复用 third_party_login 的绑定页交互范式(不共用代码)

  • oidc_provider 解决「当别人登录的入口」:把 FlintHub 账号变成标准 OIDC 身份源,供 Discourse / Ghost / WordPress 等外部程序登录。
  • oidc_client 解决「用别人登录」:对接 GitHub / Google / 另一个 FlintHub 站,用外部身份一键登录本站。
  • 为什么必须拆开:oidc_client 天然带 net:fetch 出网权限,oidc_provider 完全不需要;合并会让攻击面最敏感的 IdP 平白获得出网权,且 client 不该持有签名私钥。拆开是安全边界需求。两者可单独安装,也可一起装做互测。

前提验证结论(插件可全包,核心零改动):插件经 route_register 钩子可注册站点根路径 /.well-known/openid-configuration——路由引擎无路径白名单(Router::addRoute 只做 {param} 替换 + 正则匹配),Nginx/Apache 伪静态均将「非真实文件」转发 index.php 且无 deny 规则命中 .well-known,站点根也不存在真实 .well-known 目录。核心自身已有 index.php 注册 /manifest.webmanifest 的先例。唯一代价是部署约束:站点根不得真实存在 .well-known 文件/目录,部分面板/WAF 可能拦截 /. 开头请求,首次部署必须实测 curl -i https://<站点>/.well-known/openid-configuration。

二、技术设计

2.1 协议流程(授权码流程关键步骤)

Provider 侧(本站作为 IdP):

  1. 外部程序带用户到 GET /oidc/authorize(未登录先 requireLogin() 跳 /login,登录后带完整 query 原样回跳)。
  2. 校验 client_id 存在且 active → 再校验 redirect_uri(顺序关键:未验证前任何错误不得重定向,只能本站渲染错误页,否则变成开放重定向);redirect_uri 逐字符精确匹配白名单,禁止前缀/通配。
  3. 查记忆同意 op_consent(user_id, client_id, scope):已同意且本次 scope 被完全覆盖 → 跳过同意页直接发 code;否则渲染同意页,首次必须列全 scope,提供「记住本次授权」;scope 扩张时必须重新弹确认。
  4. 用户同意(POST 本站表单,必须 Csrf)→ 生成一次性 code(只存 hash,TTL 60s)→ header('Location:') 外部跳转回 redirect_uri?code=...&state=...;拒绝则回跳 error=access_denied。
  5. 外部程序 POST /oidc/token 换 token:client 认证 → 查 code hash → PKCE 校验 → 原子置 used=1 再发令牌(防并发重放)。

Client 侧(本站作为 RP):GET /oidc-login 生成 state/nonce/PKCE → 跳外部 IdP → 回跳 /oidc-login/callback 校验 state(一次性,600s,取出即 unset)→ 换 token → 本地验签 id_token → 按「手动绑定」策略落地用户。

2.2 端点设计

oidc_provider —— 前台(route_register)

方法路径控制器方法备注
GET/.well-known/openid-configurationMetadata::discovery唯一根路径;application/json;无会话
GET/oidc/jwksMetadata::jwks公钥集;发现文档 jwks_uri 指向此处
GET/oidc/authorizeAuthorize::show需登录
POST/oidc/authorizeAuthorize::approve同意/拒绝;必须 CSRF
POST/oidc/tokenToken::issue服务端对服务端;禁止 CSRF
GET/oidc/userinfoUserInfo::showAuthorization: Bearer
POST/oidc/revokeToken::revokeRFC 7009

oidc_provider —— 后台(admin_route_register,不含 /admin 前缀)

方法路径说明
GET/POST/oidc-provider概览 + 设置(issuer、TTL、PKCE 策略、开放 scope)
POST/oidc-provider/client/save \/delete \
POST/oidc-provider/key/generate \/rotate \
GET/oidc-provider/logs授权/令牌记录
POST/oidc-provider/token/revoke撤销已发令牌

oidc_client —— 前台(route_register)

方法路径控制器方法备注
GET/oidc-loginFront::start生成 state/nonce/PKCE → 外部跳转
GET/oidc-login/callbackFront::callback校验 state → 换 token → 验 id_token → 登录或引导绑定
GET/POST/oidc-login/bindFront::bind账号关联(手动绑定策略);POST 必须 CSRF
POST/oidc-login/unbindFront::unbind解绑;必须 CSRF

oidc_client —— 后台

方法路径说明
GET/POST/oidc-client配置:discovery URL、client_id/secret、scopes(关联策略固定为手动绑定,不提供切换)
POST/oidc-client/test连通性自检:拉 discovery + JWKS,回显 issuer/jwks_uri

发现文档(示例)关键字段:issuer、authorization_endpoint、token_endpoint、userinfo_endpoint、jwks_uri、revocation_endpoint、response_types_supported:["code"]、grant_types_supported:["authorization_code","refresh_token"]、id_token_signing_alg_values_supported:["RS256"]、scopes_supported:["openid","profile","email"]、code_challenge_methods_supported:["S256"]。只支持授权码模式,不实现 implicit/hybrid;只此 1 个根路径入口,不做冗余发现路径。

2.3 数据表(独立 SQLite 库)

oidc_provider(data/oidc_provider.sqlite)

  • op_clients:client_id UNIQUE、client_secret_hash、redirect_uris(JSON 数组,精确匹配)、scopes、client_type(confidential|public)、status
  • op_auth_codes:code_hash 主键、client_id、user_id、redirect_uri、scope、nonce、code_challenge(+method)、expires_at、used
  • op_tokens:token_hash 主键、kind(access|refresh)、client_id、user_id、scope、expires_at、revoked、family_id(同一授权链共享,复用检测时整族撤销)、rotated_from
  • op_keys:kid 主键、alg、public_pem、retired_at、is_active
  • op_config(field/value,存 sub_salt 等,sub_salt 永不更换)
  • op_consent(user_id, client_id, scope,主键 (user_id, client_id))
  • op_log(event,如 refresh_reuse;IP 只存 hash)

oidc_client(data/oidc_client.sqlite)

  • oc_config(field/value)
  • oc_bindings:user_id、issuer、sub、email,UNIQUE(issuer, sub) 为准,邮箱只作辅助
  • oc_jwks_cache:issuer 主键、jwks、fetched_at(TTL 缓存 + kid 未命中强制回源)
  • oc_log
  • 索引建议:op_tokens(user_id)、op_tokens(family_id)、op_auth_codes(expires_at)、op_log(created_at)。
  • 建表走 Plugin::ensureSchema() + init_after 兜底;过期 code/token 挂 route_after_dispatch 低频清理,禁止每请求写库。

2.4 配置项

provider 设置页:issuer(固定值 = https://www.flinthub.top,与 SITE_URL 逐字节一致,禁 HTTP_HOST 拼接);access token TTL(默认 3600);refresh token TTL(默认 2592000)+ 轮换/复用检测说明(不可关闭);开放 scope(默认 openid profile email);记忆同意开关(默认开);public client 强制 PKCE(只读展示「已强制」)。Secret 只在创建/重置时明文展示一次,库里只有 password_hash。

client 设置页:discovery URL(issuer 必须 https:// 开头无尾斜杠)、client_id、client_secret(type=password、不回填明文、只显示「已配置/未配置」,公开客户端可留空)、scopes(openid 必选)。

2.5 安全要点

  • state/nonce:client 侧二者都存 Session、一次性、600s;nonce 必须回验进 id_token。Provider 侧 state 建议必需、原样带回。
  • PKCE:public client 强制 code_challenge_method=S256(缺失或 plain 一律 invalid_request);confidential 可选但带了必校验。算法 base64url(sha256(code_verifier)) 与存值 hash_equals 比对。
  • token 校验(client 侧):解析 header 取 kid → JWKS(缓存)→ openssl_verify RS256 → 校验 iss==配置 issuer、aud==client_id、exp/iat/nonce 相符。任一步失败即中止,禁止只 base64_decode 读 claim,禁止「降级相信 email 参数」。
  • token 签发(provider 侧):access_token 用不透明随机串(存 hash,可撤销可审计,不用 JWT);id_token 用 RS256 JWT,claims 含 iss, sub, aud(=client_id), exp, iat, nonce, auth_time, name, preferred_username, email, email_verified, picture。sub 必须稳定:sub = 'fh-' . hash_hmac('sha256', user_id, sub_salt),与邮箱/用户名解耦,永不改变。
  • refresh token(D3 冻结):强制轮换 + 复用检测。每次换新即原子消费旧的;旧 refresh 再次使用 → 判定泄露 → 撤销整个 family_id 并写 op_log(event=refresh_reuse),返回 invalid_grant;scope 只允许收缩。
  • CSRF 边界:token 端点禁止 Csrf::verifyOrDie()(服务端对服务端);本站 POST /oidc/authorize 与 client 的 bind/unbind 必须 CSRF(先 requireLogin() 后 CSRF)。
  • redirect_uri:逐字符精确匹配;未验证前不得发生任何重定向(响应不得是 302)。
  • 外部跳转:不能用 $this->redirect()(基类只允许站内相对路径,外链会被改写为 /),必须 session_write_close(); header('Location: '.$url); exit;。
  • SSRF(client 侧最重要):Plugin::http() 拒绝内网/保留 IP 且不跟随重定向;自测时「自己站自己转一圈」在 localhost/内网会直接 blocked_ip,不得自写 curl 绕过。
  • 凭据与密钥:client_secret 只存 hash;私钥落 plugins/oidc_provider/data/keys/active.pem(0600,目录被 Nginx/Apache 双重拦截),不进包、不入日志、不进任何 HTTP 响应;与发布签名密钥(release_keygen.php / Updater 的 SIG_ALG RS256)严格分离,另生成一对。
  • issuer 铁律:固定值,与 SITE_URL 逐字节一致(www 与非 www、https、无尾斜杠),否则外部程序全部登录失败。
  • 限流与封禁联动:RateLimiter::check('oidc_token', 60, 60) 等;封禁用户无法提交 authorize(符合预期)。
  • 输入取值:$_POST 未被核心剥离非标量,插件必须 (string) 强转后再校验;输出全部 JSON,禁止拼字符串。

三、实现要点与目录结构

3.1 命名与两个静默失败陷阱

两个目录名都含下划线,命中规范两条静默失败陷阱:

  1. 生命周期方法被跳过:核心用目录名硬拼 \Plugin\{目录名}\Plugin,含下划线必然落空 → activate()/uninstall() 不执行(卸载留孤儿表且完全静默)。必须在 Plugin.php 类定义之外、文件末尾加别名:

```php

if (!\class_exists('Plugin\\oidc_provider\\Plugin', false)) {

\class_alias(Plugin::class, 'Plugin\\oidc_provider\\Plugin', false);

}

```

  1. 命名空间首段:统一采用仓内主流驼峰写法,与目录名逐字节可推导——oidc_provider → Plugin\OidcProvider,oidc_client → Plugin\OidcClient。发布门禁:python .workbuddy-ai/tools/plugin_case_lint.py plugins/<name>。

路由两条铁律:路径不得与站点根真实文件/目录同名(避开 plugins/data/cli/protected/assets/error);后台路由不再写 /admin 前缀。icon 用已核对的 id-card(provider)/ fingerprint(client),category 均为 integration。

3.2 目录结构

plugins/oidc_provider/
├── plugin.json                # permissions: ["route:admin"],不需要 net:fetch / system:settings
├── Plugin.php                 # 建表/配置/密钥/Client/Code/Token 业务 + class_alias
├── MetadataController.php     # discovery + jwks(纯 JSON,无视图)
├── AuthorizeController.php    # 授权页(登录 + 同意)
├── TokenController.php        # token / revoke(纯 JSON)
├── UserInfoController.php     # userinfo(Bearer)
├── AdminController.php        # 后台:Client/密钥/记录
├── Jwt.php                    # RS256 签发(JWT 编解码 + openssl)
├── Oidc.php                   # 参数校验、错误响应、PKCE 校验(协议层)
├── hook/{init_after, route_register, admin_route_register}.php
├── views/{authorize.php, admin.php}
├── assets/{style.css, admin.js}
├── lang/{zh.php, en.php, zh_tw.php}
└── data/                      # oidc_provider.sqlite + keys/(自动生成 .htaccess/web.config)

plugins/oidc_client/
├── plugin.json                # permissions: ["net:fetch", "route:admin"]
├── Plugin.php                 # 配置/绑定/discovery 缓存 + class_alias
├── FrontController.php        # start / callback / bind / unbind
├── AdminController.php        # 后台配置 + 连通性自检
├── OidcClient.php             # discovery、token 交换、JWKS 缓存、id_token 验签
├── hook/{init_after, route_register, admin_route_register, layout_head_end}.php
├── views/{bind.php, admin.php}
├── assets/{style.css, script.js}
├── lang/{zh.php, en.php, zh_tw.php}
└── data/oidc_client.sqlite

实现要点:

  • 配置存插件独立库(op_config/oc_config,沿用 third_party_login 的 tpl_config 范式),避开 system:settings 权限与核心 settings 污染。
  • 登录入口注入:layout_head_end 是唯一合规通道(auth/login 是核心页面,插件无法用 section('css'));if ($tpl !== 'auth/login') return; → 输出带 ?v=filemtime 的独立 script.js + hidden data 元素(零内联);script.js 在 #loginForm 就绪后 DOM 注入「使用 OIDC 登录」按钮;原生登录表单、注册、找回密码全部保留原样。
  • 登录页入口注入:按钮指向站内 GET /oidc-login(非外链),由控制器发起外部跳转;采用 response_mode=query(不用 form_post,会被 CSP 挡)。
  • 密钥管理:RSA-2048/RS256,kid = 公钥 DER 的 SHA-256 前 16 hex;openssl 在 phpstudy 下必须显式传 config(照抄 kgOpensslConfig(),否则报 80000003);轮换时新 key 先并入 JWKS(双 key 并存 ≥24h)再签发,旧 key 标 retired_at 后移除。
  • 后台 Tab 复用核心 .admin-tabs 标准;设置页必须渲染有效默认值、字段缺失不得静默写最小值、页面提示发现文档与 JWKS 地址 + 私钥位置与备份提示。
  • 与 third_party_login 的关系:不合并、不共用代码、不互相 require(插件间禁止直耦);可共存(登录页多一组按钮);行为一致性照抄(绑定页交互、state 存法、session_regenerate_id + $_SESSION['user_id'] + Csrf::rotate() + auth_login_after);不注册 auth_login_check。
  • 建号/用户组变更必须走核心 User 模型,禁止直写用户表。
  • 共享逻辑抽取原则(先各写各的):JWT base64url、PKCE S256、kid 计算允许两处各写一份(各 <30 行)。触发抽取条件(满足任一条再谈):① 同一段逻辑在 ≥2 个插件中都因它出过 bug;② 单段重复 >80 行;③ 安全关键路径(如 code 原子消费)需统一语义。抽取形态默认第三个独立插件 oidc_core,绝不相互 require。
  • both 模式死锁防护:若本站既是 IdP 又是 RP 且管理员仅用 OIDC 登录,IdP 授权页要求登录会造成死循环。规则:issuer 指向本站自身时登录页默认不注入 OIDC 入口,或必须存在至少一名持本地密码的管理员(插件后台检查并告警)。
  • 发布:两插件分别出包、不互相依赖;只走前台投稿包(plugins/<name>/… 单根、无 data/、无根级文件);data/keys/ 绝不能进包,出包后解压复查;verify_submit_zip.php 目标 12 PASS / 0 FAIL;key 名前缀 plugin.oidc_provider. / plugin.oidc_client.。

四、发布帖要点

对外描述(版本 1.0.0,分类「三方集成」,min_core 1.1.2,已签名,可在插件市场安装):

插件一句话说明
OIDC 身份提供方 oidc_provider把 FlintHub 变成 IdP,让 Discourse / Ghost / WordPress 等外部程序用你的论坛账号登录
OIDC 单点登录 oidc_client让 FlintHub 反过来对接 GitHub / Google / 另一个 FlintHub 站,用外部身份一键登录本站

核心卖点:

  1. 协议完整标准:发现文档、授权、令牌、用户信息、公钥集(JWKS)、令牌撤销六个标准端点;授权码模式 + PKCE(公开客户端强制 S256)。
  2. RS256 签名 + 刷新令牌强制轮换:id_token 用 RSA 私钥签名;refresh 泄露复用即整族自动撤销,会话安全有兜底。
  3. 安全优先的账号绑定:外部身份首次登录必须由用户确认关联,不自动建号、不按邮箱自动关联,从根上杜绝伪造邮箱冒领账号;解绑无密码账号会被阻止。
  4. 无感且可控的授权体验:记忆同意(scope 扩张时重新确认)、同意页完整展示 scope;密钥一键生成/轮换(旧公钥暂留 JWKS 待对方刷新)、IP 仅存哈希的 90 天审计、可一键撤销进行中的会话。
  5. 互不干扰、可互测:两个插件独立安装、互不依赖;原生登录/注册/找回密码原样保留,管理员永远有密码后路;自家站 IdP + RP 互测防死循环。

五、遗留与后续

已冻结决策(D1–D6,实现阶段不再讨论):

#决策冻结结论
D1测试环境用站点现有公网域名 https://www.flinthub.top;禁止 localhost/127.0.0.1;禁止另找域名
D2账号关联策略C 手动绑定(唯一默认);A 邮箱自动关联、B JIT 自动建号本阶段不实现(连开关都不做)
D3refresh_token一次做完,必须带 Token 轮换 + 复用检测(复用→整族撤销)
D4PKCEpublic 强制 S256;confidential 可选(带了就必校验)
D5发现文档冗余不做冗余路径,只保留根路径 1 个入口
D6记忆同意做;首次必须展示完整 scope,记忆后跳过,scope 扩张时重新弹

遗留事项:

  • 邮箱自动关联 / JIT 自动建号:P1 阶段不实现,需要时单独立项(若做,须邮箱域白名单 + IdP 可信标记 + 走 User 模型)。
  • 外部真实第三方(Discourse 等)对接:作 P1 之后的补充验证,至少覆盖 1 个 public client + PKCE、1 个 confidential client + secret,核对发现文档可解析、email_verified 正确、头像 URL 为 https:(CSP 友好)。
  • 共享逻辑抽取:Phase 1 明确不做,按 §3.2 的触发条件满足后再评估 oidc_core。
  • hairpin NAT 风险:若服务器无法通过公网域名访问自身,互测需运维手段解决,不改插件代码。
  • 环境级风险:部分面板/WAF 可能拦截 /. 开头请求,首次部署必须实测根路径;被拦时兜底为运维加精确 Nginx location / Apache RewriteRule,不改核心。

实施与验收节奏:P0 探针实测根路径(curl 200 + 预期 JSON)→ P1 provider 验收(code 一次性、token 各方式 200、refresh 轮换 + 复用整族失效、记忆同意生效)→ P2 client 验收(state 校验、id_token 本地验签且篡改必败、手动绑定全流程、无密码账号解绑被拒)→ P3 自家站互测(sub 稳定,改邮箱/改昵称后仍一致)→ P4 both 模式(无死循环 + 本地密码管理员兜底)→ P5 分别打包投稿(12 PASS / 0 FAIL)。验收标准 = 各步清单逐条勾选,全绿才算过。

轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| 浏览 3 次 | 回复 1 次

全部回复 (1)

🌳Lv.4 中级 🌛 见习会员
2026-10-26 01:54:13
把 FlintHub 同时做成 IdP 和 RP、拆成两个独立插件按需安装,这个切分很合理——多数站点只需要「用 GitHub/微信登录」(client),少数需要「给别的系统提供登录」(provider),绑在一起会让不想暴露授权端点的人被迫装。

三点:

1. provider 一旦上线,等于对外承诺一个长期稳定的授权端点,变更成本极高。方案里 D1–D6 已拍板,建议把「endpoint 路径永久冻结」也写进决策表,别留后改余地。
2. client 侧必须严格校验 id_token 的签名、iss、aud、exp、nonce 五项,少一项都可能被伪造 token 登录。尤其 aud 校验,很多实现只验签名不验受众,别的应用的 token 也能拿来登。
3. state 参数 + PKCE 都要做。confidential client 用 secret,public client(纯前端)必须用 PKCE,否则授权码能被截获利用。

「方案已冻结、未开发」这个状态标注很清楚。范围里包含 nginx-server.conf / .htaccess,说明还考虑了路由重写,这块常被忽略。
#1 楼

请 登录