本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:暂存20260913/第三方登录插件开发计划.md。
third_party_login 插件开发计划
版本:v1 — 规划稿(待评审后实施)
技术要求:遵循
插件开发规范.md(v1.1,2026-09-05)插件类型:独立插件,一目录一插件,零侵入核心
〇、目标与边界
目标
让站长可在后台按 Tab 分别启用GitHub / QQ / 微信 / 短信四种登录方式并配置参数;登录页按启用状态自动显示对应按钮;完成 OAuth 授权/短信验证登录、账号绑定/解绑,且三种登录方式(未绑定、已注册、绑定已有账号)流程完整。
硬性边界
- 零侵入核心:不改
app/任何文件、不改assets/css/*、不改核心auth/login.php视图。登录页按钮通过插件脚本注入。 - 绑定关系数据存插件独立库(
plugins/third_party_login/data/third_party_login.sqlite),不触碰核心库绑定/用户映射。 - 注册新账号复用核心
User::create()(公开方法)——这是唯一经核心 API 创建用户的方式。 - 写全局设置 / 后台路由 / 外部 HTTP分别需声明权限:本插件配置存插件库(无需
system:settings),但需声明route:admin与net:fetch。
一、目录结构
plugins/third_party_login/
├── plugin.json # 插件配置(含 hooks + permissions)
├── Plugin.php # namespace Plugin\ThirdPartyLogin;db/ddl/activate/deactivate/uninstall + 业务数据方法
├── FrontController.php # 前台:OAuth 跳转/回调、短信、绑定、解绑、绑定管理页
├── AdminController.php # 后台:设置页(Tab)
├── hook/
│ ├── init_after.php # 惰性 ensureSchema(兜底)
│ ├── route_register.php # 前台路由
│ ├── admin_route_register.php # 后台路由
│ ├── layout_head_end.php # 仅 auth/login 页加载插件 assets(供按钮注入)
│ └── nav_user_menu_items.php # 用户下拉菜单加"第三方账号绑定"入口
├── views/
│ ├── admin.php # 后台设置页(Tab + 开关 + 表单 + 保存)
│ ├── callbacks/oauth_bind.php # OAuth 未绑定 → 注册/绑定已有账号引导页
│ └── bindings.php # 前台个人中心"第三方账号绑定"页
├── assets/
│ ├── style.css # 登录按钮、绑定页样式(--mn-* 变量化)
│ └── script.js # 登录页按钮注入、短信发送/校验、Tab 交互
├── lang/
│ ├── zh.php / en.php / zh_tw.php # 三语
└── data/ # 自动生成:third_party_login.sqlite + 防下载二、独立数据库设计(SQLite)
-- 1) 配置表:每 provider 一行,field 键值
CREATE TABLE IF NOT EXISTS tpl_config (
provider TEXT NOT NULL, -- github|qq|wechat|sms
field TEXT NOT NULL, -- enabled|client_id|client_secret|app_id|app_key|access_key|sign_name|template_code...
value TEXT NOT NULL DEFAULT '',
PRIMARY KEY (provider, field)
);
-- 2) 绑定关系表:user_id + provider + openid(同一用户可绑多个)
CREATE TABLE IF NOT EXISTS tpl_bindings (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL, -- 核心 users.id
provider TEXT NOT NULL, -- github|qq|wechat
openid TEXT NOT NULL, -- 平台 openid = (外部) 用户唯一身份
nickname TEXT NOT NULL DEFAULT '',
avatar TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
UNIQUE(provider, openid)
);
CREATE INDEX IF NOT EXISTS idx_tpl_bindings_user ON tpl_bindings(user_id);
-- 3) 短信验证码表:phone + code(哈希存储)
CREATE TABLE IF NOT EXISTS tpl_sms_codes (
id INTEGER PRIMARY KEY,
phone TEXT NOT NULL, -- 哈希存储,防拖库泄露
code_hash TEXT NOT NULL, -- sha256(code)
expires_at INTEGER NOT NULL, -- time()+300(5 分钟)
used INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_tpl_sms_codes_phone ON tpl_sms_codes(phone);
-- 4) 短信频率表:每 IP/每手机号 每分钟 1 次(防轰炸)
CREATE TABLE IF NOT EXISTS tpl_sms_rate (
id INTEGER PRIMARY KEY,
bucket TEXT NOT NULL, -- "ip:1.2.3.4" / "phone:hash"
min_start INTEGER NOT NULL, -- 当前分钟的 time()/60
cnt INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (bucket, min_start)
);
-- 5) OAuth state 暂存表(防 CSRF 回调伪造)
CREATE TABLE IF NOT EXISTS tpl_oauth_states (
id INTEGER PRIMARY KEY,
session_tag TEXT NOT NULL, -- 随机 nonce
provider TEXT NOT NULL,
action TEXT NOT NULL, -- bind|login
created_at INTEGER NOT NULL
);全部表放插件独立库,
Plugin::ensureSchema('third_party_login', $ddl)幂等建表;DDL 为 SQLite 语法(TEXT取代VARCHAR/DATETIME)。
三、plugin.json
{
"name": "第三方登录",
"version": "1.0.0",
"author": "FlintHub",
"description": "支持 GitHub / QQ / 微信 / 短信四种登录,后台按 Tab 独立配置,含账号绑定与解绑",
"activated": false,
"category": "system",
"icon": "sign-in-alt",
"admin_url": "/admin/third-party-login",
"hooks": {
"init_after": "hook/init_after.php",
"route_register": "hook/route_register.php",
"admin_route_register": "hook/admin_route_register.php",
"layout_head_end": "hook/layout_head_end.php",
"nav_user_menu_items": "hook/nav_user_menu_items.php"
},
"permissions": ["route:admin", "net:fetch"]
}权限声明说明:注册后台路由需
route:admin;OAuth 令牌换取、用户信息拉取、短信下发均走Plugin::http(),需net:fetch。配置存插件库,无需system:settings。
四、Plugin.php 关键方法
db()→\app\Helpers\Plugin::db('third_party_login')ddl()→ 上一节四张表 + 索引的完整建表 SQLactivate()→ensureSchema(...)成功返回true;失败返回false/抛异常让系统回滚deactivate()→ 仅return true(不删表)uninstall()→ 按顺序:删独立库表 → 无核心库残留(配置全在插件库)→ off- 业务方法(带静态运行时缓存):
getProviderConfig($provider)/saveProviderConfig($provider, array $fields)/isEnabled($provider)findBinding($provider, $openid)/getUserBindings($userId)/bindUser($userId, $provider, $openid, $nick, $avatar)/unbind($provider, $openid, $userId)sendSmsCode($phone, $ip)/verifySmsCode($phone, $code)saveOAuthState($provider, $action)/consumeOAuthState($tag)createThirdPartyUser($provider, $openid, $nick)→ 调User::create()建号并返回
五、路由设计(hook/route_register.php + admin_route_register.php)
前台(route_register,变量 $router)
| 方法 | 路径 | 作用 |
|---|---|---|
| GET | /third-party-login/login/{provider} | 未登录→生成 state→跳转平台授权页 |
| GET | /third-party-login/callback/{provider} | 平台回调:验 state→换 token→拉 openid→登录/绑定 |
| POST | /third-party-login/sms/send | 发短信验证码(限流) |
| POST | /third-party-login/sms/login | 手机号+验证码 登录 |
| POST | /third-party-login/bind | 在引导页"绑定已有账号"(账号密码校验后绑定) |
| POST | /third-party-login/register | 在引导页"注册新账号"(建号后绑定并登录) |
| POST | /third-party-login/unbind | 个人中心解绑 |
| GET | /third-party-login/bindings | 个人中心绑定管理页(requireLogin) |
后台(admin_route_register)
| 方法 | 路径 | 作用 |
|---|---|---|
| GET | /admin/third-party-login | 设置页(Tab + 各登录方式配置) |
| POST | /admin/third-party-login/save | 保存当前 Tab 配置项 |
六、登录页按钮注入(零侵入实现)
核心 auth/login.php 不会渲染插件按钮。采用插件自定义路由 + layout_head_end 按模板加载脚本 + JS 注入按钮:
hook/layout_head_end.php:只当$template === 'auth/login'时输出以下外部资源(零内联):
```php
$tpl = $template ?? '';
if ($tpl !== 'auth/login') return;
$bp = \defined('BASE_PATH') ? BASE_PATH : '';
$cssVer = @filemtime(__DIR__ . '/../assets/style.css') ?: 1;
$jsVer = @filemtime(__DIR__ . '/../assets/script.js') ?: 1;
echo '<link rel="stylesheet" href="' . $bp . '/plugins/third_party_login/assets/style.css?v=' . $cssVer . '">';
echo '<script src="' . $bp . '/plugins/third_party_login/assets/script.js?v=' . $jsVer . '" defer></script>';
```
同时注入一个 data-* 承载已启用 provider 列表(不入内联 script,符合 7.3 规则):
```html
<div id="tplProviderData" hidden data-providers='<?php echo htmlspecialchars(json_encode($providers, JSON_HEX_TAG|...), ENT_QUOTES, 'UTF-8'); ?>'></div>
```
> 在钩子里读 getEnabledProviders()(每请求 1 次轻查询,满足每页 hook 查询 ≤5)。
assets/script.js:DOMContentLoaded后读/scripts#tplProviderData的 providers,在#loginForm之后插入 "或使用第三方账号登录" 分隔线 + 各 provider 按钮(按钮href=BASE_PATH + /third-party-login/login/{provider})。仅渲染启用的 provider,禁用则不渲染(后台改配置即时生效)。
- 登录页脚本
id="loginForm"已在核心视图存在(见 login.php),稳定定位。
为什么不用
controller_view_before改数据? 该钩子只能改$data,无法改 login.php 的渲染结构;用 JS 注入按钮是唯一不改核心视图又能达成"启用/禁用即时显隐"的方式。
七、登录流程实现
7.1 GitHub / QQ / 微信(OAuth 统一抽象)
三个平台均遵循:授权码 → 换 access_token → 拉 openid/用户信息,差异仅在授权 URL、token 端点、UserInfo 端点的参数/字段名。用 Plugin::http()(已含 SSRF 防护 + net:fetch)。
GET /third-party-login/login/{provider}
├─ 未启用 → 404/提示
├─ provider 配置缺 client_id/secret → 提示站长去后台配置
├─ 生成 state = bin2hex(random_bytes(16)),存 tpl_oauth_states(action='login')
└─ redirect($authUrl . '&state=' . $state . '&redirect_uri=' . urlencode($callbackUrl))
GET /third-party-login/callback/{provider}
├─ 校验:认证回调仅接受预设路径(不允许用户自定义 redirect_uri)
├─ 校验 state 存在且 action 匹配(consumeOAuthState,防回调伪造)
├─ code → POST token 端点 → access_token
├─ access_token → UserInfo 端点 → openid / 昵称 / 头像
├─ findBinding(provider, openid)
│ ├─ 已绑定 → 程序化登录该 user_id → redirect 首页
│ └─ 未绑定 →
│ ├─ 已登录 → 直接 bindUser,redirect 绑定页
│ └─ 未登录 → 把 openid/nick/avatar + action 存 session,渲染 oauth_bind 引导页
└─ 任一步失败 → error_log 记录真实原因 + 渲染错误提示页7.2 程序化登录(复用核心语义)
session_regenerate_id(true);
$_SESSION['user_id'] = (int)$userId;
\app\Helpers\Csrf::rotate();
(new \app\Models\User())->updateLoginTime((int)$userId, date('Y-m-d H:i:s'));
\app\Helpers\Plugin::hook('auth_login_after', ['user_id'=>$userId, 'username'=>$uname]);
$this->redirect('/');(与核心明文登录的关键动作一致,登出/记住密码走核心原有机制。)
7.3 短信登录
POST /third-party-login/sms/send
├─ CSRF verifyOrDie
├─ 手机号格式校验(^1[3-9]\d{9}$)
├─ 频率限制(tpl_sms_rate,原子):
│ ip:IP 每分钟 ≤1
│ phone 每分钟 ≤1
│ → 超限返回"发送过于频繁"
├─ 生成 6 位随机码,存 tpl_sms_codes(存 sha256,expires=time()+300,used=0,先作废旧码)
└─ 委托短信 Provider 发送(Plugin::http() POST 到配置的短信网关)
POST /third-party-login/sms/login
├─ CSRF verifyOrDie
├─ verifySmsCode:查匹配 phone + code_hash + expires>now + used=0
│ └─ 原子标 used:UPDATE tpl_sms_codes SET used=1 WHERE id=:id AND used=0 (rowCount==1 才算成功) ← 单次使用
├─ 查绑定 user_id(provider='phone', openid='ph:'+phoneHash)
│ ├─ 有 → 程序化登录
│ └─ 无 → 进入注册引导(见第八节,手机号未注册引导注册新账号后绑定)八、绑定 / 注册 / 解绑
8.1 OAuth 未绑定的引导页(views/callbacks/oauth_bind.php)
未登录未绑定 → 渲染两个入口:
- 绑定已有账号:出一个小表单(用户名 + 密码 + csrf),POST
/third-party-login/bind→ 校验密码(findByUsername+password_verify)→bindUser→ 程序化登录。 - 注册新账号 / 引导注册:按钮跳转核心
/register,注册成功后由核心auth_register_after钩子?? —— 核心注册后无法自动绑回本次 openid。因此改为插件内建注册:出用户名/邮箱/密码表单,POST/third-party-login/register→ 校验 →User::create()→bindUser→ 登录。昵称预填第三方 nickname。
说明:本插件不监听
auth_register_after,因其无法感知本次 openid;改用插件自带注册页完成"注册→绑定→登录"闭环,流程内聚、零跨状态耦合。
8.2 短信未注册
手机号未绑定 → 复用同上注册引导:用户填用户名/密码/邮箱建号,POST /third-party-login/register(附带手机号参数)→ User::create() → bindUser(provider='phone', openid) → 登录。
8.3 个人中心绑定 / 解绑页(views/bindings.php)
- 路由 GET
/third-party-login/bindings,requireLogin()。 - 展示当前用户已绑定列表(provider 图标 + 昵称 + 解绑按钮)。
- 已登录但没有绑定的第三方,可在此重新发起绑定(按钮跳
/third-party-login/login/{provider},此时 action='bind' → 回调直接绑定)。 - 解绑按钮 POST
/third-party-login/unbind,CSRF 校验 + 归属校验(只能解自己的)→ 从 tpl_bindings 删除。 - 用户下拉菜单入口由
hook/nav_user_menu_items.php提供("第三方账号绑定"链接 + BASE_PATH)。
九、安全设计(对照需求 + 规范红线)
| 需求/红线 | 实现 |
|---|---|
| 短信每 IP 每分钟≤1 | tpl_sms_rate 以 ip:IP/phone + 分钟窗口原子计数(INSERT ... ON CONFLICT DO UPDATE ... cnt=cnt+1),超限拒绝 |
| 验证码 5 分钟过期 | expires_at = time()+300,校验时判断 |
| 验证码单次使用 | 校验一次即 UPDATE ... SET used=1 WHERE id=:id AND used=0 且 rowCount()==1 |
| 验证码防拖库 | 只存 sha256(code),不存明文 |
| 回调参数校验防伪造 | ① 回调固定走 SITE_URL + BASE_PATH + /third-party-login/callback/{provider}(站长配置,拒绝自定义 redirect_uri);② state 一次性消费校验;③ 取 openid 用 access_token 调平台 UserInfo,不信任回调明文 |
| CSRF(红线 8) | 所有 POST(sms/send、sms/login、bind、unbind、admin save)都 Csrf::verifyOrDie() |
| XSS(规则 3) | 输出一律 $this->e();昵称/头像过转义;data- 注入用 htmlspecialchars(json_encode(..., JSON_HEX_)) |
| SQL 注入(规则 2) | 全部 prepare()+bindValue();ID/手机号 (int)/正则白名单 |
| 开放重定向 | redirect() 仅 / 开头相对路径,永不跳第三方回调中带回的任意 URL |
| 绑定归属(IDOR) | unbind 强制 AND user_id = :me;$me=(int)$_SESSION['user_id'] |
| 零内联(7.3) | 视图/钩子无 <style>/<script>/on*,全收敛 assets/ |
十、后台设置页(views/admin.php + AdminController)
- 顶部简短说明:"请分别启用需要的登录方式,并在对应 Tab 中填写配置。"
- Tab 导航:
[GitHub] [QQ] [微信] [短信],点击切换(Alpinex-data或纯 JS 切换,交互放 assets/script.js,视图零内联)。 - 每个 Tab:
- 启用开关:checkbox/switch 绑定
enabled。 - 各 provider 配置输入框:
- GitHub:
client_id、client_secret - QQ:
app_id、app_key - WeChat:
app_id、app_secret - SMS:
access_key、access_secret、sign_name、template_code - 保存按钮 → POST
/admin/third-party-login/save(带provider标识)→saveProviderConfig仅更新该 Tab 字段(互不影响,符合需求)。 - 显示预设回调地址(
SITE_URL + BASE_PATH + /third-party-login/callback/{provider}),提示站长去平台填此地址。 - 配置说明文字:"去哪里申请"(GitHub OAuth App / QQ 互联 / 微信开放平台 / 阿里云短信),仅指引不代申请。
- 后台路由需
route:admin权限(已声明)。 - 后台视图用
section('title')/section('css')/section('content');样式收敛 assets/style.css(--mn-*变量化,适配暗色主题);资源仅该页通过视图section('css')加载。
十一、语言包(三语)
- 键名统一
plugin.third_party_login.*,至少zh/en/zh_tw。 - 需覆盖:后台 Tab 名、各配置项 label、说明文案、登录按钮文案("使用 GitHub 登录"等)、短信输入/发送/校验文案、绑定页、解绑、各类错误提示(频率限制、验证码过期、openid 未绑定、平台配置缺失等)。
- 前台交互提示用
*_act后缀约定。 - 发布前跑"语言键全量比对"脚本,三语零缺失。
十二、零核心改动核对
| 潜在涉及点 | 处理 |
|---|---|
| 登录页按钮 | JS 注入,不改 auth/login.php |
| 注册新账号 | User::create(),不改核心注册逻辑 |
| 用户下拉菜单入口 | nav_user_menu_items 钩子 |
| 登录建立 Session | 调核心 Csrf+$_SESSION 语义,不改 AuthController |
| 后台路由 | admin_route_register 钩子 |
| 样式/JS | 全放插件 assets,不改 admin.css/modern.css |
十三、开发/验证步骤(实施阶段执行)
- 建目录骨架 + plugin.json + Plugin.php(建表/业务方法)。
- hook/init_after + plugin.json 写 permission 与 hooks;删
plugins/plugins_cache.json重建缓存。 - AdminController + hook/admin_route_register + views/admin.php + Tab 设置/保存。
- hook/layout_head_end(按 auth/login 注入资源 + providers 数据)+ assets/script.js(按钮注入、Tab、短信交互)+ assets/style.css。
- hook/route_register + FrontController:OAuth 跳转/回调、短信 发送/校验、绑定注册解绑、bindings 页。
- nav_user_menu_items 入口。
- lang 三语包。
- 验证:
php -l全部新文件;- 激活→建表成功;禁用→不删表;卸载→表删净 +
Plugin::getPlugin返回 null; - 后台各 Tab 独立保存、互不影响;
- 登录页仅显示已启用 provider 的按钮,启用/禁用即时生效;
- 短信限流(同 IP 第二次发送被拒)、过期、单次使用各验证一次;
- 回调 state 伪造/错 action 被拒;
- 双主题(浅色+暗夜星辰)走查登录页按钮与后台 Tab;
- 零内联检查
grep -nE '<style|<script>|on(click|change|submit|load)=' plugins/third_party_login/views plugins/third_party_login/hook应零命中; - 语言键三语全量比对零缺失;
- 跑
cli/smoke_frontend_flow.php确认无回归;如需清页面缓存调PageCache::invalidate()。
- 发布:清测试数据、不含
.bak/.tmp、同步update/+ CHANGELOG。
十四、待确认事项
- 短信网关:默认按阿里云短信接口形状对接;QQ/微信/GitHub 网关固定官方端点。若站长用其他短信服务商,是否需要"可自定义多个短信网关"?默认只留阿里云短信接口 + 配置项说明。
- 微信登录:采用微信开放平台·网站应用·扫码登录(OAuth 授权码模式,与 GitHub/QQ 同构)。是否需要支持微信公众号内嵌登录(需在微信浏览器 UA 下走 JS-SDK),本版先不做。
- 绑定已有账号:OAuth 未绑定时"绑定已有账号"用用户名+密码校验;是否需要支持"邮箱也参与匹配"?默认仅用户名。
以上若默认即可,我将按本计划实施;如需调整请指出。