
去年在做一款基于 Fusion 2 的多人联机项目时我在自定义身份验证Custom Authentication上整整折腾了两天。官方 Demo 默认是匿名连接拿个 AppId 就能进房间但真要对接账号体系、做封号处理、同步玩家数据匿名连接完全不够用。我的做法是让玩家先通过自己的服务器登录换取 token再用这个 token 去连接 Photon Fusion 2由 Photon Cloud 回调我自己的认证服务器确认“这个人是不是合法用户”。这套机制在 Fusion 2 里就叫 Custom Authentication。如果你也在做 Unity 联机游戏并且不想被默认匿名验证卡住这篇笔记应该能帮你省下不少翻文档的时间。1. 为什么需要自定义身份验证1.1 Fusion 2 默认身份验证的局限Fusion 2 底层基于 Photon Realtime最省事的接入方式就是填一个 AppId然后直接调StartGame。在没有配置任何认证逻辑时Photon 会给每个客户端分配一个随机的 PlayerId。这个 PlayerId 只在当次连接中作为玩家标识存在你没办法把它当成真正的用户账号来用。玩家清缓存、换设备、重装游戏之后这个 ID 基本就会变化服务器没有任何稳定字段来识别同一个用户。更麻烦的是Fusion 的房主迁移、断线重连虽然做得不错但身份层面它是“认连接不认人”。如果两个玩家各自用匿名身份连进来你在游戏逻辑里无法判断他们是否对应数据库里的同一个人也就做不到跨设备恢复进度、同账号多处登录限制、封禁账号拒绝进入这些基础运营需求。所以只要产品需要账号体系默认匿名验证基本不可用。自定义身份验证的意义就是把“这个人是谁、能不能进”这个问题交给你自己的服务器。Photon Cloud 负责转发和落地你负责给出最终裁决。1.2 自定义认证能解决的几类真实需求第一类是账号绑定。玩家在客户端先用自建登录接口拿到 userId 和 token然后带着 token 去连 PhotonPhoton 回调我的服务器服务器确认 token 有效就放行Fusion 里就用这个 userId 作为玩家标识。这样无论玩家换了几次设备我在服务端都能认出来。第二类是封禁与黑名单。哪怕玩家的 token 本身有效如果服务端查到该账号已经被封禁或者设备在风控黑名单里直接返回ResultfalsePhoton 就不会放他进游戏。这套逻辑放在认证阶段比进了房间再踢人节省大量带宽和逻辑处理。第三类是第三方登录打通。比如玩家通过微信、QQ、Steam 之类平台登录后我的服务器先验证平台返回的票据再签发一个短期 token 给客户端客户端拿这个 token 去连 Photon。这样第三方平台的密钥不会暴露到客户端所有敏感验证都在自己的服务器上完成。第四类是设备级风控。认证阶段可以顺便校验客户端版本、IP 归属、设备型号发现异常就直接拒绝。有些自动化脚本连游戏都进不去比在游戏内部做反作弊省力得多。1.3 使用边界认证接口不是业务接口自定义认证本质上是一次轻量 HTTP 回调Photon 对回调响应时间是有要求的。如果你在认证接口里做数据库全表查询、同步调用第三方 HTTP 接口很容易触发 Photon 端超时客户端拿到的结果就是连接失败。我自己踩过这个坑。最初把“查询玩家所有装备和好友列表”的逻辑也放在认证阶段结果数据库数据量上来之后认证耗时从几十毫秒涨到两秒多线上开始频繁出现认证失败。后面把装备、好友这些数据挪到进入房间之后再通过游戏服务器单独下发认证接口稳定在 100ms 以内。记住这句话认证只回答“who are you”不回答“what do you have”。2. 自定义身份验证的完整链路2.1 客户端如何携带认证信息Fusion 2 客户端并不是直接把自定义认证服务器的地址告诉 Photon而是在StartGameArgs里传入AuthenticationValuesSDK 会在连接请求中自动携带这些信息。AuthenticationValues是 Photon Realtime 里的类认证类型要设置成CustomAuthenticationType.Custom然后通过SetAuthParameters传入自定义参数。这里有个容易忽略的细节参数不能随便用逗号拼接。我看到过有人把参数写成user01,token123Photon 默认按键值对解析时根本识别不了。正确做法是显式拼接成 query string 风格比如usernameuser01tokentoken123并且每个值都要单独做 URL 编码。客户端在拿到认证结果之前连接流程是阻塞的。也就是说你要在StartGame这一层等待 Photon 完成认证认证成功后才开始进入房间逻辑。如果认证失败玩家是进不了游戏流程的。2.2 Photon Cloud 到你的认证服务器Photon 收到客户端的连接请求后会查询当前 AppId 是否配置了自定义认证地址。如果配置了Photon Cloud 会向该地址发起 HTTP 请求同时把客户端传过来的参数附加到请求里。这一步发生在 Photon 自己的服务器上而不是客户端所以你的认证服务器默认拿不到客户端的真实 IP。如果业务需要 IP可以让客户端在参数里传但服务端不能完全信任这个值只能把它当作参考。重点来了Photon 要求回调地址必须是 HTTPS并且证书要能被公网正常验证。自签名证书、内网地址、localhost 统统不行。我刚开始在本地联调时用https://localhost:44300Photon 直接拒绝认证。后面我临时用内网穿透工具暴露一个公网 HTTPS 地址才把流程跑通。Photon 还会在请求里带上专门的认证 Header用来证明这个请求确实来自 Photon Cloud。我在 Demo 阶段先校验 Header 中的 secret 字段生产环境则必须按照官方文档校验完整签名和请求时间戳否则任何人都可以直接伪造请求绕过你的认证服务器。2.3 认证服务器返回结果的约定认证服务器返回的是 JSON。成功时的最小响应是{Result:true}但为了业务方便一般会带上UserId和Nickname{Result:true,UserId:db_uuid_12345,Nickname:playerA}失败时可以返回{Result:false,Message:banned user}Photon 拿到Resultfalse或者返回非 200 状态码就会判定认证失败并拒绝连接。如果你的认证服务器返回 200 但 JSON 格式解析不出来Photon 同样会判定失败。这里要特别注意 JSON 字段的大小写我自己因为把Result写成了result导致认证始终不通过后来看官方示例才发现字段名是约定好的不能随意修改。2.4 客户端如何拿到最终的认证结果客户端这边认证成功后会进入正常的连接流程你可以通过 Fusion 的OnPlayerJoined、OnShutdown等回调跟踪状态。认证失败时Photon Realtime 会触发失败回调Fusion 的连接过程会中断。很多新手在StartGame的返回值里只看到Okfalse就以为是自己房间名的问题实际上是卡在认证阶段。排查这个阶段的问题有个笨办法在启动NetworkRunner之前先把AuthenticationValues的内容完整打印出来。我被 token 编码问题坑了很久最后发现是 token 里有个号没被正确编码Photon 解析后把变成空格服务端验签一直失败。日志里看到SetAuthParameters之后的原始字符串基本就能定位是编码问题还是服务端验签问题。3. Fusion 2 客户端接入实操3.1 在 Photon Dashboard 配置自定义认证地址打开 Photon Dashboard找到你的 Fusion 2 应用进入“管理”菜单下的“自定义身份验证”设置页。需要填写两项回调 URL 和 Secret。URL 示例https://api.yourgame.com/photon/authSecret 建议用随机生成长字符串我一般用openssl rand -hex 32生成。Secret 要同时保存在服务端环境变量里不要写死在客户端代码或前端配置中。配置完成后Photon 并不会立刻开始回调必须等客户端带着认证值发起连接时才会触发。所以配置完后可以先不启动游戏直接用浏览器打开认证地址看能不能返回合法 JSON这样可以提前排查 URL 和服务端代码的问题。3.2 服务端认证接口示例ASP.NET Core我用 ASP.NET Core 写了一个非常精简的认证接口。服务端语言不一定要 C#这里只是因为它和 Unity 同语言Unity 开发者看起来最省力。[ApiController] [Route(api/[controller])] public class PhotonAuthController : ControllerBase { private readonly IUserAuthService _userAuthService; public PhotonAuthController(IUserAuthService userAuthService) { _userAuthService userAuthService; } [HttpGet(auth)] [HttpPost(auth)] public async TaskIActionResult Authenticate( [FromQuery] string username, [FromQuery] string token, CancellationToken cancellationToken) { var secret Environment.GetEnvironmentVariable(PHOTON_SECRET); var photonAuthHeader Request.Headers[X-Photon-Authentication].ToString(); if (string.IsNullOrEmpty(photonAuthHeader)) { return Unauthorized(new { Result false, Message Missing auth header }); } // 注意生产环境必须按官方文档校验完整的签名和时间戳。 // 这里只做最小验证目的是先把认证链路跑通。 if (!photonAuthHeader.StartsWith(secret)) { return Unauthorized(new { Result false, Message Secret mismatch }); } var result await _userAuthService.ValidateAsync(username, token, cancellationToken); if (!result.Allowed) { return Ok(new { Result false, Message result.Message }); } return Ok(new { Result true, UserId result.UserId, Nickname result.Nickname }); } }对应的服务逻辑大致是这样public class UserAuthService : IUserAuthService { private readonly IUserRepository _repository; public async TaskAuthResult ValidateAsync(string username, string token, CancellationToken ct) { var user await _repository.GetByUsernameAsync(username, ct); if (user null || !user.TokenEquals(token)) { return AuthResult.Fail(username or token error); } if (user.Banned) { return AuthResult.Fail(user banned); } return AuthResult.Success(user.Id, user.Nickname); } }看到没有认证接口只做三件事验证用户名密码或 token检查封禁状态返回稳定的 UserId。千万不要在接口里写日志表、道具表、好友列表这些重逻辑。3.3 Unity 客户端设置 AuthValues 并启动 Fusion客户端代码其实不长。关键点是构造AuthenticationValues并赋值给StartGameArgs.AuthValues。using Photon.Realtime; using Fusion; public class PhotonConnector : MonoBehaviour { [SerializeField] private NetworkRunner runner; public async void ConnectWithToken(string username, string token) { var auth new AuthenticationValues(); auth.AuthType CustomAuthenticationType.Custom; auth.SetAuthParameters($username{Uri.EscapeDataString(username)}token{Uri.EscapeDataString(token)}); var args new StartGameArgs { SessionName default-room, AuthValues auth, PlayerCount 0 }; var result await runner.StartGame(args); if (!result.Ok) { Debug.LogWarning($连接失败: {result.ShutdownReason}); } } }CustomAuthenticationType.Custom是一个枚举值它告诉 Photon 不要走默认匿名认证而是走你配置的自定义回调。SetAuthParameters接收的是字符串这个字符串会原样拼到 Photon 的认证请求里。需要特别提醒不同版本的 Fusion 2 SDK 对StartGameArgs.AuthValues的命名可能有差异。我用的版本是直接暴露AuthValues属性如果你的版本没有这个字段大概率是 SDK 版本过旧建议先升级到官方推荐版本再去翻对应版本的 API 文档。3.4 调试技巧先用浏览器打通认证链路我强烈建议在集成 Unity 之前先用浏览器打通认证链路。直接在浏览器地址栏输入完整的回调 URL并加上测试参数https://api.yourgame.com/photon/auth?usernametesttokendemo123看返回结果是不是标准 JSON。如果浏览器能通过说明服务端逻辑和 URL 配置没问题接下来再排查 Unity 端。我在真实项目中还会用 Postman 保存一组测试用例包括成功、封禁用户、用户名不存在、token 过期等场景。每次改完服务端代码先跑一遍 Postman确认接口不会因为输入变化返回异常状态码再回到 Unity 里测集成效率会高很多。4. 常见问题与排查实录4.1 常见现象速查表现象可能原因排查方向认证服务器日志没有任何请求客户端没有传入 AuthValues或 Dashboard 没配置 URL检查 StartGameArgs 和仪表盘配置浏览器访问 URL 正常Unity 连接失败客户端传参格式不对打印 SetAuthParameters 后的字符串确认 URL 编码Photon 回调能到服务器但结果始终 false服务端校验 secret 失败或返回 JSON 字段不对确认 Secret 一致检查 Result 字段大小写连接偶尔超时认证接口里有耗时操作给认证接口加耗时日志把重逻辑移出认证阶段同一玩家被识别为不同 ID没有正确返回 UserId或 UserId 不稳定固定用数据库主键而不是随机字符串自签名证书导致认证失败Photon 要求可信 CA 证书换正规 HTTPS 证书或临时用公网穿透工具联调这张表是我自己遇到过的场景整理出来的不一定覆盖全部情况。碰到问题先顺着表排查能省很多时间。4.2 我踩过的几个坑第一个坑是本地联调。Photon 回调不会访问内网地址所以开发阶段我选择用公网穿透工具把一个本地端口映射成公网 HTTPS 地址再把这个地址填到 Dashboard。这样我可以一边改服务端代码一边调试不用每次都部署到测试服务器。需要注意免费穿透域名经常变Dashboard 配置要跟着更新否则联调到一半突然认证失败。第二个坑是参数编码。token 这类敏感信息里经常出现、、等字符如果不做 URL 编码直接在 query string 里拼服务端拿到的参数就是错的。我在客户端用Uri.EscapeDataString编码服务端用内置的 URL 解码两边对齐之后问题才消失。第三个坑是返回 JSON 字段大小写。Photon 的文档里示例用的是驼峰命名但我一开始按照习惯写成小写result结果服务器返回了 200 但 Photon 就是不认。后来把字段名严格对齐官方示例才恢复正常。建议不管你的服务端用什么框架都统一输出驼峰格式的 JSON并且在序列化配置里忽略命名大小写减少这类问题。第四个坑是认证接口的日志。为了排查问题我在认证接口里打印了完整的Request.Headers和 Query 参数结果发现 token 全被记录到了日志文件。线上这是安全隐患。后来我把日志改成只记录 username、认证结果和耗时token 一律脱敏。4.3 上线前检查清单上线之前我建议按下面这个清单过一遍认证 URL 是公网可访问的 HTTPS证书由可信 CA 签发。Secret 没有出现在客户端代码或前端 bundle 里。认证接口有完整的签名校验和时间戳防重放逻辑。认证接口做了频率限制防止被恶意刷请求。日志不记录 token、密码等敏感信息。认证接口的 P99 响应时间小于 500ms。游戏服务器再次校验认证结果中的 UserId 与客户端的业务逻辑是否一致。准备好认证服务器的降级开关Photon 认证挂掉时能快速切换回备用方案。这份清单是我从上一次上线事故里总结出来的。当时只做了功能验证没考虑频率限制结果有玩家抓包后用脚本疯狂调认证接口把数据库连接占满了导致整个登录入口雪崩。5. 个人经验与后续扩展5.1 连接失效与重新认证自定义认证解决的是连接那一刻的身份校验不解决长期会话过期的问题。玩家可能连着游戏超过一小时认证 token 过期了但他的连接仍然有效。这里要分两种情况如果玩家不掉线可以不管一旦玩家断线重连就必须重新走一遍认证流程。我在项目里的做法是客户端在断线重连前先向自建服务器申请新的 token再拿着新 token 去重连 Fusion。千万不要在客户端缓存过期 token 后反复重试否则服务端验签一直失败玩家会卡在“重连中”界面。5.2 多端登录与同账号踢人自定义认证接口可以在认证时检查该 userId 是否已经有一个正在使用的 Photon 连接。如果存在可以主动拒绝新连接也可以允许新连接并把旧连接踢下线这取决于业务需求。Fusion 2 本身不提供直接通过 userId 踢人的 API但你可以结合自定义认证返回的 UserId在游戏逻辑里维护“当前在线玩家”的映射。服务端要踢人时向指定房间发一条自定义消息房间内持有该 UserId 的客户端再主动断线。这样做比在认证阶段直接拒绝更平滑玩家能收到明确的“账号已在其他设备登录”提示。5.3 别把认证接口变成万能接口最后唠叨一句自定义身份验证是一个入口但它不是数据中转站也不是防作弊的全部。认证只做一次之后玩家在游戏里做什么事还得靠游戏服务器持续校验。我现在的项目分了三层最前端是 Photon 自定义认证负责门禁中间是游戏逻辑服务器负责所有关键操作校验最后是异步数据服务负责存档、道具、排行榜。三层职责分开每一层都很轻。如果你发现认证接口里塞了一堆跟身份无关的逻辑建议拆出去重新设计。从最早被自签名证书折磨到后来把整条认证链路跑通我最深的体会有两点第一先跑通最小链路再补安全细节浏览器能验证成功、集成 Unity 能进房间比任何文档都管用第二认证接口一定要保持轻量它只是开始不是终点。