
之前接了一个需要上线的轻度联机手游客户端用Unity联机方案选的Photon Fusion 2。开发到一半运营提了个需求玩家的账号体系要跟自家后端打通不能光靠昵称和匿名ID。我一开始想的很简单客户端登录完拿个Token直接塞进Session里不就行了结果一细看Fusion 2和Photon Cloud的认证机制发现事情没那么简单。Touch到Photon的Custom Authentication之后从后台配置、签名算法、服务端验签到客户端注入AuthValues一路踩了不少坑这篇把整个链路和实操心得完整记下来给同样在做Unity Photon Fusion 2自定义身份验证的朋友一个参考。本篇内容适合正打算接入Photon自定义认证、或者已经在官方文档里绕晕的开发者。我会先说清楚这个功能到底解决了什么问题然后拆解认证链路和签名细节再给出Unity端和服务端的可跑代码最后把我在实际项目里遇到的坑和排查方法整理成清单。1. 这个功能到底解决什么问题1.1 没有身份验证时联机后台有多“裸”很多刚上手Fusion 2的团队做的第一版联机往往是这样的客户端填个昵称Fusion启动时带上SessionName和UserId连上Photon Cloud就开始匹配房间。这样能跑通Demo但如果真的要上线问题一堆。举个例子玩家A想冒充玩家B进同一局游戏。在匿名连接模式下Photon并不验证客户端上报的用户ID只要两个人用同一个UserId连接Photon就会认为他们是同一个人。配合自定义属性、存档同步这些玩法身份冒用会让整个数据体系崩掉。更麻烦的是如果你不做任何认证任何人都可以伪造一个UserId连进来轻则串数据重则直接刷分成。自定义身份验证Custom Authentication要解决的就是这个关口问题让Photon Cloud在建立连接的时候回调你自己的后端服务器由你的后端来确认“这个客户端声称的用户身份是否可信”确认通过才放行。1.2 自定义认证与“客户端传玩家名”的本质区别这两者最大的区别在于信任来源。客户端传玩家名Photon照单全收自定义认证则在中间加了一个“裁判”。具体流程可以理解成三步客户端先在你的应用服务器完成一次登录拿到一个代表身份的票据通常是一串Token。客户端带着这个票据连接PhotonPhoton把你的AppId、认证参数、票据和签名转发给预先配置好的AuthURL。你的后端收到请求后验签、验票据、验业务状态然后告诉Photon“验证通过”或者“验证失败”。这样Photon不再盲信客户端而是把最终判定权交给了自己的服务器。消息链路上客户端确实还在直连Photon但身份可信度由你自己的后端背书两边职责清楚了很多。1.3 Fusion 2里认证发生在哪一步Fusion 2的启动流程里NetworkRunner.StartGame()会经历一个“连接到Photon主服务器”的阶段。自定义认证就发生在这个阶段之前。我自己的理解是Fusion把Photon Realtime的底层能力封装成了StartGameArgs其中就有一个AuthValues字段。你在启动Runner时把这个字段塞进去Fusion才会在建立连接前带着认证信息过去。如果这一步你漏了或者根本没有设置AuthValuesFusion会走默认的匿名连接。这也是很多教程里只讲连接、不讲认证的原因——默认流程直接把认证跳过了。2. 自定义认证的完整链路从客户端到你的服务器再到Photon2.1 带你走一遍核心流程我之前画过一张流程图贴在白板上这里用文字形式说清楚客户端拿到登录凭证比如id_token。客户端调用Fusion的StartGame()并通过AuthValues传入凭证。Fusion底层把这个认证请求发给Photon Cloud。Photon Cloud根据后台配置的AuthURL向你的服务器发起HTTP GET请求携带appId、authParameters、timestamp、sig四个参数。你的服务器先验签。签名合法再检查凭证的业务有效性比如Token过期没、用户是否被封禁。服务器返回HTTP 200 JSON或者返回403/401拒绝连接。Photon根据响应决定是否放行该客户端进入匹配流程。这里有一个容易误解的点客户端并没有直接请求你的AuthURL而是由Photon把你的认证参数转发给你的服务器。好处很明显你的AuthURL不会直接被客户端扫描而且签名的生成完全由Photon控制客户端没法伪造一份“看起来像服务端发起的请求”。2.2 关键签名算法这一步是核心中的核心Photon发起到你们AuthURL的回调时会带四个查询参数参数名说明appIdPhoton应用IDauthParameters客户端提交的认证参数会做URL编码timestampPhoton服务器当前的Unix时间戳单位秒sig签名用App Secret对特定字符串做HMAC-SHA256后再Base64 URL编码签名具体怎么算官方文档写得很清楚但细节特别容易出问题。构造原文的规则是HashString AppId \n AuthParameters \n Timestamp注意中间用的是换行符\n不是换行加空格也不是逗号。然后用你的App Secret作为HMAC密钥对HashString做HMAC-SHA256得到二进制摘要再做一次Base64编码。这里还有个容易翻车的点Photon发给你的sig是URL安全的Base64变体。标准Base64里的会被替换成-/会被替换成_末尾的通常会去掉。所以你服务端做验签时自己算完的Base64字符串也要做同样的替换并且移除末尾等号。我把这个验证函数直接贴进来const crypto require(crypto); function verifySignature(query, appSecret) { const { appId, authParameters, timestamp, sig } query; const hashString appId \n authParameters \n timestamp; const expected crypto .createHmac(sha256, appSecret) .update(hashString, utf8) .digest(base64) .replace(/\/g, -) .replace(/\//g, _) .replace(/$/, ); return safeEqual(sig, expected); } function safeEqual(a, b) { const bufA Buffer.from(a, base64); const bufB Buffer.from(b, base64); if (bufA.length ! bufB.length) return false; return crypto.timingSafeEqual(bufA, bufB); }如果用.replace(/\/g, -)的时候没有把Base64丢等号的问题考虑进去你会发现验证永远失败。我调试的时候卡在这个问题上卡了一下午后来打印出两边的字符串才看出差异。2.3 成功与失败各有什么后果签名业务校验都通过你的服务器返回HTTP 200同时给出JSON数据Photon就会认为认证成功。JSON里最常用的字段是UserId和NickName你可以让Photon用你服务端认定的UserId而不是客户端随手填的ID。返回403时Photon会把错误信息带到客户端连接流程中断。返回其他非200状态码时同理客户端会收到认证失败的回调。所以你的后端判定逻辑一定要严谨宁可拒绝不要恍惚。另外服务端返回JSON里除非明确需要透传业务数据否则不需要包含签名相关的任何东西。这个响应只是告知“是否放行”真正要不要信任这个玩家完全由你的后端说了算。3. 动手实现Unity/Fusion 2客户端要写什么代码3.1 Photon后台必须先做两个配置代码写之前先登录Photon Dashboard找到你的应用进入App Settings重点看两块Authentication Settings开启自定义认证Custom Authentication填上合法的AuthURL。这个URL必须是公网可访问的业务最好上HTTPS。我见过有人图省事填了http://localhost:8080/auth结果客户端连不上因为Photon服务器根本不和你本地网络互通。记下App Secret。这个Secret等同你后端和Photon之间的共享密钥千万不要写进客户端代码里否则任何人反编译就能伪造签名。后台改完配置不是即时生效的稍微等半分钟Photon那边配置同步后再测试。我建议在后台先做一次“Test Authentication”测试Dashboard里可以直接填测试参数看返回。这个功能排查签名问题特别有用能帮你区分是客户端传参问题还是服务端验签问题。3.2 构造AuthenticationValues的常见错误姿势Photon.Realtime里提供了AuthenticationValues类这是构造认证参数的主要入口。常见的错误有这么几种忘记设置AuthType CustomAuthenticationType.Custom导致Fusion走默认匿名流程。把App Secret直接塞进AddAuthParameter想着“服务端能收到就能验签”结果等于把密钥交了出去。用同一个AuthenticationValues实例跑多个连接字段被后续修改污染。正确的做法是构造一个专门的方法每次连接前创建新实例using Photon.Realtime; public static AuthenticationValues BuildAuthValues(string userId, string idToken) { var authValues new AuthenticationValues(); authValues.AuthType CustomAuthenticationType.Custom; authValues.UserId userId; authValues.AddAuthParameter(id_token, idToken); authValues.AddAuthParameter(platform, unity); return authValues; }AddAuthParameter可以加多个Photon会把它们拼接成authParameters。注意这些参数会明文出现在Photon回调请求里不要放密码、密钥之类的东西。敏感信息放进底层Token里让服务端自己去校验Token内容。3.3 把AuthValues传给StartGameArgs这里是Fusion 2的接入点。我刚开始没搞明白以为认证参数要在Photon客户端配置文件里设置后来看官方示例才发现直接在StartGameArgs里指定即可using Fusion; using UnityEngine; public class FusionAuthStarter : MonoBehaviour { [SerializeField] private NetworkRunner runner; public async void StartWithAuth(string userId, string idToken) { var authValues BuildAuthValues(userId, idToken); var args new StartGameArgs { GameMode GameMode.Shared, SessionName room_ userId, AuthValues authValues, Address wss://xxx.photonengine.io }; var result await runner.StartGame(args); if (result.Ok) { Debug.Log(认证成功已进入Photon大厅); } else { Debug.LogError($认证失败: {result.ShutdownReason}); } } }这里有几个细节值得注意StartGameArgs.AuthValues必须和AuthenticationValues实例对应缺一个都不会走自定义认证。result.Ok是Fusion 2里判断启动是否成功的方式我在实际项目中习惯把ShutdownReason打出来排查问题时信息更直观。如果认证失败Fusion会返回相应的失败原因客户端可以根据枚举值提示玩家重新登录而不是直接卡在连接界面。3.4 完整跑通的最小示例如果你的项目还没有接入任何登录流程这里给一个最小可跑的伪登录示例using System; using Fusion; using Photon.Realtime; using UnityEngine; public class AuthDemo : MonoBehaviour { [SerializeField] private NetworkRunner runner; private async void Start() { string fakeToken SimulateLoginAndGetToken(); var authValues BuildAuthValues(player_20240101, fakeToken); var args new StartGameArgs { GameMode GameMode.AutoHostOrClient, SessionName demo_room, AuthValues authValues }; var result await runner.StartGame(args); if (!result.Ok) { Debug.LogError($连接失败: {result.ShutdownReason}); } } private string SimulateLoginAndGetToken() { // 真实项目中这里应该调用你自己的登录API换取服务端签发的Token return token_from_your_server; } }注意一点我用GameMode.AutoHostOrClient是为了演示方便实际项目里根据玩法选择合适的模式。官方文档里的示例还会强调一点认证成功后Photon连接的UserId不再由客户端单独决定而是以服务端返回的为准。如果你在服务端返回了不同的UserId即使客户端传了别的ID也会被覆盖。这一点在账号绑定时非常有用。4. 服务端验签Node.js/Python二选一4.1 服务端的职责边界服务端是整个认证链路的“裁判”。它要做的只有几件事接收Photon转发的回调请求校验签名是否合法。校验业务凭证Token看看用户是否存在、是否被封禁。返回显式的认证结果给Photon。这里我特别想强调签名验证是第一步但不是全部。验签只能证明“这个请求确实来自Photon”不能证明“这个玩家身份有效”。业务层校验Token过期、用户封禁、设备风控必须在服务端做否则任何能拿到合法签名规则的人都能伪造一个有效认证。实际项目里我通常会把验签和后端业务校验拆成两个方法这样以后想扩充认证逻辑时不用动底层签名部分。4.2 一个可以直接抄的Node.js实现Node.js请求体解析用内置的http模块或者Express都可以。建议用Express路由写起来干净很多。核心逻辑const express require(express); const crypto require(crypto); const app express(); const APP_SECRET 从Dashboard复制过来的AppSecret; function base64UrlEncode(buffer) { return buffer.toString(base64) .replace(/\/g, -) .replace(/\//g, _) .replace(/$/, ); } function safeEqual(a, b) { const bufA Buffer.from(a, base64); const bufB Buffer.from(b, base64); if (bufA.length ! bufB.length) return false; return crypto.timingSafeEqual(bufA, bufB); } app.get(/auth, (req, res) { const { appId, authParameters, timestamp, sig } req.query; // 1. 验签 const hashString appId \n authParameters \n timestamp; const expectedSig base64UrlEncode( crypto.createHmac(sha256, APP_SECRET).update(hashString, utf8).digest() ); if (!safeEqual(sig, expectedSig)) { return res.status(401).json({ error: Invalid signature }); } // 2. 解析业务参数 const params new URLSearchParams(authParameters); const idToken params.get(id_token); const userId params.get(userId); // 3. 业务校验这里只是示例 if (!idToken) { return res.status(403).json({ error: Missing token }); } // 真实项目中这里要拿着idToken去认证服务器换用户信息 const validUser verifyBusinessToken(idToken); if (!validUser) { return res.status(403).json({ error: Invalid token }); } // 4. 返回成功与身份 res.json({ UserId: validUser.userId, NickName: validUser.nickName }); }); app.listen(8080, () console.log(Auth server listening on 8080));这段代码里最值得看的地方是我用safeEqual做字符串比较。常规比较存在理论上的时序攻击风险虽然实际利用难度高但做登录鉴权相关的服务能上timingSafeEqual就上。4.3 返回值与业务判断服务端返回给Photon的状态码和JSON内容决定了认证结果。状态码上面已经提过这里补充JSON字段的约定。UserId是最终被Photon采用的用户标识NickName是可选的显示名AuthData是可选的透传数据。如果在返回时你不想让Photon覆盖客户端传入的UserId可以不返回这个字段但通常我们都希望服务端主导身份所以建议显式返回。成功返回的JSON不一定非要是完整对象只返回一个空对象{}也能通过验证但这丢掉了控制权。我习惯把用户信息显式返回这样客户端后续做数据同步时可以直接拿服务端认定的身份来对齐。4.4 服务端时间与时间戳窗口回调里的timestamp是由Photon服务器生成的Unix秒级时间戳不是客户端时间。你的服务端完全可以用它来做请求新鲜度校验如果now - timestamp过大说明这个回调请求可能是重放攻击直接拒绝。这里有一个容易踩的坑Photon服务器的时钟和你们机房服务器的时钟可能有一定偏差而且手机端如果修改了本地时间也不应该影响这个判断。所以时间窗口不要卡得太死。我项目里用600秒窗口也就是10分钟基本上既能挡住陈旧重放又能兼容时钟抖动。const now Math.floor(Date.now() / 1000); if (Math.abs(now - Number(timestamp)) 600) { return res.status(401).json({ error: Timestamp expired }); }注意Math.abs可以用在双向偏差这样即使Photon和你的服务端之间有几秒级偏差也能容忍。如果有人恶意重放一个10分钟前的请求也过不了这一关。5. 我在实战中踩过的坑和排查清单5.1 签名拼接顺序绝对别改这个坑我记忆太深刻了。当时服务端同事觉得AppId \n AuthParameters \n Timestamp这个顺序不好看把AuthParameters和Timestamp换了个位结果所有请求全部验签失败。后来发现就是拼接顺序的问题。官方文档明确写了这个顺序而且Photon生成签名时就是按这个顺序算的。你的服务端必须保持完全一致的拼接顺序多一个空格少一个换行都不行。排查建议第一次接的时候在服务端把hashString打印出来肉眼检查有没有多余空格、换行符是否真的存在。拼错了90%是看不见的字符问题。5.2 Base64的加号、斜杠、等号在URL里都会咬人Photon回调URL里的sig参数是URL安全Base64我前面提过一次这里专门展开。如果你在服务端用普通Base64编码算完直接比对会发现签名时对时不常对。原因就藏在字符串处理里在URL里会被解码成空格。/会破坏URL路径。是参数分隔符末尾的等号可能被丢弃。URL会用百分号编码比如%2B和在parse后内容不同。所以必须先把Base64输出里的替换成-/替换成_去掉末尾的等号再和自己算出的签名比对。我在项目里把这个转换函数固定成一个工具方法所有验签入口统一调用避免不同语言、不同服务之间出现差异。5.3 Photon怎么通知你认证失败认证失败后客户端在Fusion 2里的表现一般是StartGame返回失败ShutdownReason会包含一个认证相关的枚举。有些时候你光看状态码不知道具体原因这时候把服务端的返回信息打出来就非常重要。我习惯在服务端返回不同的错误原因状态码含义常见场景200认证通过正常放行401验签失败App Secret不匹配、签名拼接错误403业务拒绝Token过期、用户被封禁500服务端异常后端接口报错然后在客户端把ShutdownReason和HTTP状态码一起记到日志里。联调阶段能省不少事。5.4 AuthURL回调频率与超时Photon的自定义认证是每次连接都会回调一次不是每次进房间都回。也就是说玩家每次打开游戏、重新连上Photon主服务器你的AuthURL都会被请求一次。如果你们的后端在高峰期扛不住这个请求量或者某个玩家短时间内频繁重连可能会出现回调超时。Photon那边有响应超时限制具体值会随版本调整总之后端响应必须快。我建议把AuthURL接口做成纯校验式不要在这个接口里做重型查询复杂的业务数据放Redis或缓存里降低响应时间。曾经遇到过一个极端的坑后端在这个接口里同步调用了用户画像接口画像接口又调了另一个数据库结果链路过长Photon那边已经超时了客户端一看认证失败就重连越重连越慢形成恶性循环。5.5 常被忽略的App Secret泄露通道Photon的App Secret是你的服务端和Photon之间的共享密钥它一旦泄露别人就可以自己伪造合法签名绕过业务校验。最常见的泄露方式有三种把APP_SECRET写在Unity客户端代码里比如一个static string常量。反编译Apk后一抓一个准。把包含App Secret的配置文件提交到Git仓库然后仓库设成public。这个比上一种更常见。让客户端直接请求一个“验签接口”把App Secret放在这个接口服务里但接口没有做访问控制被人刷爆。正确做法是App Secret只存在于服务端客户端通过业务API换取Token然后把这个Token传给Photon。验签只在服务端发生。如果实在需要在客户端做本地验签跑通开发流程至少要把Secret放在一个独立配置节点里并且标记为“仅开发用”打包时排除。6. 哪些项目真需要自定义认证以及哪些不该用6.1 先用三个问题判断是否需要我在带项目的时候通常会先问团队三个问题你的游戏是否需要账号体系你是否需要服务端控制玩家身份而不仅仅是让客户端自报ID你是否有多平台登录入口比如微信登录、手机号登录、苹果登录需要把多端身份统一如果这三个问题里至少两个答案是“是”那自定义认证大概率是必要的一环。反之如果只是做一个局域网DEMO、内部测试工具那搞自定义认证反而拖慢进度用默认匿名连接就行。Fusion 2本身也提供了简单的Session机制没有身份验证时也能跑起来所以这块取舍是纯需求驱动的不是技术驱动。6.2 常见替代方案快速对比如果你只是想区分不同玩家不想引入复杂认证有几个替代方案值得参考方案成本安全等级适用场景默认匿名连接0极低测试、原型自定义认证中高正式上线、多平台账号服务器权威房间较高最高需要反作弊的高度竞技这里的关键是“安全等级”不是绝对概念。自定义认证管的是身份真伪不管玩家在房间内的行为。就算你认证做得很严外挂依然可以通过篡改内存、改包等方式影响游戏逻辑。所以自定义认证只是第一道门不是反作弊的全部。6.3 从实战角度给一个选型建议如果你决定接入我的建议是把认证服务端和Fusion启动流程封装成一个独立模块不要散落在各处。比如做一个AuthService类负责和自家后端交换Token再做一个FusionConnectionService负责启动Runner二者只通过一个AuthResult对象交互以后换认证方式也不用动Fusion相关代码。我在当前项目里就是这么组织的。前端只关心“登录成功没”Fusion只关心“认证参数是啥”服务端只关心“签名和业务令牌对不对”。Debug的时候问题能快速定位到第几层。另外强调一句自定义认证的配置分布在Photon后台、Unity侧、服务端三处任何一个环节掉链子客户端都只会看到一个笼统的“认证失败”。所以每次改动配置后先把服务端接口单独用Postman模拟测一遍再走Fusion流程排查效率高很多。最后聊点个人的体会。我把这整套流程跑通之后最大感受是自定义认证并没有让代码变复杂多少但它让联机系统的身份边界清晰了。玩家能是谁、不能是谁由自己的服务器说了算而不是把信任交给客户端自觉。这种掌控感对上线项目非常关键。如果你正在做Fusion 2的联机项目建议早一点把认证链路设计进去别等隐私泄露或者账号串数据的事故发生了再回头补。