
简介这是一套面向个人及企业开发者的自助多应用授权系统名为 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_HOSTIP授权取客户端 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里我把bind_value设计成 varchar 255存储域名时是www.example.com存储 IP 时是1.2.3.4存储秘钥时是那串随机字符。好处是授权查询只走同一个索引坏处是类型划分依赖auth_type字段所以代码里必须保证auth_type和bind_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 status1 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、账号、密码、status0封禁 1正常 2等待邮箱验证、group_id决定等级权限、wallet_balance额度。等级权限和购买套餐是两套逻辑等级权限决定能下载哪些应用源码、能否使用 API 对接时间套餐决定授权到期时间。很多二次开发把这两个概念混在一个字段里后面做“自助升级权限”时非常被动。自助入口不是简单的注册登录而是把“购买授权”拆成两个动作先购买套餐获得时长再提交域名或IP生成授权记录。如果把这两个动作合并成一步用户在没确定绑定域名时就会卡住。所以我个人在二次开发时会把auth_record的bind_value设为可空用户购买后可在用户中心自行填写或绑定。3.2 卡密兑换授权的并发安全代码卡密是线下分发最常用的方式很多人会先发一批卡密到渠道群再回到后台导入。卡密表必须保证 code 唯一且不可重用。兑换接口我按事务处理public function exchangeCard($uid, $cardCode) { $pdo Db::connect(); $pdo-beginTransaction(); try { $stmt $pdo-prepare( SELECT * FROM card WHERE code? AND status0 FOR UPDATE ); $stmt-execute([$cardCode]); $card $stmt-fetch(PDO::FETCH_ASSOC); if (!$card) { throw new Exception(卡密不存在或已使用); } $pdo-prepare( UPDATE card SET status1, 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锁住卡密行避免两个请求同时读到status0导致同一张卡被重复兑换。绑定值和有效期放到事务里一起写入auth_record这样即使兑换后生成授权失败卡密状态也会回滚不会出现“卡密已用但授权没到账”的争议。duration是套餐天数乘 86400 换算成秒如果你做小时套餐这里要改成$card[duration] * 3600并额外加一个单位字段。3.3 远程更新的版本比较与下载校验远程更新接口最容易翻车的地方是版本号比较。直接用字符串比较会把1.9.9判为大于1.10.0所以必须用 PHP 的version_comparepublic 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_updatetrue后下载新包比对 md5 一致再覆盖本地文件失败保留旧版本并输出一条错误提示。3.4 后台样式和前端资源怎么动源码包里那串console.js.bak、geet.css.bak、admin.css、style.min.css、bootstrap.min.css、layui.css已经说清楚了前端结构EasyWeb 基于 Layui 和 Bootstrap 拼装后台布局admin.css是布局入口style.min.css是业务页面通用样式jmstyle.min.css是组件主题。需要改后台皮肤时我建议先改admin.css里的 CSS 变量不要动bootstrap.min.css和layui.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 缺一不可只校验签名不够签名可以直接原样重放。成熟的接口需要同时校验时间戳和 noncepublic 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_value和ip两个字段的索引UA 属于低选择性字段建索引反而拖慢写入。5. API对接与额度体系把授权能力开放给第三方的技巧5.1 开放API的请求格式第三方想在自建站点里直接为用户购买授权最简单的方式是调用授权系统开放 API不用登录后台。签名规则和第四章一致请求示例curl -X POST https://auth.example.com/api/apply \ -d app_id1001 \ -d uid88 \ -d auth_type1 \ -d timestamp1739000000 \ -d nonce6c4a2abc \ -d sign1A2B3C4D5E...签名串要覆盖除 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 balancebalance-? 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。广告内容要支持在后台随时下架最简单做法是内容存数据库而不是写死在接口返回里。本文还有配套的精品资源点击获取