Import march-2020-03-10 from private repo (scrubbed 2026-05-27)

Targets Rec Room 2020.03.10 client (also serves 2020.03.06).

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 dorknet-clientpatch-{key}.zip
  for v1-march-* Release uploads.
- .github/workflows/release.yml — on tag push publishes both
  dorknet-server-march_2020_03_10-win-x64.zip and
  dorknet-clientpatch-march_2020_03_10.zip as Release assets.

Same scrub rules as december; tools/build-patcher-zip.ps1 added to
the maintainer-side allow-list so it survives.
This commit is contained in:
Alexa
2026-05-27 01:06:12 +03:00
commit d471b418b9
389 changed files with 104767 additions and 0 deletions
@@ -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&lt;bool&gt;</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,307 @@
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",
});
}
// 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=&amp;platformIds=a,b,c</c> or
/// JSON with the same shape. Returns the flattened
/// <c>List&lt;CachedLogin&gt;</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(),
});
}
}