本文由 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):
- 外部程序带用户到
GET /oidc/authorize(未登录先requireLogin()跳/login,登录后带完整 query 原样回跳)。 - 校验
client_id存在且 active → 再校验redirect_uri(顺序关键:未验证前任何错误不得重定向,只能本站渲染错误页,否则变成开放重定向);redirect_uri逐字符精确匹配白名单,禁止前缀/通配。 - 查记忆同意
op_consent(user_id, client_id, scope):已同意且本次 scope 被完全覆盖 → 跳过同意页直接发 code;否则渲染同意页,首次必须列全 scope,提供「记住本次授权」;scope 扩张时必须重新弹确认。 - 用户同意(
POST本站表单,必须 Csrf)→ 生成一次性code(只存 hash,TTL 60s)→header('Location:')外部跳转回redirect_uri?code=...&state=...;拒绝则回跳error=access_denied。 - 外部程序
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-configuration | Metadata::discovery | 唯一根路径;application/json;无会话 |
| GET | /oidc/jwks | Metadata::jwks | 公钥集;发现文档 jwks_uri 指向此处 |
| GET | /oidc/authorize | Authorize::show | 需登录 |
| POST | /oidc/authorize | Authorize::approve | 同意/拒绝;必须 CSRF |
| POST | /oidc/token | Token::issue | 服务端对服务端;禁止 CSRF |
| GET | /oidc/userinfo | UserInfo::show | Authorization: Bearer |
| POST | /oidc/revoke | Token::revoke | RFC 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-login | Front::start | 生成 state/nonce/PKCE → 外部跳转 |
| GET | /oidc-login/callback | Front::callback | 校验 state → 换 token → 验 id_token → 登录或引导绑定 |
| GET/POST | /oidc-login/bind | Front::bind | 账号关联(手动绑定策略);POST 必须 CSRF |
| POST | /oidc-login/unbind | Front::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_idUNIQUE、client_secret_hash、redirect_uris(JSON 数组,精确匹配)、scopes、client_type(confidential|public)、statusop_auth_codes:code_hash主键、client_id、user_id、redirect_uri、scope、nonce、code_challenge(+method)、expires_at、usedop_tokens:token_hash主键、kind(access|refresh)、client_id、user_id、scope、expires_at、revoked、family_id(同一授权链共享,复用检测时整族撤销)、rotated_fromop_keys:kid主键、alg、public_pem、retired_at、is_activeop_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_verifyRS256 → 校验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 命名与两个静默失败陷阱
两个目录名都含下划线,命中规范两条静默失败陷阱:
- 生命周期方法被跳过:核心用目录名硬拼
\Plugin\{目录名}\Plugin,含下划线必然落空 →activate()/uninstall()不执行(卸载留孤儿表且完全静默)。必须在Plugin.php类定义之外、文件末尾加别名:
```php
if (!\class_exists('Plugin\\oidc_provider\\Plugin', false)) {
\class_alias(Plugin::class, 'Plugin\\oidc_provider\\Plugin', false);
}
```
- 命名空间首段:统一采用仓内主流驼峰写法,与目录名逐字节可推导——
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 站,用外部身份一键登录本站 |
核心卖点:
- 协议完整标准:发现文档、授权、令牌、用户信息、公钥集(JWKS)、令牌撤销六个标准端点;授权码模式 + PKCE(公开客户端强制 S256)。
- RS256 签名 + 刷新令牌强制轮换:id_token 用 RSA 私钥签名;refresh 泄露复用即整族自动撤销,会话安全有兜底。
- 安全优先的账号绑定:外部身份首次登录必须由用户确认关联,不自动建号、不按邮箱自动关联,从根上杜绝伪造邮箱冒领账号;解绑无密码账号会被阻止。
- 无感且可控的授权体验:记忆同意(scope 扩张时重新确认)、同意页完整展示 scope;密钥一键生成/轮换(旧公钥暂留 JWKS 待对方刷新)、IP 仅存哈希的 90 天审计、可一键撤销进行中的会话。
- 互不干扰、可互测:两个插件独立安装、互不依赖;原生登录/注册/找回密码原样保留,管理员永远有密码后路;自家站 IdP + RP 互测防死循环。
五、遗留与后续
已冻结决策(D1–D6,实现阶段不再讨论):
| # | 决策 | 冻结结论 |
|---|---|---|
| D1 | 测试环境 | 用站点现有公网域名 https://www.flinthub.top;禁止 localhost/127.0.0.1;禁止另找域名 |
| D2 | 账号关联策略 | C 手动绑定(唯一默认);A 邮箱自动关联、B JIT 自动建号本阶段不实现(连开关都不做) |
| D3 | refresh_token | 一次做完,必须带 Token 轮换 + 复用检测(复用→整族撤销) |
| D4 | PKCE | public 强制 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 可能拦截
/.开头请求,首次部署必须实测根路径;被拦时兜底为运维加精确 Nginxlocation/ ApacheRewriteRule,不改核心。
实施与验收节奏:P0 探针实测根路径(curl 200 + 预期 JSON)→ P1 provider 验收(code 一次性、token 各方式 200、refresh 轮换 + 复用整族失效、记忆同意生效)→ P2 client 验收(state 校验、id_token 本地验签且篡改必败、手动绑定全流程、无密码账号解绑被拒)→ P3 自家站互测(sub 稳定,改邮箱/改昵称后仍一致)→ P4 both 模式(无死循环 + 本地密码管理员兜底)→ P5 分别打包投稿(12 PASS / 0 FAIL)。验收标准 = 各步清单逐条勾选,全绿才算过。