PHP多应用授权中台实战:域名/IP/秘钥三通道授权与防盗版设计
2026/9/12 10:35:51 网站建设 项目流程

简介:这是一套面向个人及企业开发者的自助多应用授权系统,名为 Mangoa-Auth(芒果授权)。它基于 PHP 原生与 EasyWeb 框架构建,提供域名授权、秘钥授权、IP 授权等多种验证方式,并集成了独立的用户中心,用户可自助注册、购买授权、更换授权、下载源码与升级权限,可有效解决多应用授权分发、盗版追踪和远程更新等运维难题。压缩包约 13.87MB,共 1614 个文件,其中包含 352 个 PHP 后端接口与逻辑文件、607 个 JavaScript 交互脚本、130 个 CSS 样式文件,以及图片、字体、音频等多类前端资源,目录结构清晰,方便开发者直接部署或二次开发。系统功能覆盖面广,内置自定义等级权限、时间套餐、应用划分、卡密在线授权、API 对接授权、远程输出广告、支付接口认证、工单反馈、实名检测、盗版入库处理等模块,同时支持邮件/短信/微信通知与文章发布。目前已有 190 人学习下载,适合需要搭建私有授权平台、统一管理多应用授权流程的技术人员参考。

1. 授权系统拆解:为什么说 Mangoa-Auth 适合做多应用授权中台

手头维护五六个独立项目的开发者,应该都遇到过这种尴尬:每个项目都要写一套验证授权、判断到期、踢人下线的逻辑,写到最后比业务代码还长。Mangoa-Auth(芒果自助多应用企业级授权系统)要解决的就是这个重复劳动:它把域名授权、秘钥授权、IP授权统一到一个 PHP 原生后端,前端用 EasyWeb 做运营后台,用户可自助注册、购买授权、卡密兑换、更换绑定设备和下载源码。整套方案里,我最关注的是“盗版入库”和“远程更新”两个模块,前者让未授权来源可追踪,后者让已授权客户端免部署升级。这套源码适合个人开发者、软件众包团队以及做私有化交付的小公司。

2. 核心表结构与PHP原生实现:域名/秘钥/IP三通道授权是怎么样写的

三种授权方式本质都是验证“客户端传入的绑定值”与“授权记录”是否一致。域名授权取 HTTP_HOST,IP授权取客户端 IP,秘钥授权走自定义请求头。实现层面的差异只在取绑定值的位置,校验逻辑完全复用。

2.1 授权记录表:一个 bind_value 承载三种类型

我先给一个简化但可直接落地的表结构,后面章节的代码都基于它:

