简介:这是一套面向 iOS 网络验证开发者的完整插件授权验证解决方案,部署到自有服务器后即可通过后台管理应用与卡密,并一键云端编译生成授权插件注入目标 App,实现卡密验证。资源包共 1937 个文件,约 47.12MB,以 1320 个 PHP 后端文件与 173 个 JS 前端脚本为主体,辅以 json 配置、stub 与 dylib 插件产物、sql 建表脚本及 png、svg 等界面素材,覆盖服务端、管理后台与插件构建全链路。功能上包含云端插件构建(支持 arm64 与 arm64e 双架构、自定义构建 ID 前缀、终端风格实时日志与历史版本下载)、授权弹窗自定义、卡密批量生成与导入导出、设备在线监控与黑名单、心跳检测防共享,以及接口签名、防刷限频等安全机制。目前已有 208 人学习,适合需要自建授权体系、研究卡密与 UDID 绑定逻辑的开发者参考复用。
1. 从一次授权绕过说起:这套 iOS 网络授权验证源码到底解决什么问题
去年帮一个做工具类 App 的朋友排查问题,他的付费功能上线两周就被逆向出本地校验逻辑,改个 plist 就能白嫖。这类翻车在 iOS 独立开发里太常见了——本地校验天生是黑匣子,攻击者拿到 ipa 就能静态分析。后来我接触到一套 iOS 网络授权验证系统源码,核心思路是把授权判断从客户端搬到服务端,客户端只负责发请求、存票据、做本地缓存兜底。它解决的就是「离线校验被绕过」这个根问题,适合做订阅制工具、企业内部分发、以及需要按设备或按账号控制权限的 iOS 项目。这套源码不是某个具体 App 的成品,而是一套可复用的授权骨架:客户端有请求封装和票据管理,服务端有验证接口和状态返回,中间靠一套约定的签名和过期机制串起来。下面我按「它是什么 → 怎么跑起来 → 坑在哪 → 怎么加固」的顺序拆一遍,能直接抄的部分我都落成代码和参数说明。
2. 授权验证的通信骨架:请求签名、票据缓存与状态机
2.1 为什么授权判断必须放到服务端
本地校验的典型做法是把一个布尔值或者一段加密串写进 Keychain 或 plist,启动时读出来判断。问题在于,攻击者用 class-dump 或者 Hopper 反编译后,找到判断分支直接 patch 掉就行,成本极低。网络授权验证把决策权交给服务端:客户端每次启动或进入付费功能时,带上设备标识、账号票据、时间戳和签名去请求验证接口,服务端返回一个带过期时间的授权状态。客户端拿到状态后缓存到本地,在有效期内不再重复请求,过期或状态异常时重新拉取。
这个模型的关键在于:客户端不存储「是否授权」的最终结论,只存储「服务端上次告诉我什么」以及「这个结论什么时候过期」。即使攻击者改了本地缓存,也只能骗过自己,服务端那边该拒绝还是拒绝。常见做法是票据里带一个服务端签名的 token,客户端无法伪造,只能原样回传。
2.2 客户端请求封装与签名生成
下面这段是客户端发起验证请求的核心逻辑,用 Swift 写,负责拼参数、算签名、发请求、解析返回。签名的作用是防止请求被篡改,服务端收到后会用同样的规则重算一遍做比对。
// AuthClient.swift import Foundation import CryptoKit struct AuthRequest { let deviceId: String // 设备唯一标识,建议用 identifierForVendor let accountToken: String // 登录后服务端下发的账号票据 let timestamp: Int64 // 当前毫秒时间戳,防重放 let nonce: String // 随机串,每次请求不同 } final class AuthClient { private let appSecret = "your_app_secret_here" // 与服务端约定,不要硬编码在明显位置 private let verifyURL = URL(string: "https://your.domain.com/api/auth/verify")! // 生成签名:把关键字段按固定顺序拼接后做 SHA256 private func sign(_ req: AuthRequest) -> String { let raw = "\(req.deviceId)|\(req.accountToken)|\(req.timestamp)|\(req.nonce)|\(appSecret)" let digest = SHA256.hash(data: Data(raw.utf8)) return digest.map { String(format: "%02x", $0) }.joined() } func verify(completion: @escaping (Bool, Date?) -> Void) { let req = AuthRequest( deviceId: UIDevice.current.identifierForVendor?.uuidString ?? "", accountToken: TokenStore.shared.accountToken, timestamp: Int64(Date().timeIntervalSince1970 * 1000), nonce: UUID().uuidString ) var urlRequest = URLRequest(url: verifyURL) urlRequest.httpMethod = "POST" urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") let body: [String: Any] = [ "device_id": req.deviceId, "account_token": req.accountToken, "timestamp": req.timestamp, "nonce": req.nonce, "sign": sign(req) ] urlRequest.httpBody = try? JSONSerialization.data(withJSONObject: body) URLSession.shared.dataTask(with: urlRequest) { data, _, _ in guard let data = data, let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let authorized = json["authorized"] as? Bool else { completion(false, nil) return } // expire_at 是服务端返回的过期时间戳,单位秒 let expireAt = (json["expire_at"] as? TimeInterval).map { Date(timeIntervalSince1970: $0) } completion(authorized, expireAt) }.resume() } }逻辑说明:sign方法把设备号、账号票据、时间戳、随机串和密钥按固定顺序拼接后做 SHA256,服务端用同样顺序和密钥重算,不一致就拒绝。参数上,timestamp用来防重放,服务端一般会拒绝超过 5 分钟的请求;nonce保证同一秒内的多次请求签名不同。appSecret不要明文写在源码里,常见做法是拆成几段拼接或者从编译期注入,后面避坑章节会细说。
2.3 票据缓存与本地状态机
客户端拿到授权结果后不能每次都请求,否则离线就废了。合理做法是把结果和过期时间写进 Keychain,启动时先读缓存,没过期就直接用,过期了再走网络。下面是一个简化的状态机实现。
// TokenStore.swift import Foundation final class TokenStore { static let shared = TokenStore() private let cacheKey = "auth_cache_v1" var accountToken: String = "" struct CacheEntry: Codable { let authorized: Bool let expireAt: Date } // 读取本地缓存,返回 nil 表示需要重新请求 func validCache() -> CacheEntry? { guard let data = KeychainHelper.read(key: cacheKey), let entry = try? JSONDecoder().decode(CacheEntry.self, from: data) else { return nil } // 留 60 秒缓冲,避免边界时间点请求失败 return entry.expireAt.timeIntervalSinceNow > 60 ? entry : nil } func save(authorized: Bool, expireAt: Date) { let entry = CacheEntry(authorized: authorized, expireAt: expireAt) if let data = try? JSONEncoder().encode(entry) { KeychainHelper.write(key: cacheKey, data: data) } } }逻辑说明:validCache在过期时间上留了 60 秒缓冲,这是血泪经验——如果卡在过期瞬间发请求,网络抖动会导致用户被误判为未授权。expireAt由服务端控制,客户端不要自己算,否则改本地时间就能续期。Keychain 的读写封装这里省略,用系统 Security 框架即可,注意别用 UserDefaults,那个太容易被改。
3. 服务端验证接口:签名比对、过期控制与返回结构
3.1 接口的输入输出约定
服务端是整个授权系统的裁判,输入是客户端传来的设备号、账号票据、时间戳、随机串和签名,输出是授权状态和过期时间。下面用 Python Flask 写一个最小可跑的验证接口,逻辑清晰,方便你照着改成自己熟悉的框架。
# auth_server.py import hashlib import time from flask import Flask, request, jsonify app = Flask(__name__) APP_SECRET = "your_app_secret_here" # 必须与客户端一致 REPLAY_WINDOW_MS = 5 * 60 * 1000 # 5 分钟防重放窗口 def calc_sign(device_id, account_token, timestamp, nonce): raw = f"{device_id}|{account_token}|{timestamp}|{nonce}|{APP_SECRET}" return hashlib.sha256(raw.encode("utf-8")).hexdigest() @app.route("/api/auth/verify", methods=["POST"]) def verify(): data = request.get_json(force=True) device_id = data.get("device_id", "") account_token = data.get("account_token", "") timestamp = int(data.get("timestamp", 0)) nonce = data.get("nonce", "") sign = data.get("sign", "") # 1. 防重放:时间戳偏离过大直接拒绝 now_ms = int(time.time() * 1000) if abs(now_ms - timestamp) > REPLAY_WINDOW_MS: return jsonify({"authorized": False, "reason": "expired_request"}), 401 # 2. 签名比对 expected = calc_sign(device_id, account_token, timestamp, nonce) if expected != sign: return jsonify({"authorized": False, "reason": "bad_sign"}), 401 # 3. 查库判断账号票据是否有效(这里用伪代码代替真实查询) account = query_account(account_token) if not account or account["status"] != "active": return jsonify({"authorized": False, "reason": "invalid_account"}), 403 # 4. 返回授权状态和过期时间,过期时间由服务端决定 expire_at = time.time() + 3600 # 一小时有效期 return jsonify({"authorized": True, "expire_at": expire_at}) def query_account(token): # 实际项目里查数据库或缓存,这里返回一个示例 return {"status": "active"} if token else None if __name__ == "__main__": app.run(host="0.0.0.0", port=8080)逻辑说明:接口按四步走——防重放、签名比对、账号状态查询、返回结果。REPLAY_WINDOW_MS设成 5 分钟是常见值,太短会误伤网络慢的用户,太长会给重放留空间。expire_at由服务端算,客户端只负责存。返回结构里authorized是布尔值,reason只在失败时出现,方便客户端做区分处理,比如bad_sign可以触发重新登录,expired_request可以提示检查系统时间。
3.2 参数怎么调:有效期、重放窗口与设备绑定
几个关键参数需要根据业务调。授权有效期expire_at的间隔,工具类 App 一般设 1 到 24 小时,太短会增加服务端压力,太长会让封禁延迟生效。防重放窗口REPLAY_WINDOW_MS建议 3 到 5 分钟,配合客户端的时间同步。设备绑定策略上,可以在服务端记录device_id和账号的对应关系,限制一个账号最多绑几台设备,超出就拒绝。下面这张表是我在几个项目里用过的参数组合,可以直接参考。
| 参数 | 工具类 App | 企业内部分发 | 订阅制内容 |
|---|---|---|---|
| 授权有效期 | 24 小时 | 8 小时 | 1 小时 |
| 防重放窗口 | 5 分钟 | 3 分钟 | 5 分钟 |
| 设备绑定上限 | 3 台 | 1 台 | 2 台 |
| 缓存缓冲 | 60 秒 | 30 秒 | 60 秒 |
选型理由:工具类用户可能几天才打开一次,有效期太短会导致频繁请求;企业分发设备固定,可以绑死单设备;订阅制内容对实时性要求高,有效期短一点,封禁能快速生效。
3.3 客户端如何区分「网络失败」和「授权失败」
这是实际落地时最容易翻车的地方。网络请求失败和授权被拒绝是两回事,前者应该放行缓存,后者应该拦截。下面这段是处理逻辑。
func checkAuth(completion: @escaping (Bool) -> Void) { // 先看本地缓存 if let cache = TokenStore.shared.validCache() { completion(cache.authorized) return } // 缓存无效,走网络 AuthClient().verify { authorized, expireAt in if let expireAt = expireAt { // 服务端明确返回了结果,无论通过与否都存下来 TokenStore.shared.save(authorized: authorized, expireAt: expireAt) completion(authorized) } else { // 网络失败,没有拿到 expireAt,此时应该放行还是拦截? // 常见做法:如果之前有过授权记录,短暂放行;否则拦截 completion(false) } } }逻辑说明:关键判断是expireAt是否为 nil。服务端正常返回时一定带过期时间,网络失败时没有。这里我选择网络失败时拦截,因为工具类 App 对安全要求高。如果你的场景更看重可用性,可以改成读一个更早的缓存做兜底,但要在服务端加风控,比如记录异常请求频率。
4. 避坑与排查:签名对不上、时间漂移和缓存穿透
4.1 签名一直对不上,先查拼接顺序和编码
现象:客户端和服务端用同样的密钥和字段,签名就是不一致。原因通常是拼接顺序不同,或者字符串编码不一致。比如客户端用 UTF-8,服务端用了默认编码;或者字段之间分隔符一个是|一个是,。解决:把拼接规则写成一个文档,两端都按文档实现,调试时把原始拼接串打印出来逐字符比对。注意timestamp是整数还是字符串也会影响结果,统一成字符串再拼。
4.2 用户改系统时间导致授权异常
现象:部分用户反馈明明在有效期内却被判未授权。原因:客户端缓存的有效期判断依赖本地时间,用户手动改了系统时间就会错乱。解决:不要用本地时间做唯一判断依据,服务端返回的expire_at是绝对时间戳,客户端存下来后,每次用Date()比较。如果用户改了时间,请求到服务端时timestamp会偏离,服务端直接拒绝,客户端收到expired_request后提示用户检查系统时间,而不是直接判未授权。
4.3 缓存穿透:每次启动都请求服务端
现象:服务端日志显示每个用户每次启动都发验证请求,缓存没生效。原因:缓存写入失败或者读取时解码失败,导致每次都走网络。解决:检查 Keychain 写入是否成功,CacheEntry的 Codable 实现是否和写入时一致。常见错误是改了结构体字段但没升版本号,旧数据解码失败被当成无缓存。加一个版本号字段,解码失败时清掉旧缓存重新请求。
4.4 密钥硬编码被逆向提取
现象:攻击者从 ipa 里提取出appSecret,伪造请求。原因:密钥明文写在源码里。解决:不要把密钥当唯一防线,服务端还要做设备绑定、频率限制和账号状态校验。密钥本身可以拆成多段、运行时拼接,或者从编译期注入,增加提取成本。但记住,客户端没有绝对安全,服务端的多层校验才是关键。
4.5 返回结构变更导致老版本客户端崩溃
现象:服务端加了新字段,老版本客户端解析 JSON 时崩溃。原因:客户端用了强制解包或者假设字段一定存在。解决:所有字段用可选类型解析,缺失时走默认逻辑。服务端加字段要向后兼容,不要改已有字段的类型。上线前用老版本客户端跑一遍回归。
5. 进阶加固:把授权验证做成可观测、可灰度的闭环
前面跑通了基本流程,但真正上线后你会发现,光有验证接口不够,还得知道它有没有被绕过、封禁有没有生效、新版本会不会误伤。我一般会加三样东西:请求日志、灰度开关和本地降级策略。
请求日志不是简单记一条「验证成功」,而是把device_id、account_token的哈希、authorized结果、reason和请求耗时都记下来。这样当用户反馈「突然不能用了」,你能快速定位是签名问题、账号问题还是服务端故障。日志里不要记明文票据,用哈希代替,避免泄露。
灰度开关的作用是控制新验证逻辑的放量比例。比如你改了一版签名算法,不要一次性全量,先让 5% 的请求走新逻辑,观察错误率。实现上可以在服务端根据device_id的哈希取模,决定走新逻辑还是旧逻辑。客户端不需要改,服务端返回的expire_at和authorized结构保持一致就行。
本地降级策略是给网络异常准备的。当验证请求连续失败超过阈值,客户端可以进入一个「宽限期」,比如 24 小时内仍然放行已缓存过的授权,同时后台继续重试。宽限期结束后如果还没恢复,就拦截。这个策略要在服务端有对应记录,避免被滥用。
验证方法上,我习惯用 Charles 或 Proxyman 抓包看请求结构,用curl直接打接口验证签名逻辑。下面这条命令可以快速测服务端接口是否正常。
# 用 curl 模拟客户端请求,注意 timestamp 要填当前毫秒值 curl -X POST https://your.domain.com/api/auth/verify \ -H "Content-Type: application/json" \ -d '{ "device_id": "test-device-001", "account_token": "test-token", "timestamp": 1700000000000, "nonce": "abc123", "sign": "用同样规则算出来的签名" }'如果返回bad_sign,就检查签名规则;返回expired_request,就检查时间戳;返回invalid_account,就检查账号状态。这套排查顺序能覆盖大部分问题。
从那以后我每次接入授权验证,都会先把签名规则写成单元测试,两端各跑一遍,确认一致再联调。这个习惯帮我省了至少两次通宵排查。希望帮到你。
本文还有配套的精品资源,点击获取