mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
6ee1d6f9b3
Every change below was checked against the 2023-03-21 client disassembly;
file:line evidence is in the code comments and in
docs/recroom-2023-client-api-complete.md.
Presence
- matchmake/none means "online, in no room" (its only caller is
TryGoToOfflineRoom, which ignores the reply). The server used to keep the
old room or invent a dorm, so the next heartbeat looked out of sync and
the client cancelled its load to chase that room.
- The join watchdog no longer clears presence 30 s into a long room load;
it is a 3 minute backstop that drops the room only.
- One fan-out (PresenceBroadcastService) for every transition: deferred
joins, matchmake/none, logout, failed joins, visibility changes, SignalR
connect/disconnect. isOnline is sent on its own; pushes never go to the
subject, because a self push moves the player.
- Online-status visibility (Public / Friends / Favorites / Appear offline)
is stored and honoured by GET /player and by the live pushes.
Profile
- PUT account/me/{emoji,personalpronouns,identityflags,bannerimage} were
404; added with the client's real form keys and persisted (new Players
columns plus migration). Every account read now returns them, and edits
push AccountUpdate to everyone.
Outfit menu
- Saved outfits kept OutfitSelectionsV2 and CustomAvatarItems, so loading a
slot restores what was saved instead of a stale outfit.
- isCreationAllowedForAccount answers the {Success, Error} envelope the
client parses; minPriceForPublicItem is a bare int; gifts/generate accepts
form bodies; lockeditems returns the full owned-item shape; colourways no
longer share their base item's AvatarItemId.
RecNetResult writes
- About 40 writes look status-only but parse the body as {Success, Error}.
An empty 200 showed "Failed to ..." after the server applied the change,
and a body without Success threw "Error message cannot be null". Clubs,
announcements, keepsakes, comments/read, subscriptions, purchase offers
and privacy settings now answer RecNetResult.
- Login token and device-code grants are real; remoteauth approves the
device code; createlogintoken returns a bare string.
- New: incentivizedreferrals/referrals, account/bulk/{email,phone},
GET strings, account/me/confirmphone, PlayMenuCarouselOrdering_1.
Route inventory and coverage test extended to match.
README now describes the 2023 branch.
442 lines
20 KiB
C#
442 lines
20 KiB
C#
using System.Security.Cryptography;
|
|
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");
|
|
}
|
|
|
|
private static Dictionary<string, object> BuildTokenResponse(string accessToken, string refreshToken)
|
|
{
|
|
var signingKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
|
|
|
return new()
|
|
{
|
|
["access_token"] = accessToken,
|
|
["Access_token"] = accessToken,
|
|
["accessToken"] = accessToken,
|
|
["AccessToken"] = accessToken,
|
|
["token"] = accessToken,
|
|
["Token"] = accessToken,
|
|
["token_type"] = "Bearer",
|
|
["tokenType"] = "Bearer",
|
|
["TokenType"] = "Bearer",
|
|
["expires_in"] = 43200,
|
|
["expiresIn"] = 43200,
|
|
["ExpiresIn"] = 43200,
|
|
["refresh_token"] = refreshToken,
|
|
["Refresh_token"] = refreshToken,
|
|
["refreshToken"] = refreshToken,
|
|
["RefreshToken"] = refreshToken,
|
|
["key"] = signingKey,
|
|
["Key"] = signingKey,
|
|
["scope"] = "openid profile",
|
|
};
|
|
}
|
|
|
|
private IActionResult TokenOk(string accessToken, string refreshToken)
|
|
{
|
|
var cookie = new CookieOptions
|
|
{
|
|
HttpOnly = true,
|
|
Secure = string.Equals(domain.Scheme, "https", StringComparison.OrdinalIgnoreCase),
|
|
SameSite = SameSiteMode.None,
|
|
Expires = DateTimeOffset.UtcNow.AddHours(12),
|
|
IsEssential = true,
|
|
};
|
|
if (domain.Apex.Contains('.'))
|
|
cookie.Domain = "." + domain.Apex;
|
|
Response.Cookies.Append(AuthService.AccessCookieName, accessToken, cookie);
|
|
return Ok(BuildTokenResponse(accessToken, refreshToken));
|
|
}
|
|
|
|
// 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,
|
|
[FromForm] string? device_code = null,
|
|
[FromForm] string? login_token = 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 TokenOk(acc1, refr1);
|
|
}
|
|
|
|
// Device-authorization poll (LBNJFPOLCDL_NestedType_DDNJDALGIME.txt:444-451).
|
|
// The client keeps polling on HTTP 400 with error authorization_pending
|
|
// or slow_down (LBNJFPOLCDL_NestedType_JAEKFNKDNCP.txt:97-116); any other
|
|
// error ends the attempt. This grant used to fall through to the
|
|
// device-id fallback, which signed the polling device straight in as
|
|
// whatever account its device id mapped to — no approval involved.
|
|
if (string.Equals(grant_type, DeviceCodeGrantType, StringComparison.Ordinal))
|
|
{
|
|
var (status, approverId) = await DeviceAuthorizations.RedeemAsync(db, device_code, DateTime.UtcNow);
|
|
switch (status)
|
|
{
|
|
case DeviceAuthorizations.RedeemStatus.Pending:
|
|
return BadRequest(new { error = "authorization_pending" });
|
|
case DeviceAuthorizations.RedeemStatus.Expired:
|
|
return BadRequest(new { error = "expired_token" });
|
|
case DeviceAuthorizations.RedeemStatus.Invalid:
|
|
return BadRequest(new { error = "invalid_grant" });
|
|
}
|
|
var approver = await playerService.GetByIdAsync(approverId);
|
|
if (approver is null) return BadRequest(new { error = "invalid_grant" });
|
|
await TryAwardLoginXpAsync(approver.Id);
|
|
var (accD, refrD) = authService.GenerateTokenPair(approver.Id);
|
|
return TokenOk(accD, refrD);
|
|
}
|
|
|
|
// One-shot token from account/me/createlogintoken, redeemed with the
|
|
// account it was minted for (LBNJFPOLCDL.txt:7593-7606: grant_type,
|
|
// account_id, login_token). Also used to fall through to the device-id
|
|
// fallback, ignoring both account_id and the token.
|
|
if (string.Equals(grant_type, "login_token", StringComparison.Ordinal))
|
|
{
|
|
if (account_id is not long tokenAccountId || string.IsNullOrWhiteSpace(login_token))
|
|
return BadRequest(new { error = "invalid_request", error_description = "invalid login_token" });
|
|
var prefix = login_token.Trim() + "|";
|
|
var row = await db.PlayerSettings.FirstOrDefaultAsync(s =>
|
|
s.PlayerId == tokenAccountId
|
|
&& s.Key == Accounts.AccountsController.RemoteLoginTokenKey
|
|
&& s.Value.StartsWith(prefix));
|
|
if (row is null
|
|
|| !DateTime.TryParse(row.Value[prefix.Length..], null,
|
|
System.Globalization.DateTimeStyles.RoundtripKind, out var tokenExpiry)
|
|
|| tokenExpiry < DateTime.UtcNow)
|
|
return BadRequest(new { error = "invalid_grant", error_description = "invalid login_token" });
|
|
db.PlayerSettings.Remove(row);
|
|
await db.SaveChangesAsync();
|
|
var (accL, refrL) = authService.GenerateTokenPair(tokenAccountId);
|
|
return TokenOk(accL, refrL);
|
|
}
|
|
|
|
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 TokenOk(acc, refr);
|
|
}
|
|
|
|
// 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 TokenOk(pair.AccessToken, pair.RefreshToken);
|
|
}
|
|
|
|
// 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 TokenOk(access, refresh);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
// Sign-out / "forget this account" sends DELETE (LBNJFPOLCDL.txt:4190,
|
|
// verb 4); GET-only returned 405 and the forget-account promise rejected.
|
|
[HttpDelete("/cachedlogin/current")]
|
|
[HttpGet("/cachedlogin/current")]
|
|
public async Task<IActionResult> CurrentCachedLogin()
|
|
{
|
|
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(ToCachedLogin(player, player.LastPlatform, player.LastPlatformId));
|
|
}
|
|
|
|
[HttpPost("/cachedlogin/migrate")]
|
|
public async Task<IActionResult> MigrateCachedLogin(
|
|
[FromForm] int platform = 0,
|
|
[FromForm] string? platformId = null)
|
|
{
|
|
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();
|
|
await playerService.TagPlatformAsync(playerId, platform, platformId);
|
|
var player = await playerService.GetByIdAsync(playerId);
|
|
return player is null
|
|
? Unauthorized()
|
|
: Ok(ToCachedLogin(player, platform, platformId ?? string.Empty));
|
|
}
|
|
|
|
[HttpPost("/connect/deviceauthorization")]
|
|
[HttpGet("/connect/deviceauthorization")]
|
|
public async Task<IActionResult> DeviceAuthorization()
|
|
{
|
|
var created = await DeviceAuthorizations.CreateAsync(db, DateTime.UtcNow);
|
|
return Ok(new
|
|
{
|
|
device_code = created.DeviceCode,
|
|
user_code = created.UserCode,
|
|
verification_uri = $"https://{domain.Sub("auth")}/device",
|
|
verification_uri_complete = $"https://{domain.Sub("auth")}/device?user_code={created.UserCode}",
|
|
expires_in = created.ExpiresInSeconds,
|
|
interval = 5,
|
|
});
|
|
}
|
|
|
|
public const string DeviceCodeGrantType = "urn:ietf:params:oauth:grant-type:device_code";
|
|
|
|
private static CachedLogin ToCachedLogin(PlayerEntity p, int platform, string platformId) => new()
|
|
{
|
|
Platform = platform,
|
|
PlatformId = platformId,
|
|
AccountId = (int)p.Id,
|
|
LastLoginTime = p.LastSeenAt,
|
|
RequirePassword = false,
|
|
};
|
|
|
|
}
|