本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:WWW/plugins/third_party_login/说明.md。
第三方登录(third_party_login)插件说明
版本:1.0.0 | 作者:FlintHub | 状态:⛔ 未启用
功能:支持 GitHub / QQ / 微信 / 短信 四种登录方式,后台按 Tab 独立配置;
第三方校验通过但未绑定站内账号时引导「绑定已有账号」(不自动注册),并提供绑定管理与解绑。
一、功能简介
- 四种登录方式(
Plugin::$fields定义的四个 provider)
| provider | 名称 | 凭据字段 | 协议 / 网关 |
|---|---|---|---|
github | GitHub | client_id / client_secret | OAuth 2.0(github.com) |
qq | app_id / app_key | OAuth 2.0(graph.qq.com) | |
wechat | 微信 | app_id / app_secret | OAuth 2.0 扫码(open.weixin.qq.com) |
sms | 短信 | access_key / access_secret / sign_name / template_code | 阿里云短信(dysmsapi.aliyuncs.com) |
- 端点固定:三个 OAuth 平台的授权 / 换 token / 取用户信息地址全部硬编码在
Plugin::$endpoints,
不接受自定义网关或自定义 redirect_uri(防开放重定向与钓鱼中转)
- 独立开关:每个 provider 一个
enabled开关;isEnabled()要求「开关打开 且 必填项全部非空」才算启用 - 登录页按钮注入:
layout_head_end钩子仅在auth/login页注入资源,且「零 provider 启用时完全不输出」 - 绑定已有账号:第三方校验通过但该 openid 未绑定任何站内账号时,引导输入「站内用户名 + 密码」完成绑定。
本插件不做自动注册——没有账号的用户必须先去注册
- 绑定管理页:
/third-party-login/bindings列出已绑定账号,逐条可解绑 - 后台管理:
/admin/third-party-login四个 Tab(GitHub / QQ / 微信 / 短信),每 Tab 独立保存,互不影响 - 零内联:样式与脚本全在
assets/,视图无内联style与on*事件
二、目录结构
plugins/third_party_login/
├── plugin.json # 插件清单(5 个钩子 + admin_url + permissions: route:admin, net:fetch)
├── Plugin.php # 主类:建表、配置读写、绑定关系、短信验证码与频率、端点与回调地址
├── FrontController.php # 前台:OAuth 跳转/回调、短信发送/登录、绑定/解绑、绑定列表
├── AdminController.php # 后台:设置页读取与保存(四个 Tab 共用)
├── hook/
│ ├── init_after.php # 幂等建表兜底(缓存标记 data/.tpl_table_cache)
│ ├── route_register.php # 前台路由注册
│ ├── admin_route_register.php # 后台路由注册(需 route:admin)
│ ├── layout_head_end.php # 仅 auth/login 页注入样式/脚本与按钮数据
│ └── nav_user_menu_items.php # 用户下拉菜单「第三方账号绑定」入口(PC + 移动双触发)
├── views/
│ ├── admin.php # 后台设置页(四 Tab,Alpine 切换)
│ ├── bindings.php # 绑定管理页(列表 + 解绑)
│ └── oauth_bind.php # 绑定引导页 / 错误页(phase: oauth | sms | error)
├── assets/
│ ├── style.css # 样式(全用 --mn-* 主题变量,零硬编码主题色)
│ └── script.js # 登录页按钮渲染、短信发送/登录、后台开关联动(零内联)
├── data/ # third_party_login.sqlite(独立库)+ .htaccess / web.config
└── lang/ # zh.php / en.php / zh_tw.php(各 98 键,三语零差异)三、数据表
插件独立库:plugins/third_party_login/data/third_party_login.sqlite
| 表 | 用途 |
|---|---|
tpl_config | 配置(provider + field 联合主键,value)。每个 provider 的字段与开关都存在这里 |
tpl_bindings | 绑定关系(user_id / provider / openid / nickname / avatar / created_at;UNIQUE(provider, openid)) |
tpl_sms_codes | 短信验证码(phone / code_hash(sha256)/ expires_at / used / created_at) |
tpl_sms_rate | 短信频率计数(bucket + min_start 联合唯一,cnt) |
- 索引:
idx_tpl_bindings_user(user_id)、idx_tpl_sms_codes_phone(phone) - 建表:
activate()+init_after双保险(幂等 DDL);init_after带.tpl_table_cache戳文件门控,
表已存在时每请求 0 条 DDL(规范性能红线:单插件每请求所有 hook 查询总和 ≤ 5)
- 卸载:
uninstall()删四张表。本插件配置全在独立库,不写核心settings,故无需清理核心键 - ⚠️
tpl_sms_codes.phone存明文手机号(code_hash才是哈希);tpl_bindings.openid对sms用
'ph:' + sha256(手机号),不存明文
四、路由
前台
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /third-party-login/login/{provider} | 跳转第三方授权页(provider ∈ github / qq / wechat) |
| GET | /third-party-login/callback/{provider} | 授权回调:校验 state → 换 token → 拉用户信息 → 登录或引导绑定 |
| POST | /third-party-login/sms/send | 发送短信验证码(CSRF) |
| POST | /third-party-login/sms/login | 短信验证码登录(CSRF) |
| POST | /third-party-login/bind | 绑定已有账号(CSRF) |
| GET | /third-party-login/bindings | 绑定管理页(需登录) |
| POST | /third-party-login/unbind | 解绑(需登录 + CSRF) |
后台
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /admin/third-party-login | 设置页(`?tab=github\ |
| POST | /admin/third-party-login/save | 保存指定 provider 的配置(CSRF) |
⚠️ 后台路由需声明
route:admin;外呼第三方接口需net:fetch。二者均已声明。
五、核心规则
OAuth 流程(防 CSRF / 防重放)
① 发起(oauthLogin)
provider 白名单校验 → isEnabled 校验
→ 生成 state = bin2hex(random_bytes(16))(32 位十六进制)
→ 存 $_SESSION['tpl_oauth'] = { state, provider, expires: now + 600 }
→ 302 到平台授权页(client_id / redirect_uri / scope / state 全部 rawurlencode)
② 回调(oauthCallback)
→ 取出并**立即 unset** $_SESSION['tpl_oauth'](一次性)
→ 四重校验:存在 / provider 一致 / state 用 hash_equals 比对 / 未过期(600s)
→ 取 code(缺失即错误页)
→ 换 access_token → 拉 openid / 昵称 / 头像
→ 已绑定 → 直接登录;未绑定 → 已登录则直接绑定,未登录则引导绑定已有账号- 任一校验不过 →
error_log+ 错误页,不泄露失败细节 redirect_uri由Plugin::callbackUrl($provider)生成,并对SITE_URL做引号/空白/尾斜杠清洗
(防 FLINTHUB_SITE_URL 被误写成带引号的整串 → 回调地址变成 https://域名%22%22/...)
短信验证码
| 环节 | 规则 |
|---|---|
| 手机号 | 正则 ^1[3-9]\d{9}$(中国大陆手机号) |
| 验证码 | 6 位数字,random_int(100000, 999999)(避免弱随机) |
| 存储 | 只存 sha256(code),不存明文 |
| 有效期 | SMS_TTL = 300 秒(5 分钟) |
| 使用 | 单次使用:校验通过立即置 used = 1;置位用 UPDATE ... WHERE id = ? AND used = 0 + rowCount() 断言,并发下只有一次成功 |
| 作废旧码 | 每次发新码前把该手机号的旧码全部置 used = 1 |
| 频率限制 | 双维度:ip:<REMOTE_ADDR> 与 ph:<手机号> 各自每分钟 ≤ 1 次(防短信轰炸) |
| 计数清理 | 每次限流检查顺带删除 1 小时前的旧记录,控制表增长 |
登录动作(与核心明文登录保持一致)
FrontController::loginUser() 按顺序执行四件事,缺一不可:
① session_regenerate_id(true) // 防会话固定
② $_SESSION['user_id'] = $userId
③ Csrf::rotate() // 轮换 CSRF token
④ (new User())->updateLoginTime() // 更新最后登录时间
⑤ Plugin::hook('auth_login_after', [user_id, username]) // 通知其他插件绑定 / 解绑
- 绑定:
tpl_bindings上UNIQUE(provider, openid)是硬约束;写入用ON CONFLICT(provider, openid) DO UPDATE - 绑定已有账号(
bind())必须先过「用户名 +password_verify密码」校验;
并触发核心 auth_login_check 钩子,读 $GLOBALS['mod_system_login_blocked'] 判断封禁 → 被禁用户不允许绑定
- 解绑:按
(user_id, provider, openid)三元组删除,只能解绑自己的(user_id取自当前会话) - ⚠️ 同一第三方账号(openid)只能绑定一个站内账号;重复绑定会改绑到后者(见注意事项)
后台配置保存
- 只接受该 provider 白名单内的字段(
Plugin::$fields[$provider]),其余字段一律忽略 - 每个值
mb_substr(..., 0, 191)截断;enabled归一为'0'/'1' - 每 Tab 独立保存:只写当前 provider 的键,不影响其他 Tab
- 保存后 302 回当前 Tab(
?tab=<provider>),不会跳回第一个
六、注意事项
- ⚠️ 依赖
net:fetch权限:OAuth 换 token / 拉用户信息 / 阿里云短信发送均走\app\Helpers\Plugin::http()。
permission_enforce 为强制模式(默认)时,未声明该权限会被 DENY
- ⚠️ 回调地址必须在第三方平台原样登记:GitHub / QQ / 微信后台填的
redirect_uri必须与
Plugin::callbackUrl() 输出逐字符一致(含 http/https、端口、结尾无斜杠)。后台设置页已直接展示该地址
- ⚠️
SITE_URL不要写成带引号的整串。callbackUrl()会清洗首尾引号/空白/尾斜杠,但源头写对更稳妥 - ⚠️ 手机号明文存库:
tpl_sms_codes.phone是明文。若对隐私有更高要求,需另行加密改造 - ⚠️ 重复绑定会改绑:
tpl_bindings唯一键是(provider, openid)而非(user_id, provider),
因此同一第三方账号被第二个站内账号绑定时,会把归属改到后者。正常流程(先 findBinding 命中即登录)不会触发,
但管理员排查「绑定关系变了」时应知悉此语义
- ⚠️ 解绑不设「至少保留一种登录方式」保护:用户可解绑全部第三方账号,之后靠站内密码登录。
本插件不做自动注册,因此不存在「无密码账号」
- ⚠️ 建表缓存标记在
data/.tpl_table_cache:手工删表后需一并删该标记,否则不会重建 - ⚠️ 本插件目录名含下划线,核心
Plugin::activate/deactivate/uninstall()的类名推导
("\\Plugin\\{$name}\\Plugin")会拼出 \Plugin\third_party_login\Plugin 而无法命中
\Plugin\ThirdPartyLogin\Plugin → 已在 Plugin.php 末尾用 class_alias 补别名
(否则卸载时四张数据表不会被删除)
- ⚠️
nav_user_menu_items是双触发钩子(PC + 移动各一次,带placement参数)。
本插件两次都输出(与 qa / user_level 同款),与 post_favorite 不同——后者会显式跳过 pc
- ⚠️ 登录页资源只走
layout_head_end(因为auth/login是核心页面,无法用插件视图的section('css'));
后台页与绑定页则走视图 section('css')。改动注入条件时注意别把两套混淆
- 当前状态:四张表均无业务数据(插件未启用);三语各 98 键,键集合零差异、键序一致