CREATE TABLE `auth_record` ( `id` int(11) NOT NULL AUTO_INCREMENT, `app_id` int(11) NOT NULL COMMENT '所属应用', `uid` int(11) NOT NULL COMMENT '授权用户', `auth_type` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1域名 2IP 3秘钥', `bind_value` varchar(255) NOT NULL DEFAULT '' COMMENT '域名或IP或秘钥本身', `expire_time` datetime DEFAULT NULL COMMENT '到期时间,NULL表示永久', `status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1有效 0禁用', `origin` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1购买 2卡密 3人工导入', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_app_bind` (`app_id`, `bind_value`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这里我把bind_value设计成 varchar 255,存储域名时是www.example.com,存储 IP 时是1.2.3.4,存储秘钥时是那串随机字符。好处是授权查询只走同一个索引,坏处是类型划分依赖auth_type字段,所以代码里必须保证auth_typebind_value成对写入。常见误区是把域名和IP分表存,那样看似清晰,实际做跨应用统计时反而要反复 UNION,我一般只在授权量超过百万时才考虑分表。

2.2 PHP原生验证类:按请求来源自动选择绑定值

授权服务最关键的是验证入口,我按“当前请求”和“指定绑定值”两种方式封装:

class AuthCheck { private $appId; private $secret; public function __construct($appId, $secret) { $this->appId = $appId; $this->secret = $secret; } public function verify($authType, $bindValue) { $pdo = Db::connect(); $stmt = $pdo->prepare( 'SELECT * FROM auth_record WHERE app_id=? AND bind_value=? AND status=1 LIMIT 1' ); $stmt->execute([$this->appId, $bindValue]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row) { return ['code' => 401, 'msg' => 'unlicensed']; } if (!empty($row['expire_time']) && strtotime($row['expire_time']) < time()) { return ['code' => 402, 'msg' => 'expired']; } return ['code' => 200, 'data' => $row]; } public function verifyByCurrentRequest($authType) { $value = ''; if ((int)$authType === 1) { $value = $_SERVER['HTTP_HOST'] ?? ''; } elseif ((int)$authType === 2) { $value = $this->clientIp(); } elseif ((int)$authType === 3) { $value = $_SERVER['HTTP_X_AUTH_CODE'] ?? ''; } return $this->verify($authType, $value); } private function clientIp() { return $_SERVER['REMOTE_ADDR'] ?? ''; } }

逻辑说明:verify()只做“是否存在有效授权”判断,不关心客户端怎么传进来;verifyByCurrentRequest()根据auth_type从请求环境里取绑定值。秘钥类型我习惯放在HTTP_X_AUTH_CODE自定义头,避免出现在 URL 参数中被访问日志记录。IP 地址这里直接用$_SERVER['REMOTE_ADDR'],不要轻信HTTP_X_FORWARDED_FOR,因为反代环境下客户端可以伪造这个头部。

提示:生产环境务必在 Nginx 层用 realip 模块重置 REMOTE_ADDR,不要在 PHP 层解析 X-Forwarded-For,否则 IP 授权可以被一条请求头绕过。

2.3 后端选型:为什么这套源码坚持 PHP 原生而不是上框架

Mangoa-Auth 的后端没有挂载 Laravel 或 ThinkPHP,整个验证服务就是一组原生 PHP 类。这样设计有它的理由:授权代码经常要被复制到各种老项目里做“接入”,原生文件丢进include_path就能跑,不引入 composer 依赖链;而 Laravel 应用版本一变,授权类可能因为容器或门脸机制跑不起来。对比下来:

选型部署成本二次开发成本性能适合场景
PHP原生低,纯文件拷贝中,无自动加载需手动require高,无框架启动开销嵌入式集成、老项目改造
ThinkPHP/Laravel高,需composer和运行目录配置低,有现成ORM和迁移中,框架初始化有损耗从零做主系统,团队熟悉框架

如果你准备把 Mangoa-Auth 嵌入到一个跑在 2GB 内存云主机上的重业务系统,我建议保留原生接口不动,只在后台管理端套一层框架;反之,如果这就是你以后的用户中心主站,那保留原生实现反而省心。

3. 用户自助流程实战:从注册到卡密兑换再到远程更新的状态机

用户自助流程是这套系统区别于普通授权库的地方。普通验证类只回答“能不能跑”,Mangoa-Auth 还回答了“用户怎么获得授权、怎么换设备、怎么拿新版本”。

3.1 用户状态与自助入口设计

用户表的核心字段包括 uid、账号、密码、status(0封禁 1正常 2等待邮箱验证)、group_id(决定等级权限)、wallet_balance(额度)。等级权限和购买套餐是两套逻辑:等级权限决定能下载哪些应用源码、能否使用 API 对接;时间套餐决定授权到期时间。很多二次开发把这两个概念混在一个字段里,后面做“自助升级权限”时非常被动。

自助入口不是简单的注册登录,而是把“购买授权”拆成两个动作:先购买套餐获得时长,再提交域名或IP生成授权记录。如果把这两个动作合并成一步,用户在没确定绑定域名时就会卡住。所以我个人在二次开发时会把auth_recordbind_value设为可空,用户购买后可在用户中心自行填写或绑定。

3.2 卡密兑换授权的并发安全代码

卡密是线下分发最常用的方式,很多人会先发一批卡密到渠道群,再回到后台导入。卡密表必须保证 code 唯一且不可重用。兑换接口我按事务处理:

public function exchangeCard($uid, $cardCode) { $pdo = Db::connect(); $pdo->beginTransaction(); try { $stmt = $pdo->prepare( 'SELECT * FROM card WHERE code=? AND status=0 FOR UPDATE' ); $stmt->execute([$cardCode]); $card = $stmt->fetch(PDO::FETCH_ASSOC); if (!$card) { throw new Exception('卡密不存在或已使用'); } $pdo->prepare( 'UPDATE card SET status=1, use_uid=?, use_time=? WHERE id=?' )->execute([$uid, date('Y-m-d H:i:s'), $card['id']]); $pdo->prepare( 'INSERT INTO auth_record (app_id, uid, auth_type, bind_value, expire_time, origin) VALUES (?,?,?,?,?,?)' )->execute([ $card['app_id'], $uid, $card['auth_type'], $card['bind_value'], date('Y-m-d H:i:s', time() + $card['duration'] * 86400), 2 ]); $pdo->commit(); return ['code' => 200, 'msg' => '兑换成功']; } catch (Exception $e) { $pdo->rollBack(); return ['code' => 500, 'msg' => $e->getMessage()]; } }

逻辑说明:SELECT ... FOR UPDATE锁住卡密行,避免两个请求同时读到status=0导致同一张卡被重复兑换。绑定值和有效期放到事务里一起写入auth_record,这样即使兑换后生成授权失败,卡密状态也会回滚,不会出现“卡密已用但授权没到账”的争议。duration是套餐天数,乘 86400 换算成秒;如果你做小时套餐,这里要改成$card['duration'] * 3600,并额外加一个单位字段。

3.3 远程更新的版本比较与下载校验

远程更新接口最容易翻车的地方是版本号比较。直接用字符串比较会把1.9.9判为大于1.10.0,所以必须用 PHP 的version_compare

public function checkUpdate($appId, $version, $authType) { $auth = (new AuthCheck($appId, $this->secret)) ->verifyByCurrentRequest($authType); if ($auth['code'] !== 200) { return json_encode(['code' => 401, 'has_update' => false, 'msg' => '授权不合法']); } $pdo = Db::connect(); $stmt = $pdo->prepare( 'SELECT version, download_url, md5, update_desc FROM app_version WHERE app_id=? ORDER BY id DESC LIMIT 1' ); $stmt->execute([$appId]); $latest = $stmt->fetch(PDO::FETCH_ASSOC); $needUpdate = $latest && version_compare($version, $latest['version'], '<'); return json_encode([ 'code' => 200, 'has_update' => $needUpdate, 'version' => $latest['version'] ?? $version, 'download_url' => $latest['download_url'] ?? '', 'md5' => $latest['md5'] ?? '', 'desc' => $latest['update_desc'] ?? '' ]); }

参数说明:$version是客户端上报的当前版本号;download_url建议指向 CDN 而不是授权服务目录,避免更新包下载占用 PHP 进程;md5字段在客户端下载完整包后校验,防止传输中被劫持更换。客户端逻辑一般是:收到has_update=true后下载新包,比对 md5 一致再覆盖本地文件,失败保留旧版本并输出一条错误提示。

3.4 后台样式和前端资源怎么动

源码包里那串console.js.bakgeet.css.bakadmin.cssstyle.min.cssbootstrap.min.csslayui.css已经说清楚了前端结构:EasyWeb 基于 Layui 和 Bootstrap 拼装后台布局,admin.css是布局入口,style.min.css是业务页面通用样式,jmstyle.min.css是组件主题。需要改后台皮肤时,我建议先改admin.css里的 CSS 变量,不要动bootstrap.min.csslayui.css这两个第三方文件,否则下次升级原包时你很难合并冲突。.bak文件是官方留下的备份,实际部署可以删除,避免被扫描到历史版本差异。

4. 盗版入库与授权验证的攻防细节:伪装请求、防重放、日志审计

授权接口放到公网后,最先被打的不是数据库,而是接口本身。抓包、重放、伪造来源,都是常规操作。盗版入库的核心并不是“封死所有破解”,而是把未授权来源记录下来,让后续溯源和批量处理有依据。

4.1 盗版入库的判定逻辑与数据落库

当任意一次授权验证失败时,服务端记录请求特征。实现上不能每次失败都写一条新记录,否则一个暴力脚本几分钟就能把表写爆。我按“绑定值 + 自然日”做聚合:

public function recordPiracy($appId, $authType, $bindValue) { $row = [ 'app_id' => $appId, 'auth_type' => $authType, 'bind_value' => $bindValue ?: '', 'ip' => $this->clientIp(), 'ua' => substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255), 'hit_count' => 1, 'created_at' => date('Y-m-d H:i:s') ]; $pdo = Db::connect(); $stmt = $pdo->prepare( 'SELECT id, hit_count FROM piracy_log WHERE bind_value=? AND DATE(created_at)=CURDATE() LIMIT 1' ); $stmt->execute([$row['bind_value']]); $exist = $stmt->fetch(PDO::FETCH_ASSOC); if ($exist) { $pdo->prepare( 'UPDATE piracy_log SET hit_count = hit_count + 1, ip=?, ua=? WHERE id=?' )->execute([$row['ip'], $row['ua'], $exist['id']]); } else { $pdo->prepare( 'INSERT INTO piracy_log (app_id, auth_type, bind_value, ip, ua, hit_count) VALUES (?,?,?,?,?,?)' )->execute([ $row['app_id'], $row['auth_type'], $row['bind_value'], $row['ip'], $row['ua'], 1 ]); } }

