using Microsoft.AspNetCore.Mvc; using DorkNet.Server.Services; namespace DorkNet.Server.Controllers.Auth; /// /// auth.localhost/photon/customauth — endpoint Photon Cloud calls during a /// client connect when "Custom Authentication" is configured on the /// Photon AppId. /// /// Photon Cloud sends a GET (or POST) with the parameters the client /// attached via AuthenticationValues.AddAuthParameter. The /// DorkNet client mod (DorkNet.ClientMod) injects two: /// /// userid — the player's DorkNet account id (long). /// LoginLock — the per-session GUID stored in /// at /player/login time. /// /// /// Response shape (per /// https://doc.photonengine.com/en-us/realtime/current/reference/custom-authentication): /// /// ResultCode = 1 → success, allow client /// ResultCode = 2 → parameter invalid, reject /// ResultCode = 3 → auth failed, reject /// /// /// MODE: strict (2026-05-11). Rejects connects whose userid+LoginLock /// don't match the most-recent /player/login. The "Reject if Auth /// Failed" toggle in the Photon dashboard SHOULD also be on so a /// rejected ResultCode actually closes the Photon socket — without /// the toggle the rejection is informational only. /// /// Fail-open on Redis errors: /// returns true if Redis throws, so a transient infra blip doesn't /// mass-kick every player off Photon. /// [ApiController] public class PhotonCustomAuthController( PlayerPresenceService presence, IConfiguration config, ILogger log) : ControllerBase { [HttpGet("/photon/customauth")] [HttpPost("/photon/customauth")] public async Task Authenticate() { // Photon forwards AuthGetParameters as querystring and // AuthPostData as form body. Read both so it works whichever // mode the dashboard picks. // // Multi-value handling: Photon's dashboard URL template can // contain literal placeholders like ?userid={userid}, // and the watch ALSO ships AuthValues.AuthGetParameters // (which Photon appends to the URL). The two collide in the // querystring as userid={userid}&userid=230343 — both // values arrive on the same key. ASP.NET Core surfaces this // as a multi-value StringValues; we keep the array so // below can pick the first value // that's actually a usable string (skipping unsubstituted // {…} placeholders). var qs = Request.Query .ToDictionary(kv => kv.Key, kv => kv.Value.ToArray(), StringComparer.OrdinalIgnoreCase); Dictionary form = new(StringComparer.OrdinalIgnoreCase); if (Request.HasFormContentType) { try { var f = await Request.ReadFormAsync(); form = f.ToDictionary( kv => kv.Key, kv => kv.Value.ToArray()!, StringComparer.OrdinalIgnoreCase); } catch { /* best effort */ } } // Pick the first non-empty, non-placeholder value across // every alias for a parameter. A "placeholder" looks like // {userid} — Photon's dashboard URL template syntax // that Photon Cloud forgets to substitute when the same key // is also sent as an AuthGetParameter. Falling back to the // SECOND query value (the AuthGetParameter) gives us the // real id. static bool IsUsable(string? v) => !string.IsNullOrWhiteSpace(v) && !(v!.StartsWith('{') && v.EndsWith('}')); string? PickClean(params string[] keys) { foreach (var k in keys) { if (qs.TryGetValue(k, out var arr)) foreach (var v in arr) if (IsUsable(v)) return v; if (form.TryGetValue(k, out var farr)) foreach (var v in farr) if (IsUsable(v)) return v; } return null; } var userIdRaw = PickClean("userid", "userId", "UserId"); var loginLock = PickClean("LoginLock", "loginlock", "loginLock"); if (!config.GetValue("Photon:RequireCustomAuth", false)) { log.LogInformation( "[photon-auth] fail-open allow RequireCustomAuth=false userid={UserId} LoginLockPresent={Lock}", userIdRaw ?? "", !string.IsNullOrWhiteSpace(loginLock)); return Ok(new { ResultCode = 1, Message = "ok (custom auth disabled)", UserId = userIdRaw ?? string.Empty, }); } // Anonymous connect: pre-login Photon region pings + the watch's // initial NameServer probe both fire CustomAuth WITHOUT // AuthValues attached (the client mod only injects AuthValues // once /player/login has run and Core.LocalAccountId is set). // Returning ResultCode 1 here lets those bootstrap connects // succeed; the actual gameplay session has populated AuthValues // and goes through the strict path below. if (string.IsNullOrWhiteSpace(userIdRaw) && string.IsNullOrWhiteSpace(loginLock)) { log.LogDebug("[photon-auth] anonymous bootstrap connect — allow"); return Ok(new { ResultCode = 1, Message = "ok (anonymous)" }); } // Half-set is suspicious — either someone's spoofing AuthValues // by hand, or the plugin's injector partially fired. Reject so // we don't grant gameplay credentials to an unverifiable session. if (string.IsNullOrWhiteSpace(userIdRaw) || string.IsNullOrWhiteSpace(loginLock)) { log.LogWarning( "[photon-auth] partial auth — userid={UserId} LoginLockPresent={Lock} " + "query={Query} form={Form}", userIdRaw ?? "", !string.IsNullOrWhiteSpace(loginLock), System.Text.Json.JsonSerializer.Serialize(qs), System.Text.Json.JsonSerializer.Serialize(form)); return Ok(new { ResultCode = 2, Message = "userid and LoginLock must both be sent (or both absent for anonymous bootstrap)", }); } if (!long.TryParse(userIdRaw, out var playerId)) { log.LogWarning("[photon-auth] non-numeric userid={UserId}", userIdRaw); return Ok(new { ResultCode = 2, Message = $"userid '{userIdRaw}' is not a valid account id" }); } if (!presence.ValidateLock(playerId, loginLock)) { log.LogWarning( "[photon-auth] lock mismatch for player={PlayerId} — stale session, " + "different machine, or never logged in", playerId); return Ok(new { ResultCode = 3, Message = "LoginLock mismatch — sign in via /player/login first", }); } log.LogDebug("[photon-auth] OK player={PlayerId}", playerId); return Ok(new { ResultCode = 1, Message = "ok", UserId = playerId.ToString(), }); } }