去年在做一款基于 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 去连 Photon,Photon 回调我的服务器,服务器确认 token 有效就放行,Fusion 里就用这个 userId 作为玩家标识。这样无论玩家换了几次设备,我在服务端都能认出来。
第二类是封禁与黑名单。哪怕玩家的 token 本身有效,如果服务端查到该账号已经被封禁,或者设备在风控黑名单里,直接返回Result=false,Photon 就不会放他进游戏。这套逻辑放在认证阶段,比进了房间再踢人节省大量带宽和逻辑处理。
第三类是第三方登录打通。比如玩家通过微信、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里传入AuthenticationValues,SDK 会在连接请求中自动携带这些信息。AuthenticationValues是 Photon Realtime 里的类,认证类型要设置成CustomAuthenticationType.Custom,然后通过SetAuthParameters传入自定义参数。
这里有个容易忽略的细节:参数不能随便用逗号拼接。我看到过有人把参数写成"user01,token123",Photon 默认按键值对解析时根本识别不了。正确做法是显式拼接成 query string 风格,比如"username=user01&token=token123",并且每个值都要单独做 URL 编码。
客户端在拿到认证结果之前,连接流程是阻塞的。也就是说,你要在StartGame这一层等待 Photon 完成认证,认证成功后才开始进入房间逻辑。如果认证失败,玩家是进不了游戏流程的。
2.2 Photon Cloud 到你的认证服务器
Photon 收到客户端的连接请求后,会查询当前 AppId 是否配置了自定义认证地址。如果配置了,Photon Cloud 会向该地址发起 HTTP 请求,同时把客户端传过来的参数附加到请求里。这一步发生在 Photon 自己的服务器上,而不是客户端,所以你的认证服务器默认拿不到客户端的真实 IP。如果业务需要 IP,可以让客户端在参数里传,但服务端不能完全信任这个值,只能把它当作参考。
重点来了:Photon 要求回调地址必须是 HTTPS,并且证书要能被公网正常验证。自签名证书、内网地址、localhost 统统不行。我刚开始在本地联调时用https://localhost:44300,Photon 直接拒绝认证。后面我临时用内网穿透工具暴露一个公网 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 拿到Result=false,或者返回非 200 状态码,就会判定认证失败并拒绝连接。如果你的认证服务器返回 200 但 JSON 格式解析不出来,Photon 同样会判定失败。这里要特别注意 JSON 字段的大小写,我自己因为把Result写成了result,导致认证始终不通过,后来看官方示例才发现字段名是约定好的,不能随意修改。
2.4 客户端如何拿到最终的认证结果
客户端这边,认证成功后会进入正常的连接流程,你可以通过 Fusion 的OnPlayerJoined、OnShutdown等回调跟踪状态。认证失败时,Photon Realtime 会触发失败回调,Fusion 的连接过程会中断。很多新手在StartGame的返回值里只看到Ok=false,就以为是自己房间名的问题,实际上是卡在认证阶段。
排查这个阶段的问题有个笨办法:在启动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 Task<IActionResult> 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 Task<AuthResult> 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?username=test&token=demo123看返回结果是不是标准 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 能进房间,比任何文档都管用;第二,认证接口一定要保持轻量,它只是开始,不是终点。