这里的bind_value是请求里传的待校验值。盗版者把正版用户的域名改掉再试,这个字段就会留下他的网站域名;之后你可以在后台按hit_count排序,找出高频探测的域名。我一般会设一个阈值:单日hit_count >= 10自动给管理员推送通知,邮件或短信都行,别让入库数据只躺在那没人看。

4.2 防重放签名校验:时间戳与 nonce 缺一不可

只校验签名不够,签名可以直接原样重放。成熟的接口需要同时校验时间戳和 nonce:

public function verifySign($params, $secret) { if (abs(time() - (int)$params['timestamp']) > 300) { return false; } $nonce = $params['nonce']; $cacheKey = 'nonce:' . $nonce; if (Redis::get($cacheKey)) { return false; } $signParams = $params; unset($signParams['sign']); ksort($signParams); $str = urldecode(http_build_query($signParams)); $calcSign = strtoupper(md5($str . $secret)); if (!hash_equals($calcSign, strtoupper($params['sign']))) { return false; } Redis::setex($cacheKey, 600, 1); return true; }

逻辑说明:时间戳超过 300 秒直接拒绝,防历史请求重放;nonce 记录到 Redis 并设置 600 秒过期,同一个 nonce 只允许一次;签名内容必须排除 sign 本身,并统一排序。hash_equals是防止时序攻击的比较函数,不要用==比较签名。这里有一个容易被忽略的点:如果客户端参数里有数组,http_build_query会生成类似a[0]=1的结构,服务端和客户端必须约定同一套编码规则,否则签名永远不一致。

