mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
Import december-2020-12-18 from private repo (scrubbed 2026-05-27)
Targets Rec Room 2020.12.18 client.
Re-import after adding:
- DorkNet.ClientMod/dorknet-clientmod.json.template — config schema
the launcher's manifest-driven patcher renders into UserData/.
- tools/build-patcher-zip.ps1 — produces the dorknet-clientpatch-{key}.zip
Release asset the launcher fetches.
- .github/workflows/release.yml — auto-publishes both Release assets
on v1-december-* tag push.
Scrubbed via maintainer-side scrub-for-public.ps1: same drop rules as
the prior import, plus build-patcher-zip.ps1 added to the tools/
allow-list so it survives the scrub.
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
|
||||
namespace DorkNet.Server.Controllers.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// auth.rec.net/account/me/* — password and account-recovery endpoints
|
||||
/// the client invokes from the in-game settings screen. Real persistence:
|
||||
/// the password is stored as a BCrypt hash on
|
||||
/// <see cref="DorkNet.Server.Data.Entities.PlayerEntity.PasswordHash"/>;
|
||||
/// HasPassword reflects whether the column is non-null; ChangePassword
|
||||
/// requires the old password to match (or for the column to be null —
|
||||
/// first-time set); ResetPassword is admin-gated for now.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class AuthAccountController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
/// <summary>GET /account/me/haspassword — returns whether the
|
||||
/// caller has a password set. Client uses
|
||||
/// <c>ExpectPrimitiveResponse<bool></c> so the body must be
|
||||
/// the literal JSON <c>true</c> or <c>false</c>.</summary>
|
||||
[HttpGet("/account/me/haspassword")]
|
||||
[HttpGet("/api/platformlogin/haspassword")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> HasPassword()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var hasPwd = await db.Players
|
||||
.Where(p => p.Id == pid)
|
||||
.Select(p => p.PasswordHash != null)
|
||||
.FirstOrDefaultAsync();
|
||||
return Content(hasPwd ? "true" : "false", "application/json");
|
||||
}
|
||||
|
||||
/// <summary>POST /account/me/changepassword (and aliases). When the
|
||||
/// account already has a password the caller must supply the old
|
||||
/// one; first-time creation skips that check. Form-encoded body.</summary>
|
||||
[HttpPost("/account/me/changepassword")]
|
||||
[HttpPost("/account/me/createpassword")]
|
||||
[HttpPost("/api/platformlogin/password")]
|
||||
[HttpPut("/api/platformlogin/password")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> ChangePassword(
|
||||
[FromForm] string? oldPassword,
|
||||
[FromForm] string? newPassword)
|
||||
{
|
||||
if (string.IsNullOrEmpty(newPassword) || newPassword.Length < 6)
|
||||
return Ok(new { success = false, error = "password_too_short" });
|
||||
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == pid);
|
||||
if (player is null) return Unauthorized();
|
||||
|
||||
// First-time create: PasswordHash is null and oldPassword is
|
||||
// ignored. Subsequent changes require the existing password to
|
||||
// verify so a stolen JWT can't silently rotate the password.
|
||||
if (!string.IsNullOrEmpty(player.PasswordHash))
|
||||
{
|
||||
if (string.IsNullOrEmpty(oldPassword) ||
|
||||
!BCrypt.Net.BCrypt.Verify(oldPassword, player.PasswordHash))
|
||||
return Ok(new { success = false, error = "old_password_mismatch" });
|
||||
}
|
||||
|
||||
player.PasswordHash = BCrypt.Net.BCrypt.HashPassword(newPassword, workFactor: 11);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
/// <summary>POST /account/me/resetpassword — sends a reset link.
|
||||
/// Without an SMTP integration we have nothing to send to, so this
|
||||
/// is admin-only: an admin can clear the password column on any
|
||||
/// account from the admin tab and the user re-creates it via
|
||||
/// changepassword on next login. Anonymous calls 401.</summary>
|
||||
[HttpPost("/account/me/resetpassword")]
|
||||
[HttpPost("/api/platformlogin/resetpassword")]
|
||||
[Authorize]
|
||||
[AdminOnly]
|
||||
public async Task<IActionResult> AdminResetPassword(
|
||||
[FromForm] long? targetPlayerId)
|
||||
{
|
||||
if (targetPlayerId is null)
|
||||
return Ok(new { success = false, error = "missing_target" });
|
||||
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == targetPlayerId);
|
||||
if (player is null) return Ok(new { success = false, error = "unknown_player" });
|
||||
player.PasswordHash = null;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Models.Auth;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// auth.rec.net — OAuth-style token endpoint used by 2019+ builds alongside PlatformLogin.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class AuthController(
|
||||
PlayerService playerService,
|
||||
AuthService authService,
|
||||
LevelService level,
|
||||
OrphanAccountTracker orphans,
|
||||
ServerSettingsService settings,
|
||||
DorkNet.Server.Data.DorkNetDbContext db,
|
||||
DomainConfig domain,
|
||||
ILogger<AuthController> logger) : ControllerBase
|
||||
{
|
||||
/// <summary>Award the per-day first-login XP if the calling
|
||||
/// player hasn't been seen yet today (UTC).</summary>
|
||||
private async Task TryAwardLoginXpAsync(long playerId)
|
||||
{
|
||||
var lastSeen = await db.Players
|
||||
.Where(p => p.Id == playerId)
|
||||
.Select(p => p.LastSeenAt)
|
||||
.FirstOrDefaultAsync();
|
||||
if (lastSeen.Date < DateTime.UtcNow.Date)
|
||||
await level.AwardXpAsync(playerId, LevelService.FirstLoginXp, "first_login_today");
|
||||
}
|
||||
// OAuth token endpoint. The 2020 client uses three grant types here:
|
||||
// - grant_type=cached_login → user clicked a remembered account on the
|
||||
// account-selection screen. Body includes `account_id` (and platform
|
||||
// creds). MUST issue a token for THAT specific account; otherwise the
|
||||
// client receives a JWT for a different account and prompts the user
|
||||
// to create a new one (because the JWT identity doesn't match what
|
||||
// they picked).
|
||||
// - grant_type=refresh_token → re-issue access token from the existing
|
||||
// refresh JWT.
|
||||
// - grant_type=password → first-time sign-in, resolve via deviceId
|
||||
// (preferred) or fall back to a stable synthetic id derived from
|
||||
// username so legacy callers don't share accounts.
|
||||
[HttpPost("/connect/token")]
|
||||
[Consumes("application/x-www-form-urlencoded")]
|
||||
public async Task<IActionResult> Token(
|
||||
[FromForm] string? grant_type,
|
||||
[FromForm] string? username,
|
||||
[FromForm] string? password,
|
||||
[FromForm] string? refresh_token,
|
||||
[FromForm] string? deviceId,
|
||||
[FromForm(Name = "device_id")] string? device_id_snake,
|
||||
[FromForm] int platform = 0,
|
||||
[FromForm] string? platformId = null,
|
||||
[FromForm(Name = "platform_id")] string? platform_id_snake = null,
|
||||
[FromForm] long? account_id = null)
|
||||
{
|
||||
// The client mixes camelCase and snake_case parameter naming
|
||||
// depending on grant type — accept both and merge.
|
||||
var effDeviceId = !string.IsNullOrWhiteSpace(deviceId) ? deviceId : device_id_snake;
|
||||
var effPlatformId = !string.IsNullOrWhiteSpace(platformId) ? platformId : platform_id_snake;
|
||||
|
||||
if (string.Equals(grant_type, "cached_login", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (account_id is not long pickedId)
|
||||
return BadRequest(new { error = "invalid_request", error_description = "missing account_id" });
|
||||
var picked = await playerService.GetByIdAsync(pickedId);
|
||||
if (picked is null)
|
||||
return Unauthorized(new { error = "invalid_grant", error_description = "unknown account_id" });
|
||||
|
||||
// Orphan cleanup: if the watch just created a brand-new
|
||||
// account on account/create as part of its boot probe, but
|
||||
// the user actually picked a different cached account here,
|
||||
// the just-created row is unused. Delete it so the DB
|
||||
// doesn't accumulate one orphan per game launch. When the
|
||||
// user is genuinely creating a new account, the picked id
|
||||
// matches the just-created id and we leave it.
|
||||
var pendingId = orphans.PeekPending(effDeviceId, effPlatformId);
|
||||
if (pendingId is long orphanId && orphanId != pickedId)
|
||||
{
|
||||
await playerService.DeleteOrphanAsync(orphanId);
|
||||
logger.LogInformation(
|
||||
"[orphan-cleanup] cached_login picked {PickedId}; deleted just-created {OrphanId}",
|
||||
pickedId, orphanId);
|
||||
}
|
||||
orphans.Clear(effDeviceId, effPlatformId);
|
||||
|
||||
await TryAwardLoginXpAsync(picked.Id);
|
||||
await playerService.TagPlatformAsync(picked.Id, platform, effPlatformId);
|
||||
var (acc1, refr1) = authService.GenerateTokenPair(picked.Id);
|
||||
return Ok(new
|
||||
{
|
||||
access_token = acc1,
|
||||
token_type = "Bearer",
|
||||
expires_in = 43200,
|
||||
refresh_token = refr1,
|
||||
scope = "openid profile",
|
||||
});
|
||||
}
|
||||
|
||||
if (string.Equals(grant_type, "refresh_token", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(refresh_token))
|
||||
return BadRequest(new { error = "invalid_request" });
|
||||
var resolved = authService.ValidateToken(refresh_token);
|
||||
if (resolved is not long refreshedId)
|
||||
return Unauthorized(new { error = "invalid_grant" });
|
||||
var existing = await playerService.GetByIdAsync(refreshedId);
|
||||
if (existing is null)
|
||||
return Unauthorized(new { error = "invalid_grant" });
|
||||
var (acc, refr) = authService.GenerateTokenPair(existing.Id);
|
||||
return Ok(new
|
||||
{
|
||||
access_token = acc,
|
||||
token_type = "Bearer",
|
||||
expires_in = 43200,
|
||||
refresh_token = refr,
|
||||
scope = "openid profile",
|
||||
});
|
||||
}
|
||||
|
||||
// password grant: two paths.
|
||||
// • Username + password supplied: real password verify against
|
||||
// PlayerEntity.PasswordHash. Wrong password → 401.
|
||||
// • No password: legacy device-id synth path for the 2020
|
||||
// client's first-launch flow where the player hasn't set a
|
||||
// password yet (HasPassword returns false in the watch UI
|
||||
// and prompts them to create one). Resolves the existing
|
||||
// account (or creates a fresh one) by deviceId.
|
||||
if (!string.IsNullOrEmpty(password) && !string.IsNullOrEmpty(username))
|
||||
{
|
||||
var byName = await playerService.GetByUsernameAsync(username);
|
||||
if (byName is null || byName.PasswordHash is null)
|
||||
return Unauthorized(new
|
||||
{
|
||||
error = "invalid_grant",
|
||||
error_description = "unknown_user_or_no_password",
|
||||
});
|
||||
if (!BCrypt.Net.BCrypt.Verify(password, byName.PasswordHash))
|
||||
return Unauthorized(new
|
||||
{
|
||||
error = "invalid_grant",
|
||||
error_description = "password_mismatch",
|
||||
});
|
||||
// Tag the platform so the next boot's cached-login lookup
|
||||
// finds this account — without this the watch's "remembered
|
||||
// accounts" prompt would stay empty after a manual sign-in.
|
||||
await playerService.TagPlatformAsync(byName.Id, platform, effPlatformId);
|
||||
await TryAwardLoginXpAsync(byName.Id);
|
||||
var pair = authService.GenerateTokenPair(byName.Id);
|
||||
return Ok(new
|
||||
{
|
||||
access_token = pair.AccessToken,
|
||||
token_type = "Bearer",
|
||||
expires_in = 43200,
|
||||
refresh_token = pair.RefreshToken,
|
||||
scope = "openid profile",
|
||||
});
|
||||
}
|
||||
|
||||
// Device-id fallback path. Prefer the real deviceId; fall back
|
||||
// to a stable legacy synthesis from username so older callers
|
||||
// don't accidentally share an account.
|
||||
var effectiveDeviceId = !string.IsNullOrWhiteSpace(effDeviceId)
|
||||
? effDeviceId
|
||||
: $"legacy-{(username ?? "anonymous").ToLowerInvariant()}";
|
||||
|
||||
// Honour the admin "signups disabled" toggle. If the device
|
||||
// hasn't logged in before, GetOrCreateByDeviceAsync would mint
|
||||
// a fresh account here — same outcome as hitting
|
||||
// /account/create directly, which the toggle blocks. Refuse
|
||||
// BEFORE creation so the flag isn't bypassable.
|
||||
if (await settings.AreSignupsDisabledAsync())
|
||||
{
|
||||
var existing = await playerService.GetByDeviceAsync(effectiveDeviceId);
|
||||
if (existing is null)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"[auth] /connect/token device fallback refused — signups disabled (device={Device})",
|
||||
effectiveDeviceId);
|
||||
return Unauthorized(new
|
||||
{
|
||||
error = "signups_disabled",
|
||||
error_description = "Account creation is currently disabled by the server admin.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var player = await playerService.GetOrCreateByDeviceAsync(
|
||||
deviceId: effectiveDeviceId,
|
||||
platform: platform,
|
||||
platformId: effPlatformId,
|
||||
displayName: username);
|
||||
await TryAwardLoginXpAsync(player.Id);
|
||||
var (access, refresh) = authService.GenerateTokenPair(player.Id);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
access_token = access,
|
||||
token_type = "Bearer",
|
||||
expires_in = 43200,
|
||||
refresh_token = refresh,
|
||||
scope = "openid profile",
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("/connect/userinfo")]
|
||||
public async Task<IActionResult> UserInfo()
|
||||
{
|
||||
var auth = Request.Headers.Authorization.ToString();
|
||||
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
return Unauthorized();
|
||||
var token = auth["Bearer ".Length..].Trim();
|
||||
var id = authService.ValidateToken(token);
|
||||
if (id is not long playerId) return Unauthorized();
|
||||
var player = await playerService.GetByIdAsync(playerId);
|
||||
if (player is null) return Unauthorized();
|
||||
return Ok(new { sub = player.Id.ToString(), name = player.Username });
|
||||
}
|
||||
|
||||
// Discovery document — issuer + endpoints derived from
|
||||
// DomainConfig so a single DORKNET_DOMAIN env var keeps the
|
||||
// OpenID document pointed at the live deployment.
|
||||
[HttpGet("/.well-known/openid-configuration")]
|
||||
public IActionResult OpenIdConfig()
|
||||
{
|
||||
var base_ = $"https://{domain.Sub("auth")}";
|
||||
return Ok(new
|
||||
{
|
||||
issuer = base_,
|
||||
token_endpoint = $"{base_}/connect/token",
|
||||
userinfo_endpoint = $"{base_}/connect/userinfo",
|
||||
});
|
||||
}
|
||||
|
||||
// 2020.12 added an EAC challenge step in the login flow. The client GETs
|
||||
// a challenge string and passes it to EACManager.GenerateChallengeResponse,
|
||||
// which calls System.Convert.FromBase64CharPtr on the string. So the
|
||||
// returned challenge MUST be valid base64 — sending arbitrary text like
|
||||
// "dorknet-eac-disabled" throws FormatException client-side and kicks
|
||||
// the login. 24 chars of base64 'A's decodes to 18 zero bytes; matches
|
||||
// the rough size of a real EAC handshake nonce.
|
||||
[HttpGet("/eac/challenge")]
|
||||
[HttpPost("/eac/challenge")]
|
||||
public IActionResult EacChallenge()
|
||||
=> Content("\"AAAAAAAAAAAAAAAAAAAAAAAA\"", "application/json");
|
||||
|
||||
// Cached-logins lookup — boot-sequence call from ScreenAccountSelectionManager.
|
||||
// URL: GET auth.rec.net/cachedlogin/forplatformid/{platform}/{platformId}
|
||||
// Returns List<Login.CachedLogin>. Empty array = no remembered accounts.
|
||||
//
|
||||
// Real lookup: any account whose last platformlogin matched
|
||||
// (platform, platformId) shows up here, sorted most-recent-first. On a
|
||||
// single-Steam-emu setup that's one row; on a multi-account dev box
|
||||
// it's the full list the user has cycled through.
|
||||
[HttpGet("/cachedlogin/forplatformid/{platform:int}/{platformId}")]
|
||||
public async Task<IActionResult> CachedLoginsForPlatform(int platform, string platformId)
|
||||
{
|
||||
var players = await playerService.GetCachedLoginsAsync(platform, platformId);
|
||||
return Ok(players.Select(p => ToCachedLogin(p, platform, platformId)).ToList());
|
||||
}
|
||||
|
||||
/// <summary>POST <c>cachedlogin/forplatformids</c> — bulk variant
|
||||
/// the watch uses when hydrating multiple platform-id mappings
|
||||
/// at once (e.g. populating a friend list from Steam ids). Body
|
||||
/// is form-urlencoded <c>platform=&platformIds=a,b,c</c> or
|
||||
/// JSON with the same shape. Returns the flattened
|
||||
/// <c>List<CachedLogin></c> across every matched id.</summary>
|
||||
[HttpPost("/cachedlogin/forplatformids")]
|
||||
[HttpGet("/cachedlogin/forplatformids")]
|
||||
public async Task<IActionResult> CachedLoginsForPlatformIds()
|
||||
{
|
||||
int platform = 0;
|
||||
string? rawIds = null;
|
||||
try
|
||||
{
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
int.TryParse(Request.Form["platform"].ToString(), out platform);
|
||||
rawIds = Request.Form["platformIds"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(rawIds))
|
||||
rawIds = Request.Form["PlatformIds"].ToString();
|
||||
}
|
||||
}
|
||||
catch { /* not form */ }
|
||||
if (string.IsNullOrWhiteSpace(rawIds))
|
||||
{
|
||||
int.TryParse(Request.Query["platform"], out platform);
|
||||
rawIds = Request.Query["platformIds"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(rawIds))
|
||||
rawIds = Request.Query["PlatformIds"].ToString();
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(rawIds)) return Ok(Array.Empty<object>());
|
||||
|
||||
var ids = rawIds
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.ToList();
|
||||
var results = new List<CachedLogin>();
|
||||
foreach (var pid in ids)
|
||||
{
|
||||
var players = await playerService.GetCachedLoginsAsync(platform, pid);
|
||||
foreach (var p in players)
|
||||
results.Add(ToCachedLogin(p, platform, pid));
|
||||
}
|
||||
return Ok(results);
|
||||
}
|
||||
|
||||
private static CachedLogin ToCachedLogin(PlayerEntity p, int platform, string platformId) => new()
|
||||
{
|
||||
Platform = platform,
|
||||
PlatformId = platformId,
|
||||
AccountId = (int)p.Id,
|
||||
LastLoginTime = p.LastSeenAt,
|
||||
RequirePassword = false,
|
||||
};
|
||||
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>AuthenticationValues.AddAuthParameter</c>. The
|
||||
/// DorkNet BepInEx plugin (DorkNet.ClientPatch) injects two:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>userid</c> — the player's DorkNet account id (long).</item>
|
||||
/// <item><c>LoginLock</c> — the per-session GUID stored in
|
||||
/// <see cref="PlayerPresenceService"/> at /player/login time.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// Response shape (per
|
||||
/// https://doc.photonengine.com/en-us/realtime/current/reference/custom-authentication):
|
||||
/// <list type="bullet">
|
||||
/// <item><c>ResultCode = 1</c> → success, allow client</item>
|
||||
/// <item><c>ResultCode = 2</c> → parameter invalid, reject</item>
|
||||
/// <item><c>ResultCode = 3</c> → auth failed, reject</item>
|
||||
/// </list>
|
||||
///
|
||||
/// 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: <see cref="PlayerPresenceService.ValidateLock"/>
|
||||
/// returns true if Redis throws, so a transient infra blip doesn't
|
||||
/// mass-kick every player off Photon.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class PhotonCustomAuthController(
|
||||
PlayerPresenceService presence,
|
||||
IConfiguration config,
|
||||
ILogger<PhotonCustomAuthController> log) : ControllerBase
|
||||
{
|
||||
[HttpGet("/photon/customauth")]
|
||||
[HttpPost("/photon/customauth")]
|
||||
public async Task<IActionResult> 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 <c>?userid={userid}</c>,
|
||||
// and the watch ALSO ships AuthValues.AuthGetParameters
|
||||
// (which Photon appends to the URL). The two collide in the
|
||||
// querystring as <c>userid={userid}&userid=230343</c> — both
|
||||
// values arrive on the same key. ASP.NET Core surfaces this
|
||||
// as a multi-value <c>StringValues</c>; we keep the array so
|
||||
// <see cref="PickClean"/> below can pick the first value
|
||||
// that's actually a usable string (skipping unsubstituted
|
||||
// <c>{…}</c> placeholders).
|
||||
var qs = Request.Query
|
||||
.ToDictionary(kv => kv.Key, kv => kv.Value.ToArray(),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
Dictionary<string, string[]> 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
|
||||
// <c>{userid}</c> — 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 ?? "<anonymous>",
|
||||
!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 BepInEx plugin 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 ?? "<missing>",
|
||||
!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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using DorkNet.Server.Compat;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DorkNet.Server.Controllers.Auth;
|
||||
|
||||
/// <summary>The boot-time client-version gate the 2020.12 watch hits
|
||||
/// at startup as <c>GET api/versioncheck/v4?v={build}&p={platform}</c>.
|
||||
///
|
||||
/// <para>Verified against the watch's <c>KMDHPCHFADM.VerifyGameVersion</c>
|
||||
/// (decompiled): the response is parsed into <c>JFKEIPDCCHH</c> which
|
||||
/// exposes a single <c>VersionStatus</c> property of enum type
|
||||
/// <c>PCBCGDHBAEL</c>:
|
||||
/// <list type="bullet">
|
||||
/// <item>0 = <c>ValidForPlay</c> — proceed normally</item>
|
||||
/// <item>1 = <c>ValidForMenu</c> — let user reach menus but block matchmaking</item>
|
||||
/// <item>2 = <c>UpdateRequired</c> — watch shows its built-in
|
||||
/// "Update Required" dialog and refuses to advance past the boot screen</item>
|
||||
/// </list>
|
||||
/// This is the CLEAN way to reject a wrong-build client at the
|
||||
/// front door — the watch has dedicated UX for it, no custom client
|
||||
/// mod work needed.</para>
|
||||
///
|
||||
/// <para>The <c>v</c> query param is the raw build identifier the watch
|
||||
/// reads from <c>KMJJGELKNAP.MCFMPCNNEBH</c> — for the December build
|
||||
/// that's the literal string <c>"20201210"</c>. We map raw build IDs
|
||||
/// to our internal <see cref="ClientVersion.Key"/>s via the
|
||||
/// <c>DorkNet:BuildIdToVersionKey</c> config map; any build ID we
|
||||
/// don't recognise OR that maps to a key not in
|
||||
/// <see cref="VersionRegistry.SupportedVersionKeys"/> gets
|
||||
/// <c>VersionStatus = 2</c> and the watch refuses to proceed.</para>
|
||||
///
|
||||
/// <para>This endpoint is EXEMPT from
|
||||
/// <c>VersionDetectionMiddleware</c>'s gate (otherwise the gate would
|
||||
/// 426 the rejection request itself before this endpoint could reply
|
||||
/// with the polite UpdateRequired payload). The exemption is set in
|
||||
/// <c>VersionDetectionMiddleware.IsExempt</c>.</para>
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class VersionCheckController(
|
||||
IConfiguration config,
|
||||
VersionRegistry registry,
|
||||
ILogger<VersionCheckController> logger) : ControllerBase
|
||||
{
|
||||
// Mirrors the PCBCGDHBAEL enum the watch decodes the response into.
|
||||
private const int VersionStatus_ValidForPlay = 0;
|
||||
private const int VersionStatus_ValidForMenu = 1;
|
||||
private const int VersionStatus_UpdateRequired = 2;
|
||||
|
||||
[HttpGet("/api/versioncheck/v4")]
|
||||
[HttpGet("/api/versioncheck/v3")]
|
||||
[HttpGet("/api/versioncheck/v2")]
|
||||
[HttpGet("/api/versioncheck/v1")]
|
||||
[HttpGet("/api/versioncheck")]
|
||||
[HttpGet("/api/version/v1")]
|
||||
[HttpGet("/api/version")]
|
||||
public IActionResult Check(
|
||||
[FromQuery(Name = "v")] string? buildId,
|
||||
[FromQuery(Name = "p")] string? platform)
|
||||
{
|
||||
// Map raw build ID → ClientVersion key via config. Format:
|
||||
// "DorkNet": {
|
||||
// "BuildIdToVersionKey": {
|
||||
// "20201210": "december_2020_12_18",
|
||||
// "20200310": "march_2020_03_10"
|
||||
// }
|
||||
// }
|
||||
// Mapping ABSENT → unknown build → UpdateRequired.
|
||||
var mapSection = config.GetSection("DorkNet:BuildIdToVersionKey");
|
||||
string? versionKey = !string.IsNullOrWhiteSpace(buildId)
|
||||
? mapSection[buildId]
|
||||
: null;
|
||||
|
||||
bool allowed = versionKey is not null && registry.IsSupported(versionKey);
|
||||
var status = allowed ? VersionStatus_ValidForPlay : VersionStatus_UpdateRequired;
|
||||
|
||||
logger.LogInformation(
|
||||
"[versioncheck] buildId={Build} platform={Platform} → versionKey={Key} status={Status} ({Label})",
|
||||
buildId, platform, versionKey ?? "(unmapped)", status,
|
||||
status == VersionStatus_ValidForPlay ? "ValidForPlay" : "UpdateRequired");
|
||||
|
||||
// Wire shape verified against KMDHPCHFADM_NestedType_JFKEIPDCCHH.txt:
|
||||
// single key "VersionStatus" (PascalCase, case-sensitive — LitJson).
|
||||
return Ok(new { VersionStatus = status });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user