[开发]third_party_login 插件开发计划

👑Lv.11 元老 🌞 骨干会员
2026-10-25 01:04:42

本文由 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() → 上一节四张表 + 索引的完整建表 SQL
  • activate() → 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 注入按钮:

  1. 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)。

  1. assets/script.js:DOMContentLoaded 后读 /scripts#tplProviderData 的 providers,在 #loginForm 之后插入 "或使用第三方账号登录" 分隔线 + 各 provider 按钮(按钮 href = BASE_PATH + /third-party-login/login/{provider})。仅渲染启用的 provider,禁用则不渲染(后台改配置即时生效)。
  1. 登录页脚本 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 每分钟≤1tpl_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] [微信] [短信],点击切换(Alpine x-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


十三、开发/验证步骤(实施阶段执行)

  1. 建目录骨架 + plugin.json + Plugin.php(建表/业务方法)。
  2. hook/init_after + plugin.json 写 permission 与 hooks;删 plugins/plugins_cache.json 重建缓存。
  3. AdminController + hook/admin_route_register + views/admin.php + Tab 设置/保存。
  4. hook/layout_head_end(按 auth/login 注入资源 + providers 数据)+ assets/script.js(按钮注入、Tab、短信交互)+ assets/style.css。
  5. hook/route_register + FrontController:OAuth 跳转/回调、短信 发送/校验、绑定注册解绑、bindings 页。
  6. nav_user_menu_items 入口。
  7. lang 三语包。
  8. 验证:
  • 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()。
  1. 发布:清测试数据、不含 .bak/.tmp、同步 update/ + CHANGELOG。

十四、待确认事项

  1. 短信网关:默认按阿里云短信接口形状对接;QQ/微信/GitHub 网关固定官方端点。若站长用其他短信服务商,是否需要"可自定义多个短信网关"?默认只留阿里云短信接口 + 配置项说明。
  2. 微信登录:采用微信开放平台·网站应用·扫码登录(OAuth 授权码模式,与 GitHub/QQ 同构)。是否需要支持微信公众号内嵌登录(需在微信浏览器 UA 下走 JS-SDK),本版先不做。
  3. 绑定已有账号:OAuth 未绑定时"绑定已有账号"用用户名+密码校验;是否需要支持"邮箱也参与匹配"?默认仅用户名。

以上若默认即可,我将按本计划实施;如需调整请指出。

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

全部回复 (0)

暂无回复

请 登录