4.3 日志审计字段与后台检索

授权系统能追踪到人才算闭环。下面是最值得保留的日志维度:

日志类型记录内容典型问题
登录日志uid、IP、UA、登录时间同一账号多地同时登录
授权验证日志app_id、auth_type、bind_value、验证结果某域名被频繁请求但无授权
卡密操作日志卡号、操作用户、IP、时间某渠道卡密被集中兑换
工单日志用户ID、主题、处理人权限变更无记录、责任难分

这些日志我建议单独落到log_*表,不要在auth_record上做条件筛选。线上数据量一旦上来,日志表可以和业务表分离到不同磁盘,甚至直接同步到 ElasticSearch。后台检索时,优先查bind_valueip两个字段的索引;UA 属于低选择性字段,建索引反而拖慢写入。

5. API对接与额度体系:把授权能力开放给第三方的技巧

5.1 开放API的请求格式

第三方想在自建站点里直接为用户购买授权,最简单的方式是调用授权系统开放 API,不用登录后台。签名规则和第四章一致,请求示例:

curl -X POST https://auth.example.com/api/apply \ -d app_id=1001 \ -d uid=88 \ -d auth_type=1 \ -d timestamp=1739000000 \ -d nonce=6c4a2abc \ -d sign=1A2B3C4D5E...

签名串要覆盖除 sign 外的所有表单参数。第三方应用需要先在后台申请一对 appId/secret,并把来源 IP 加入白名单,否则即使签名正确也拒绝服务。这里我建议额外加一个biz_id作为第三方请求幂等键,避免同一笔购买请求在超时重试时生成两条授权。

5.2 额度扣减与授权续费的事务处理

额度系统本质是预付费钱包:用户先买额度套餐,再按授权时长消耗。扣费必须和授权续期放同一个事务:

$pdo->beginTransaction(); $stmt = $pdo->prepare('SELECT balance FROM user_wallet WHERE uid=? FOR UPDATE'); $stmt->execute([$uid]); $balance = (int) $stmt->fetchColumn(); if ($balance < $cost) { $pdo->rollBack(); throw new Exception('额度不足'); } $pdo->prepare('UPDATE user_wallet SET balance=balance-? WHERE uid=?') ->execute([$cost, $uid]); $pdo->prepare('UPDATE auth_record SET expire_time=? WHERE id=?') ->execute([$newExpire, $recordId]); $pdo->commit();

说明:FOR UPDATE加在钱包行,确保并发请求下不会出现负余额。cost要由后端根据套餐时长和单价计算,不能信任客户端传上来的应付金额。额度扣减成功后,把扣费流水单独写一条wallet_log,后续对账时以流水为准。

5.3 远程输出广告的开关与频控

“远程输出广告”是常见需求:已授权应用在启动页展示授权方提供的推广内容。广告接口必须复用授权验证,未授权请求不返回广告内容:

{ "code": 0, "data": { "ad_type": "image", "image_url": "https://example.com/ad.png", "link": "https://example.com/to" } }

接口侧做两层控制:第一层是授权有效,第二层是频控。同一客户端请求广告间隔不低于 120 秒,可以直接用 Redis:

$key = 'ad_limit:' . md5($clientId . date('YmdH')); if (Redis::get($key) && Redis::ttl($key) > 0) { return json_encode(['code' => 429, 'msg' => 'too many requests']); } Redis::setex($key, 120, 1);

这里$clientId千万不要用 IP,否则同一个 NAT 后面的所有用户都会互相限频,应该用授权码绑定的 unique_id。广告内容要支持在后台随时下架,最简单做法是内容存数据库而不是写死在接口返回里。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询