Compare commits

...

11 Commits

Author SHA1 Message Date
Alexa 212d4327da Migrate auth to Open.IdentityServer 2026-06-30 15:47:28 +03:00
Alexa b5dec4ba1b Handle signing certificate decode failures 2026-06-30 14:45:23 +03:00
Alexa 0cc78fbf9d Harden signing certificate base64 parsing 2026-06-30 14:36:11 +03:00
Alexa d9494373a9 Use native game auth client only 2026-06-30 14:29:55 +03:00
Alexa 2d6a6ec94c Support native game auth client 2026-06-30 14:16:33 +03:00
Alexa 8c4f33ca80 Restore device password grant compatibility 2026-06-30 14:05:57 +03:00
Alexa c50b305786 Support env-based signing certificate 2026-06-30 13:54:56 +03:00
Alexa 71df7a3ef1 Migrate server auth to IdentityServer 2026-06-30 13:27:54 +03:00
Alexa da596a2692 feat: Add PlayerReputation handling and WebSocket handshake for 2018 client 2026-06-08 01:27:08 +03:00
Alexa 2eba82b342 feat: Update 2018 client support with registration defaults and WebSocket handling 2026-06-07 16:35:07 +03:00
Alexa 579071858a feat: Add support for 2018 client API endpoints and data models
- Implemented new search endpoints for rooms to accommodate the 2018 client.
- Added support for retrieving room details and featured rooms in a format compatible with the 2018 client.
- Introduced new data models for login responses and player presence specific to the 2018 client.
- Created mappers to convert between internal entities and the 2018 wire formats for players and rooms.
- Updated existing storefront APIs to include versioned endpoints for the 2018 client.
- Enhanced the version controller to return dual-format responses for compatibility with both 2018 and 2020 clients.
- Refactored the configuration service to ensure trailing slashes in URLs for the 2018 client.
2026-06-07 15:32:52 +03:00
35 changed files with 1902 additions and 406 deletions
+4 -4
View File
@@ -1,14 +1,14 @@
# DorkNet.Server container image — Coolify deploy target. # DorkNet.Server container image — Coolify deploy target.
# ──────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────
# Multi-stage: SDK image builds + publishes, runtime image is the # Multi-stage: SDK image builds + publishes, runtime image is the
# slimmer aspnet:9.0 with only the published output. # slimmer aspnet:10.0 with only the published output.
# #
# Build context: repo root (so Tools/ and DorkNet.Models/ are visible # Build context: repo root (so Tools/ and DorkNet.Models/ are visible
# to ProjectReference + dotnet restore). The .dockerignore at the # to ProjectReference + dotnet restore). The .dockerignore at the
# repo root keeps the bin/, obj/, and other dev junk out of the # repo root keeps the bin/, obj/, and other dev junk out of the
# context. # context.
ARG DOTNET_VERSION=9.0 ARG DOTNET_VERSION=10.0
ARG NODE_VERSION=20 ARG NODE_VERSION=20
# ── admin SPA build stage ────────────────────────────────────────── # ── admin SPA build stage ──────────────────────────────────────────
@@ -76,13 +76,13 @@ WORKDIR /app
# curl + wget for healthcheck probes — Coolify v4 defaults to wget for # curl + wget for healthcheck probes — Coolify v4 defaults to wget for
# its own probe regardless of what the image's HEALTHCHECK uses, so # its own probe regardless of what the image's HEALTHCHECK uses, so
# both have to be present. The aspnet:9.0 base image strips both by # both have to be present. The aspnet:10.0 base image strips both by
# default to keep the surface area small. # default to keep the surface area small.
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends curl wget \ && apt-get install -y --no-install-recommends curl wget \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Run as a non-root user — aspnet:9.0 ships with the `app` UID 1654. # Run as a non-root user — aspnet:10.0 ships with the `app` UID 1654.
# Coolify mounts persistent volumes with write permission for this UID. # Coolify mounts persistent volumes with write permission for this UID.
USER app USER app
+143
View File
@@ -0,0 +1,143 @@
using System.Text.Json.Serialization;
namespace DorkNet.Models.Auth;
// ── June-2018 (build 20180621_EA) login wire shapes ──────────────────────────
//
// The 2018 client speaks a DIFFERENT login protocol than the 2019/2020 OAuth
// flow the rest of DorkNet implements:
// • POST api/platformlogin/v1/{loginaccount,logincached,createaccount}
// • request body is a JSON object (Unity JsonUtility / LitJson), NOT form
// • response is the "login envelope" below, read by RecNet.cs:40014-40021
// via case-sensitive LitJson keys.
//
// Keys verified from the assembly's REAL (name-preserved) Deserialize methods
// via dnlib — the obfuscated-name sibling methods are Beebyte decoys with
// scrambled literals (see project_2018_mono_obfuscation memory).
/// <summary>
/// Login response envelope. RecNet login-response Deserialize (RecNet.cs:40016-40020)
/// reads: Error (req string), Player (optional object), Token (req string),
/// FirstLoginOfTheDay (req bool), AnalyticsSessionId (req long). Empty Error
/// means success; a non-empty Error is shown to the user and aborts login.
/// </summary>
public class Login2018Response
{
[JsonPropertyName("Error")]
public string Error { get; set; } = string.Empty;
[JsonPropertyName("Player")]
public Player2018? Player { get; set; }
[JsonPropertyName("Token")]
public string Token { get; set; } = string.Empty;
[JsonPropertyName("FirstLoginOfTheDay")]
public bool FirstLoginOfTheDay { get; set; }
[JsonPropertyName("AnalyticsSessionId")]
public long AnalyticsSessionId { get; set; }
}
/// <summary>
/// 2018 player object embedded in the login envelope and returned bare from
/// getcachedlogins. Keys + required/optional verified from RecNet.cs:64632-64667
/// (RecNet.EEBPLECPEGD.Deserialize):
/// REQUIRED (Util.GetKey throws if missing): Id, Username, DisplayName, XP,
/// Level, RegistrationStatus, Developer, CanReceiveInvites, ProfileImageName,
/// JuniorProfile, ForceJuniorImages, PendingJunior, HasBirthday.
/// OPTIONAL: Bio (TryGetKey), AvoidJuniors (ContainsKey-guarded),
/// PlayerReputation/PlatformId (GetObjectKey -> null ok; omitted here),
/// GroupMemberships (ContainsKey-guarded; omitted).
/// NOTE the 2018 client uses "Id" (not "AccountId") and "Developer" (not
/// "IsDeveloper") — distinct from the 2020 PlayerProfile DTO.
/// </summary>
public class Player2018
{
[JsonPropertyName("Id")]
public long Id { get; set; }
[JsonPropertyName("Username")]
public string Username { get; set; } = string.Empty;
[JsonPropertyName("DisplayName")]
public string DisplayName { get; set; } = string.Empty;
[JsonPropertyName("Bio")]
public string Bio { get; set; } = string.Empty;
[JsonPropertyName("XP")]
public int XP { get; set; }
[JsonPropertyName("Level")]
public int Level { get; set; } = 1;
// Registration step enum (ProfileSelectionManager.NFCBDCFLBOC):
// invalid=-1, video=0, birthday=1, email=2, COMPLETE=10. Anything < 10
// makes the client run the signup step flow (it got "stuck on birthday").
// 10 = fully registered → client goes straight in-game.
[JsonPropertyName("RegistrationStatus")]
public int RegistrationStatus { get; set; } = 10;
[JsonPropertyName("Developer")]
public bool Developer { get; set; }
[JsonPropertyName("CanReceiveInvites")]
public bool CanReceiveInvites { get; set; } = true;
[JsonPropertyName("ProfileImageName")]
public string ProfileImageName { get; set; } = string.Empty;
[JsonPropertyName("JuniorProfile")]
public bool JuniorProfile { get; set; }
[JsonPropertyName("ForceJuniorImages")]
public bool ForceJuniorImages { get; set; }
[JsonPropertyName("PendingJunior")]
public bool PendingJunior { get; set; }
[JsonPropertyName("HasBirthday")]
public bool HasBirthday { get; set; }
[JsonPropertyName("AvoidJuniors")]
public bool AvoidJuniors { get; set; }
// REQUIRED to be non-null: the post-login analytics Identify dereferences
// localPlayer.PlayerReputation.<field> (RecNet.cs ~NECCOFKHCAA) — a null
// here NREs the boot coroutine and the client hangs on splash_screen.
[JsonPropertyName("PlayerReputation")]
public PlayerReputation2018 PlayerReputation { get; set; } = new();
}
/// <summary>
/// 2018 PlayerReputation (RecNet.HOLNFPNKNGB.Deserialize) — all int. Keys exact
/// incl. the "Noteriety" misspelling. Must be present (non-null) on the player.
/// </summary>
public class PlayerReputation2018
{
[JsonPropertyName("Noteriety")] public int Noteriety { get; set; }
[JsonPropertyName("CheerGeneral")] public int CheerGeneral { get; set; }
[JsonPropertyName("CheerHelpful")] public int CheerHelpful { get; set; }
[JsonPropertyName("CheerGreatHost")] public int CheerGreatHost { get; set; }
[JsonPropertyName("CheerSportsman")] public int CheerSportsman { get; set; }
[JsonPropertyName("CheerCreative")] public int CheerCreative { get; set; }
[JsonPropertyName("CheerCredit")] public int CheerCredit { get; set; }
[JsonPropertyName("SubscriberCount")] public int SubscriberCount { get; set; }
[JsonPropertyName("SubscribedCount")] public int SubscribedCount { get; set; }
[JsonPropertyName("SelectedCheer")] public int SelectedCheer { get; set; }
}
/// <summary>
/// Generic 2018 success envelope {Success, Message} (RecNet EFDOILDKBFK) used by
/// registeraccount (email attach) and other simple POSTs. Note the key is
/// "Message" (not "error").
/// </summary>
public class SuccessMessage2018
{
[JsonPropertyName("Success")]
public bool Success { get; set; }
[JsonPropertyName("Message")]
public string Message { get; set; } = string.Empty;
}
+32 -5
View File
@@ -50,13 +50,34 @@ public class RecRoomConfig
[JsonPropertyName("DailyObjectives")] [JsonPropertyName("DailyObjectives")]
public List<List<DailyObjective>> DailyObjectives { get; set; } = DailyObjective.DefaultSets(); public List<List<DailyObjective>> DailyObjectives { get; set; } = DailyObjective.DefaultSets();
[JsonPropertyName("ConfigTable")] // 2018.06 client (RecNet.OPOHNGAOCCD.Deserialize, RecNet.cs:9062-9068) reads
// ConfigTable as an ARRAY of {Key,Value} objects, NOT a JSON object map:
// foreach elem in dict["ConfigTable"]: table[elem["Key"]] = elem["Value"]
// The 2020 client read it as an object — but this branch targets 2018, so we
// emit the array form. Keep the settable Dictionary for existing call sites
// (ConfigService.GetConfig populates it) and project to the wire array.
[JsonIgnore]
public Dictionary<string, string> ConfigTable { get; set; } = []; public Dictionary<string, string> ConfigTable { get; set; } = [];
[JsonPropertyName("ConfigTable")]
public List<ConfigTableEntry> ConfigTableWire =>
ConfigTable.Select(kv => new ConfigTableEntry { Key = kv.Key, Value = kv.Value }).ToList();
[JsonPropertyName("ServiceUrls")] [JsonPropertyName("ServiceUrls")]
public Dictionary<string, string> ServiceUrls { get; set; } = []; public Dictionary<string, string> ServiceUrls { get; set; } = [];
} }
// 2018 ConfigTable element shape: {"Key": "...", "Value": "..."} (PascalCase,
// LitJson Util.GetKey<string>("Key"/"Value")).
public class ConfigTableEntry
{
[JsonPropertyName("Key")]
public string Key { get; set; } = string.Empty;
[JsonPropertyName("Value")]
public string Value { get; set; } = string.Empty;
}
public class PhotonConfig public class PhotonConfig
{ {
[JsonPropertyName("AppId")] [JsonPropertyName("AppId")]
@@ -77,11 +98,17 @@ public class PhotonConfig
public class MatchmakingParams public class MatchmakingParams
{ {
[JsonPropertyName("MaxPlayersPerRoom")] // 2018 RecNet.LOIBFONMHMF.Deserialize reads exactly TWO required float keys
public int MaxPlayersPerRoom { get; set; } = 8; // via Util.GetKey<Single> (verified via dnlib). It's fetched as an optional
// object (GetObjectKey) inside config/v2, but once PRESENT the client
// deserializes it — wrong/missing keys throw KeyNotFoundException →
// "Received malformed RecNet response" → "Failed to connect to RecNet" at
// boot. The 2020 MaxPlayersPerRoom/MinPlayersToStart keys are NOT read here.
[JsonPropertyName("PreferFullRoomsFrequency")]
public float PreferFullRoomsFrequency { get; set; } = 0.5f;
[JsonPropertyName("MinPlayersToStart")] [JsonPropertyName("PreferEmptyRoomsFrequency")]
public int MinPlayersToStart { get; set; } = 1; public float PreferEmptyRoomsFrequency { get; set; } = 0.5f;
} }
public class LevelProgressionEntry public class LevelProgressionEntry
+2 -2
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
+21
View File
@@ -0,0 +1,21 @@
using System.Text.Json.Serialization;
namespace DorkNet.Models.Players;
/// <summary>
/// June-2018 presence record returned from POST api/presence/v2/list.
/// Keys verified from RecNet.DBHHFILLFLC.Deserialize: PlayerId (long),
/// IsOnline (bool), GameSession (optional object — null when the player is
/// online but not in a session; the client null-checks it).
/// </summary>
public class Presence2018
{
[JsonPropertyName("PlayerId")]
public long PlayerId { get; set; }
[JsonPropertyName("IsOnline")]
public bool IsOnline { get; set; }
[JsonPropertyName("GameSession")]
public object? GameSession { get; set; }
}
+48
View File
@@ -0,0 +1,48 @@
using System.Text.Json.Serialization;
namespace DorkNet.Models.Rooms;
/// <summary>
/// Flat June-2018 room object. The 2018 client (RecNet Room.Deserialize,
/// RecNet.cs:78244) reads these keys directly off the room JSON — there is NO
/// 2020-style RoomDetails/Scenes wrapper; DataBlobName + PersonalDetails are
/// embedded right here. Required keys throw (Util.GetKey) if missing, so every
/// field below must be present except the genuinely-optional ones noted.
///
/// Notes:
/// • CreatorPlayerId is read as an int client-side; all account ids fit int.
/// • Date keys are ISO-8601 (client DateTimeParse.ParseISO8601). Use real
/// dates, never year-9999 (overflow — see WireDates).
/// • ActivityLevelId parse failure is a non-fatal warning client-side, so ""
/// is safe for non-activity rooms.
/// • CoOwners/Hosts are int arrays (empty ok); PersonalDetails is an optional
/// object (null ok).
/// </summary>
public class Room2018
{
[JsonPropertyName("RoomId")] public long RoomId { get; set; }
[JsonPropertyName("Name")] public string Name { get; set; } = string.Empty;
[JsonPropertyName("Description")] public string Description { get; set; } = string.Empty;
[JsonPropertyName("CreatorPlayerId")] public long CreatorPlayerId { get; set; }
[JsonPropertyName("ImageName")] public string ImageName { get; set; } = string.Empty;
[JsonPropertyName("DataBlobName")] public string DataBlobName { get; set; } = string.Empty;
[JsonPropertyName("ActivityLevelId")] public string ActivityLevelId { get; set; } = string.Empty;
[JsonPropertyName("IsSandbox")] public bool IsSandbox { get; set; }
[JsonPropertyName("Instanced")] public bool Instanced { get; set; } = true;
[JsonPropertyName("MaxPlayers")] public int MaxPlayers { get; set; } = 20;
[JsonPropertyName("Accessibility")] public int Accessibility { get; set; } = 1;
[JsonPropertyName("AccessibilityLocked")] public bool AccessibilityLocked { get; set; }
[JsonPropertyName("VisitorCount")] public int VisitorCount { get; set; }
[JsonPropertyName("VisitCount")] public int VisitCount { get; set; }
[JsonPropertyName("CheerCount")] public int CheerCount { get; set; }
[JsonPropertyName("ReportCount")] public int ReportCount { get; set; }
[JsonPropertyName("State")] public int State { get; set; }
[JsonPropertyName("StateModifiedAt")] public DateTime StateModifiedAt { get; set; }
[JsonPropertyName("CreatedAt")] public DateTime CreatedAt { get; set; }
[JsonPropertyName("ModifiedAt")] public DateTime ModifiedAt { get; set; }
[JsonPropertyName("LastVisitedAt")] public DateTime LastVisitedAt { get; set; }
[JsonPropertyName("DataModifiedAt")] public DateTime DataModifiedAt { get; set; }
[JsonPropertyName("CoOwners")] public List<int> CoOwners { get; set; } = [];
[JsonPropertyName("Hosts")] public List<int> Hosts { get; set; } = [];
[JsonPropertyName("PersonalDetails")] public object? PersonalDetails { get; set; }
}
@@ -0,0 +1,590 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using Open.IdentityServer;
using Open.IdentityServer.Models;
using Open.IdentityServer.Services;
using Open.IdentityServer.Validation;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Primitives;
using Microsoft.IdentityModel.Tokens;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Auth;
public sealed class IdentityServerSigningKeyProvider
{
private readonly string _legacySecret;
public IdentityServerSigningKeyProvider(IConfiguration config)
{
_legacySecret =
Environment.GetEnvironmentVariable("DORKNET_JWT_SECRET")
?? Environment.GetEnvironmentVariable("RECNET_JWT_SECRET")
?? config["Jwt:Secret"]
?? throw new InvalidOperationException(
"JWT secret not configured. Set DORKNET_JWT_SECRET env var or Jwt:Secret in appsettings.Local.json.");
CertificatePath = ResolveCertificatePath(config);
var password = config["IdentityServer:SigningCertificatePassword"] ?? _legacySecret;
var certificateBase64 = config["IdentityServer:SigningCertificateBase64"];
if (!string.IsNullOrWhiteSpace(certificateBase64))
{
LoadedFromBase64 = true;
Certificate = LoadCertificateFromBase64(certificateBase64, password);
}
else
{
Certificate = LoadOrCreateCertificate(CertificatePath, password);
}
SigningKey = new X509SecurityKey(Certificate);
LegacyJwtKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_legacySecret));
}
public string CertificatePath { get; }
public bool LoadedFromBase64 { get; }
public string CertificateSource => LoadedFromBase64
? "IdentityServer:SigningCertificateBase64"
: CertificatePath;
public X509Certificate2 Certificate { get; }
public SecurityKey SigningKey { get; }
public SecurityKey LegacyJwtKey { get; }
public SecurityKey[] ValidationKeys => new[] { SigningKey, LegacyJwtKey };
private static string ResolveCertificatePath(IConfiguration config)
{
var path = config["IdentityServer:SigningCertificatePath"];
if (string.IsNullOrWhiteSpace(path))
path = Path.Combine(AppContext.BaseDirectory, "data", "identityserver-signing.pfx");
return path;
}
private static X509Certificate2 LoadCertificateFromBase64(string certificateBase64, string password)
{
var flags = X509KeyStorageFlags.UserKeySet | X509KeyStorageFlags.Exportable;
var compact = NormalizeCertificateBase64(certificateBase64);
byte[] bytes;
try
{
bytes = Convert.FromBase64String(compact);
}
catch (FormatException ex)
{
throw new InvalidOperationException(
"IdentityServer signing certificate base64 is invalid. " +
"Set IdentityServer__SigningCertificateBase64 to the PFX bytes encoded as base64, " +
"without a file path or shell assignment text.",
ex);
}
try
{
return new X509Certificate2(bytes, password, flags);
}
catch (CryptographicException ex)
{
if (TryLoadPemCertificate(bytes, password, flags, out var certificate))
return certificate;
throw new InvalidOperationException(
"IdentityServer signing certificate base64 decoded successfully, but it is not a valid PFX/PKCS#12 certificate. " +
"Set IdentityServer__SigningCertificateBase64 to the PFX bytes encoded as base64 and make sure " +
"IdentityServer__SigningCertificatePassword matches the PFX password.",
ex);
}
}
private static bool TryLoadPemCertificate(
byte[] bytes,
string password,
X509KeyStorageFlags flags,
out X509Certificate2 certificate)
{
certificate = null!;
string pem;
try
{
pem = Encoding.UTF8.GetString(bytes);
}
catch (DecoderFallbackException)
{
return false;
}
if (!pem.Contains("-----BEGIN", StringComparison.Ordinal))
return false;
try
{
using var pemCertificate = X509Certificate2.CreateFromPem(pem, pem);
if (!pemCertificate.HasPrivateKey)
return false;
var pfxBytes = pemCertificate.Export(X509ContentType.Pfx, password);
certificate = new X509Certificate2(pfxBytes, password, flags);
return true;
}
catch (CryptographicException)
{
return false;
}
}
private static string NormalizeCertificateBase64(string value)
{
var text = value.Trim().Trim('"', '\'');
var equals = text.IndexOf('=');
if (equals > 0)
{
var key = text[..equals].Trim();
if (key.Equals("IdentityServer__SigningCertificateBase64", StringComparison.OrdinalIgnoreCase) ||
key.Equals("IdentityServer:SigningCertificateBase64", StringComparison.OrdinalIgnoreCase))
text = text[(equals + 1)..].Trim().Trim('"', '\'');
}
if (text.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
var comma = text.IndexOf(',');
if (comma >= 0)
text = text[(comma + 1)..];
}
if (text.StartsWith("base64:", StringComparison.OrdinalIgnoreCase))
text = text["base64:".Length..];
var compact = new string(text.Where(c => !char.IsWhiteSpace(c)).ToArray())
.Replace('-', '+')
.Replace('_', '/');
return (compact.Length % 4) switch
{
2 => compact + "==",
3 => compact + "=",
_ => compact,
};
}
private static X509Certificate2 LoadOrCreateCertificate(string path, string password)
{
var flags = X509KeyStorageFlags.UserKeySet | X509KeyStorageFlags.Exportable;
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
if (File.Exists(path))
return new X509Certificate2(path, password, flags);
using var rsa = RSA.Create(2048);
var request = new CertificateRequest(
"CN=DorkNet IdentityServer Signing",
rsa,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
request.CertificateExtensions.Add(
new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, critical: false));
using var created = request.CreateSelfSigned(
DateTimeOffset.UtcNow.AddDays(-1),
DateTimeOffset.UtcNow.AddYears(10));
var bytes = created.Export(X509ContentType.Pfx, password);
File.WriteAllBytes(path, bytes);
return new X509Certificate2(bytes, password, flags);
}
}
public static class RecRoomIdentityServerRegistration
{
public static void AddRecRoomIdentityServer(
this IServiceCollection services,
IConfiguration config,
DomainConfig domain,
IdentityServerSigningKeyProvider signingKeyProvider)
{
services
.AddIdentityServer(options =>
{
options.IssuerUri = domain.AuthIssuer.TrimEnd('/');
})
.AddInMemoryIdentityResources(RecRoomIdentityServerConfig.IdentityResources)
.AddInMemoryApiScopes(RecRoomIdentityServerConfig.ApiScopes)
.AddInMemoryClients(RecRoomIdentityServerConfig.Clients)
.AddSigningCredential(signingKeyProvider.SigningKey, SecurityAlgorithms.RsaSha256)
.AddResourceOwnerValidator<RecRoomPasswordValidator>()
.AddExtensionGrantValidator<CachedLoginGrantValidator>()
.AddProfileService<RecRoomProfileService>();
}
}
internal static class RecRoomIdentityServerConfig
{
public const string ClientId = "recroom";
public const string ClientSecret = "VxZ53kgbbEaRoZAeMe00MagtgD12GLL2";
public const string GameClientScope = "gameClient";
public const string CachedLoginGrantType = "cached_login";
public static readonly IdentityResource[] IdentityResources =
{
new IdentityResources.OpenId(),
new IdentityResources.Profile(),
};
public static readonly ApiScope[] ApiScopes =
{
new(GameClientScope, "Game client")
{
UserClaims = RecRoomIdentityClaims.UserClaimTypes,
},
new("api", "DorkNet API")
{
UserClaims = RecRoomIdentityClaims.UserClaimTypes,
},
};
public static readonly Client[] Clients =
{
new()
{
ClientId = ClientId,
ClientName = "Rec Room game client",
RequireClientSecret = true,
ClientSecrets = { new Secret(ClientSecret.Sha256()) },
AllowedGrantTypes = { GrantType.ResourceOwnerPassword, "refresh_token", CachedLoginGrantType },
AllowedScopes = RecRoomScopes(),
AllowOfflineAccess = true,
AccessTokenLifetime = 60 * 60 * 12,
AbsoluteRefreshTokenLifetime = 60 * 60 * 24 * 30,
SlidingRefreshTokenLifetime = 60 * 60 * 24 * 30,
RefreshTokenUsage = TokenUsage.ReUse,
RefreshTokenExpiration = TokenExpiration.Absolute,
UpdateAccessTokenClaimsOnRefresh = true,
AlwaysIncludeUserClaimsInIdToken = true,
},
};
private static ICollection<string> RecRoomScopes() =>
[
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile,
IdentityServerConstants.StandardScopes.OfflineAccess,
GameClientScope,
"api",
];
}
public sealed class IdentityServerGameTokenRequestMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context)
{
if (!IsTokenRequest(context))
{
await next(context);
return;
}
var form = await context.Request.ReadFormAsync();
var values = form.ToDictionary(
kvp => kvp.Key,
kvp => kvp.Value,
StringComparer.OrdinalIgnoreCase);
var changed = false;
if (IsDevicePasswordGrant(values))
{
if (!values.ContainsKey("username") || string.IsNullOrWhiteSpace(values["username"].ToString()))
{
values["username"] = First(
values.TryGetValue("deviceId", out var deviceId) ? deviceId.ToString() : null,
values.TryGetValue("device_id", out var deviceIdSnake) ? deviceIdSnake.ToString() : null,
values.TryGetValue("platformId", out var platformId) ? platformId.ToString() : null,
values.TryGetValue("platform_id", out var platformIdSnake) ? platformIdSnake.ToString() : null,
"anonymous");
changed = true;
}
if (!values.ContainsKey("password") || string.IsNullOrWhiteSpace(values["password"].ToString()))
{
values["password"] = "__dorknet_device_login__";
values["dorknet_device_login"] = "true";
changed = true;
}
}
var scopes = values.TryGetValue("scope", out var rawScope)
? rawScope.ToString()
: string.Empty;
var mergedScopes = MergeScopes(scopes);
if (!string.Equals(scopes, mergedScopes, StringComparison.Ordinal))
{
values["scope"] = mergedScopes;
changed = true;
}
if (changed)
context.Features.Set<IFormFeature>(new FormFeature(new FormCollection(values)));
await next(context);
}
private static bool IsTokenRequest(HttpContext context) =>
HttpMethods.IsPost(context.Request.Method)
&& context.Request.Path.Equals("/connect/token", StringComparison.OrdinalIgnoreCase)
&& context.Request.HasFormContentType;
private static bool IsDevicePasswordGrant(Dictionary<string, StringValues> values)
{
if (!values.TryGetValue("grant_type", out var grantType) ||
!string.Equals(grantType.ToString(), GrantType.ResourceOwnerPassword, StringComparison.OrdinalIgnoreCase))
return false;
return !values.TryGetValue("password", out var password) ||
string.IsNullOrWhiteSpace(password.ToString());
}
private static string First(params string?[] values) =>
values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)) ?? "anonymous";
private static string MergeScopes(string existing)
{
var scopes = existing
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
scopes.Add(IdentityServerConstants.StandardScopes.OpenId);
scopes.Add(IdentityServerConstants.StandardScopes.Profile);
scopes.Add(IdentityServerConstants.StandardScopes.OfflineAccess);
scopes.Add(RecRoomIdentityServerConfig.GameClientScope);
return string.Join(' ', scopes);
}
}
public sealed class RecRoomPasswordValidator(
PlayerService playerService,
LevelService level,
ServerSettingsService settings,
DorkNetDbContext db,
ILogger<RecRoomPasswordValidator> logger) : IResourceOwnerPasswordValidator
{
public async Task ValidateAsync(ResourceOwnerPasswordValidationContext context)
{
var raw = context.Request.Raw;
var deviceId = First(raw.Get("deviceId"), raw.Get("device_id"));
var platformId = First(raw.Get("platformId"), raw.Get("platform_id"));
var platform = int.TryParse(raw.Get("platform"), out var p) ? p : 0;
var deviceLogin = string.Equals(raw.Get("dorknet_device_login"), "true", StringComparison.OrdinalIgnoreCase);
if (!deviceLogin && !string.IsNullOrEmpty(context.Password) && !string.IsNullOrEmpty(context.UserName))
{
var byName = await playerService.GetByUsernameAsync(context.UserName);
if (byName is null || byName.PasswordHash is null)
{
context.Result = new GrantValidationResult(
TokenRequestErrors.InvalidGrant,
"unknown_user_or_no_password");
return;
}
if (!BCrypt.Net.BCrypt.Verify(context.Password, byName.PasswordHash))
{
context.Result = new GrantValidationResult(
TokenRequestErrors.InvalidGrant,
"password_mismatch");
return;
}
await playerService.TagPlatformAsync(byName.Id, platform, platformId);
await TryAwardLoginXpAsync(byName.Id);
context.Result = await CreateSuccessAsync(byName, "password");
return;
}
var effectiveDeviceId = !string.IsNullOrWhiteSpace(deviceId)
? deviceId
: $"legacy-{(context.UserName ?? "anonymous").ToLowerInvariant()}";
if (await settings.AreSignupsDisabledAsync())
{
var existing = await playerService.GetByDeviceAsync(effectiveDeviceId);
if (existing is null)
{
logger.LogInformation(
"[auth] IdentityServer password grant device fallback refused - signups disabled (device={Device})",
effectiveDeviceId);
context.Result = new GrantValidationResult(
TokenRequestErrors.InvalidGrant,
"signups_disabled");
return;
}
}
var player = await playerService.GetOrCreateByDeviceAsync(
deviceId: effectiveDeviceId,
platform: platform,
platformId: platformId,
displayName: context.UserName);
await TryAwardLoginXpAsync(player.Id);
context.Result = await CreateSuccessAsync(player, "device_password");
}
private async Task<GrantValidationResult> CreateSuccessAsync(PlayerEntity player, string authMethod) =>
new(
subject: player.Id.ToString(),
authenticationMethod: authMethod,
claims: await RecRoomIdentityClaims.CreateAsync(db, player.Id, player.Username));
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 string? First(params string?[] values) =>
values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v));
}
public sealed class CachedLoginGrantValidator(
PlayerService playerService,
LevelService level,
OrphanAccountTracker orphans,
DorkNetDbContext db,
ILogger<CachedLoginGrantValidator> logger) : IExtensionGrantValidator
{
public string GrantType => RecRoomIdentityServerConfig.CachedLoginGrantType;
public async Task ValidateAsync(ExtensionGrantValidationContext context)
{
var raw = context.Request.Raw;
if (!long.TryParse(raw.Get("account_id"), out var pickedId))
{
context.Result = new GrantValidationResult(
TokenRequestErrors.InvalidGrant,
"missing account_id");
return;
}
var picked = await playerService.GetByIdAsync(pickedId);
if (picked is null)
{
context.Result = new GrantValidationResult(
TokenRequestErrors.InvalidGrant,
"unknown account_id");
return;
}
var deviceId = First(raw.Get("deviceId"), raw.Get("device_id"));
var platformId = First(raw.Get("platformId"), raw.Get("platform_id"));
var platform = int.TryParse(raw.Get("platform"), out var p) ? p : 0;
var pendingId = orphans.PeekPending(deviceId, platformId);
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(deviceId, platformId);
await TryAwardLoginXpAsync(picked.Id);
await playerService.TagPlatformAsync(picked.Id, platform, platformId);
context.Result = new GrantValidationResult(
subject: picked.Id.ToString(),
authenticationMethod: GrantType,
claims: await RecRoomIdentityClaims.CreateAsync(db, picked.Id, picked.Username));
}
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 string? First(params string?[] values) =>
values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v));
}
public sealed class RecRoomProfileService(PlayerService playerService, DorkNetDbContext db) : IProfileService
{
public async Task GetProfileDataAsync(ProfileDataRequestContext context)
{
if (!TryGetSubjectId(context.Subject, out var playerId))
return;
var player = await playerService.GetByIdAsync(playerId);
if (player is null)
return;
context.IssuedClaims.AddRange(
await RecRoomIdentityClaims.CreateAsync(db, player.Id, player.Username));
}
public async Task IsActiveAsync(IsActiveContext context)
{
context.IsActive =
TryGetSubjectId(context.Subject, out var playerId)
&& await playerService.GetByIdAsync(playerId) is not null;
}
private static bool TryGetSubjectId(ClaimsPrincipal subject, out long playerId)
{
var value = subject.FindFirstValue(JwtRegisteredClaimNames.Sub)
?? subject.FindFirstValue(ClaimTypes.NameIdentifier);
return long.TryParse(value, out playerId);
}
}
public static class RecRoomIdentityClaims
{
public static readonly List<string> UserClaimTypes =
[
JwtRegisteredClaimNames.Sub,
ClaimTypes.NameIdentifier,
"name",
"role",
ClaimTypes.Role,
"roles",
"accountId",
"account_id",
"accountid",
];
public static async Task<List<Claim>> CreateAsync(DorkNetDbContext db, long playerId, string? username = null)
{
var playerInfo = await db.Players
.Where(p => p.Id == playerId)
.Select(p => new { p.Username, IsDev = p.IsDeveloper || p.IsAdmin })
.FirstOrDefaultAsync();
var name = username ?? playerInfo?.Username ?? playerId.ToString();
var isDev = playerInfo?.IsDev ?? false;
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, playerId.ToString()),
new(JwtRegisteredClaimNames.Sub, playerId.ToString()),
new("name", name),
new("accountId", playerId.ToString()),
new("account_id", playerId.ToString()),
new("accountid", playerId.ToString()),
new(ClaimTypes.Role, "gameClient"),
new("role", "gameClient"),
new("roles", "gameClient"),
};
if (isDev)
{
claims.Add(new Claim(ClaimTypes.Role, "developer"));
claims.Add(new Claim("role", "developer"));
claims.Add(new Claim("roles", "developer"));
}
return claims;
}
}
@@ -0,0 +1,31 @@
using DorkNet.Models.Auth;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Compat2018;
/// <summary>
/// Maps a <see cref="PlayerEntity"/> to the June-2018 wire player object
/// (<see cref="Player2018"/>). Lives in the server (not DorkNet.Models) because
/// the DTO project can't reference the data-layer entity. Shared by the
/// platformlogin/v1, players/v1, and presence controllers so the 2018 player
/// shape is produced in exactly one place.
/// Keys verified against RecNet.EEBPLECPEGD.Deserialize (RecNet.cs:64632).
/// </summary>
public static class Player2018Mapper
{
public static Player2018 From(PlayerEntity p) => new()
{
Id = p.Id,
Username = p.Username,
DisplayName = string.IsNullOrEmpty(p.DisplayName) ? p.Username : p.DisplayName,
Bio = p.Bio ?? string.Empty,
XP = p.XP,
Level = p.Level,
Developer = p.IsDeveloper,
CanReceiveInvites = p.CanReceiveInvites,
ProfileImageName = p.ProfileImageName ?? string.Empty,
JuniorProfile = p.IsJunior,
HasBirthday = p.Birthday.HasValue,
PlayerReputation = new PlayerReputation2018 { Noteriety = p.Reputation },
};
}
@@ -0,0 +1,43 @@
using DorkNet.Models.Rooms;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Compat2018;
/// <summary>
/// Maps a <see cref="RoomEntity"/> to the flat June-2018 wire room
/// (<see cref="Room2018"/>). Server-side because DorkNet.Models can't reference
/// the data entity. Some 2018 fields have no column (Instanced, MaxPlayers,
/// ReportCount, ActivityLevelId) and get sensible defaults; IsSandbox is derived
/// from IsAGRoom (official AG rooms are not sandbox/maker rooms).
/// </summary>
public static class Room2018Mapper
{
public static Room2018 From(RoomEntity r) => new()
{
RoomId = r.Id,
Name = r.Name,
Description = r.Description,
CreatorPlayerId = r.CreatorPlayerId,
ImageName = r.ImageName,
DataBlobName = r.CurrentDataBlobName ?? string.Empty,
ActivityLevelId = string.Empty,
IsSandbox = !r.IsAGRoom,
Instanced = true,
MaxPlayers = 20,
Accessibility = r.Accessibility,
AccessibilityLocked = false,
VisitorCount = r.VisitorCount,
VisitCount = r.VisitCount,
CheerCount = r.CheerCount,
ReportCount = 0,
State = r.State,
StateModifiedAt = r.UpdatedAt,
CreatedAt = r.CreatedAt,
ModifiedAt = r.UpdatedAt,
LastVisitedAt = r.UpdatedAt,
DataModifiedAt = r.UpdatedAt,
CoOwners = [],
Hosts = [],
PersonalDetails = null,
};
}
@@ -26,6 +26,7 @@ public class AvatarSavedOutfitsController(DorkNetDbContext db) : ControllerBase
{ {
private const int MaxSlots = 20; private const int MaxSlots = 20;
[HttpGet("api/avatar/v1/saved")]
[HttpGet("api/avatar/v2/saved")] [HttpGet("api/avatar/v2/saved")]
[HttpGet("api/avatar/v3/saved")] [HttpGet("api/avatar/v3/saved")]
[HttpGet("api/avatar/v4/saved")] [HttpGet("api/avatar/v4/saved")]
@@ -39,6 +40,7 @@ public class AvatarSavedOutfitsController(DorkNetDbContext db) : ControllerBase
/// <summary>POST <c>v3/saved/set</c> — upsert one outfit by Slot. /// <summary>POST <c>v3/saved/set</c> — upsert one outfit by Slot.
/// Body is the SavedOutfit shape directly.</summary> /// Body is the SavedOutfit shape directly.</summary>
[HttpPost("api/avatar/v1/saved/set")]
[HttpPost("api/avatar/v3/saved/set")] [HttpPost("api/avatar/v3/saved/set")]
[HttpPost("api/avatar/v2/saved/set")] [HttpPost("api/avatar/v2/saved/set")]
public async Task<IActionResult> Set([FromBody] SavedOutfitDto body) public async Task<IActionResult> Set([FromBody] SavedOutfitDto body)
@@ -68,6 +68,13 @@ public class ImagesController(
[HttpPost("api/images/v3/uploadsaved")] [HttpPost("api/images/v3/uploadsaved")]
[HttpPost("api/images/v4/uploadsaved")] [HttpPost("api/images/v4/uploadsaved")]
[HttpPost("api/images/v5/uploadsaved")] [HttpPost("api/images/v5/uploadsaved")]
// 2018 flat-screen (non-VR) camera save posts a single 'image' part to
// uploadsavedsingle (RecNet.cs:35080); ReadImageBytesAsync already falls
// back to the first uploaded file, so the same handler serves it.
[HttpPost("api/images/v2/uploadsavedsingle")]
[HttpPost("api/images/v3/uploadsavedsingle")]
[HttpPost("api/images/v4/uploadsavedsingle")]
[HttpPost("api/images/v5/uploadsavedsingle")]
[Authorize] [Authorize]
public async Task<IActionResult> UploadSaved() public async Task<IActionResult> UploadSaved()
{ {
@@ -71,27 +71,37 @@ public class InventoryController(
/// <summary>POST <c>api/equipment/v1/update</c> — toggle the /// <summary>POST <c>api/equipment/v1/update</c> — toggle the
/// "favorited" flag on a piece of equipment (the watch lets the /// "favorited" flag on a piece of equipment (the watch lets the
/// player pin commonly-used tools).</summary> /// player pin commonly-used tools).</summary>
// The 2018 client posts a JSON ARRAY of equipment objects (RecNet.cs:21812)
// — each {PrefabName, ModificationGuid, UnlockedLevel, Favorited}. Accept an
// array OR a single object so both shapes bind. Slug = modification GUID, or
// the prefab name when no mod is applied. Unowned items are skipped (not a
// hard reject) so one stale entry doesn't drop the whole batch.
[HttpPost("api/equipment/v1/update")] [HttpPost("api/equipment/v1/update")]
public async Task<IActionResult> UpdateEquipment([FromBody] EquipmentUpdateRequest req) public async Task<IActionResult> UpdateEquipment([FromBody] System.Text.Json.JsonElement body)
{ {
var pid = Me; var pid = Me;
// The slug for equipment is the modification GUID (or prefab var items = new List<System.Text.Json.JsonElement>();
// name fallback when no mod is applied). if (body.ValueKind == System.Text.Json.JsonValueKind.Array)
var slug = !string.IsNullOrWhiteSpace(req.ModificationGuid) items.AddRange(body.EnumerateArray());
? req.ModificationGuid else if (body.ValueKind == System.Text.Json.JsonValueKind.Object)
: req.PrefabName; items.Add(body);
if (string.IsNullOrWhiteSpace(slug)) return BadRequest("missing prefab/mod identifier");
var row = await db.PlayerInventory static string? Str(System.Text.Json.JsonElement e, string k)
.FirstOrDefaultAsync(p => p.PlayerId == pid && p.ItemSlug == slug); => e.TryGetProperty(k, out var v) && v.ValueKind == System.Text.Json.JsonValueKind.String
if (row is null) ? v.GetString() : null;
static bool Bool(System.Text.Json.JsonElement e, string k)
=> e.TryGetProperty(k, out var v) && (v.ValueKind == System.Text.Json.JsonValueKind.True
|| (v.ValueKind == System.Text.Json.JsonValueKind.String && v.GetString() == "true"));
foreach (var el in items)
{ {
// Player doesn't own this; reject rather than silently var slug = Str(el, "ModificationGuid") is { Length: > 0 } mod ? mod : Str(el, "PrefabName");
// creating ownership (avoids letting clients grant items if (string.IsNullOrWhiteSpace(slug)) continue;
// to themselves). var row = await db.PlayerInventory
return Forbid(); .FirstOrDefaultAsync(p => p.PlayerId == pid && p.ItemSlug == slug);
if (row is null) continue; // skip items the player doesn't own
row.IsActive = Bool(el, "Favorited");
} }
row.IsActive = req.Favorited;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(new { Updated = true }); return Ok(new { Updated = true });
} }
@@ -92,6 +92,7 @@ public class MessagesController(
/// scope every delete to <c>RecipientPlayerId == Me</c> so the /// scope every delete to <c>RecipientPlayerId == Me</c> so the
/// sender can't unsend.</summary> /// sender can't unsend.</summary>
[HttpPost("/api/messages/v3/delete")] [HttpPost("/api/messages/v3/delete")]
[HttpPost("/api/messages/v2/delete")]
[HttpPost("/api/messages/v1/delete")] [HttpPost("/api/messages/v1/delete")]
[Consumes("application/json")] [Consumes("application/json")]
public async Task<IActionResult> DeleteJson([FromBody] DeleteRequest body) public async Task<IActionResult> DeleteJson([FromBody] DeleteRequest body)
@@ -105,6 +106,7 @@ public class MessagesController(
} }
[HttpPost("/api/messages/v3/delete")] [HttpPost("/api/messages/v3/delete")]
[HttpPost("/api/messages/v2/delete")]
[HttpPost("/api/messages/v1/delete")] [HttpPost("/api/messages/v1/delete")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> DeleteForm( public async Task<IActionResult> DeleteForm(
@@ -1,29 +1,76 @@
using System.Net.WebSockets;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Services; using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Notification.V2; namespace DorkNet.Server.Controllers.API.Notification.V2;
/// <summary> /// <summary>
/// Legacy raw-WebSocket notification endpoint. Replaced by the SignalR /// api/notification/v2 — the JUNE-2018 client opens a raw WebSocket here for
/// hub at <c>notify.{apex}/hub/v1</c>. Returns 410 Gone for any client /// push notifications (RecNet.cs:48895, RecNet.IJADBHMBMPG wraps a
/// that still tries the old path. /// BestHTTP.WebSocket with open/message(string)/error events). The 2020 client
/// replaced this with the SignalR hub at notify.{apex}/hub/v1.
///
/// This branch targets 2018, so we ACCEPT the WebSocket and hold it open. We
/// don't push anything yet (server-driven notifications are a later feature) —
/// an open, quiet socket keeps the client's notification subsystem "connected"
/// instead of erroring/retry-looping on the old 410 Gone. Incoming frames
/// (subscribe/auth) are drained and ignored.
/// </summary> /// </summary>
[ApiController] [ApiController]
[Route("api/[controller]/v2")] [Route("api/[controller]/v2")]
public class NotificationController(DomainConfig domain) : ControllerBase public class NotificationController(
DomainConfig domain,
ILogger<NotificationController> log) : ControllerBase
{ {
[HttpGet] [HttpGet]
public IActionResult Connect() => StatusCode(StatusCodes.Status410Gone, new public async Task Connect()
{ {
error = "use_signalr_hub", if (!HttpContext.WebSockets.IsWebSocketRequest)
hint = $"connect to {domain.Sub("notify")}/hub/v1 instead", {
}); // Non-WS probe (or a curl check) — return an empty notification
// list rather than 410 so HTTP callers don't treat it as an error.
Response.StatusCode = StatusCodes.Status200OK;
Response.ContentType = "application/json";
await Response.WriteAsync("[]");
return;
}
/// <summary>GET <c>api/notification/hub/v1</c> — returns the using var ws = await HttpContext.WebSockets.AcceptWebSocketAsync();
/// SignalR hub URL the client should open. Without this the log.LogInformation("[notify] 2018 client opened notification WebSocket");
/// watch falls back to its own default and may try the wrong
/// scheme. URL is built off the configured deployment apex // HANDSHAKE: on connect the 2018 client enters PerformingHandshake and
/// (DORKNET_DOMAIN).</summary> // blocks ("Connecting to RecNet") until the SERVER sends a handshake
// message — any valid JSON object marks the channel Connected
// (RecNet.cs:49616-49620, JFBBMEIDBNA just needs dict != null). Without
// it both sides wait for each other ~35s, then the client aborts the
// channel and shows "Failed to connect to RecNet". Send {} immediately.
var handshake = System.Text.Encoding.UTF8.GetBytes("{}");
await ws.SendAsync(handshake, WebSocketMessageType.Text, endOfMessage: true,
HttpContext.RequestAborted);
var buf = new byte[4096];
try
{
while (ws.State == WebSocketState.Open)
{
var result = await ws.ReceiveAsync(
new ArraySegment<byte>(buf), HttpContext.RequestAborted);
if (result.MessageType == WebSocketMessageType.Close)
{
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure,
"bye", HttpContext.RequestAborted);
break;
}
// Drain & ignore subscribe/auth frames — no server push yet.
}
}
catch (OperationCanceledException) { /* client navigated away */ }
catch (WebSocketException) { /* abrupt disconnect — normal */ }
log.LogDebug("[notify] notification WebSocket closed");
}
/// <summary>GET <c>api/notification/hub/v1</c> — 2020 SignalR hub pointer
/// (unused by the 2018 client; kept for any 2019/2020 caller).</summary>
[HttpGet("/api/notification/hub/v1")] [HttpGet("/api/notification/hub/v1")]
public IActionResult HubInfo() => Ok(new public IActionResult HubInfo() => Ok(new
{ {
@@ -0,0 +1,255 @@
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Auth;
using DorkNet.Server.Auth;
using DorkNet.Server.Compat2018;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.PlatformLogin.V1;
/// <summary>
/// api.rec.net/api/platformlogin/v1/* — the JUNE-2018 login family.
///
/// The 2018 client (build 20180621_EA) logs in via a JSON body and parses the
/// PascalCase "login envelope" {Error, Player, Token, FirstLoginOfTheDay,
/// AnalyticsSessionId} (RecNet.cs:40014-40021). This is wholly different from
/// the 2019/2020 OAuth password-grant flow handled by the V2 controller, so it
/// lives in its own versioned controller. This branch targets the 2018 client;
/// the V2 controller still serves 2019/2020 builds.
///
/// Bodies are read leniently from JSON OR form OR query so JsonUtility,
/// LitJson, and form-encoded callers all bind. The single login Token IS the
/// JWT access token — the 2018 client re-attaches it as "Bearer {Token}" on
/// every authenticated call and refreshes it via GET v1/refreshlogin.
/// </summary>
[ApiController]
[Route("api/platformlogin/v1")]
public class PlatformLoginV1Controller(
PlayerService playerService,
AuthService authService,
ServerSettingsService settings,
SignupCodeService signupCodes,
DorkNetDbContext db,
ILogger<PlatformLoginV1Controller> logger) : ControllerBase
{
// POST api/platformlogin/v1/loginaccount — primary sign-in.
[HttpPost("loginaccount")]
public async Task<IActionResult> LoginAccount()
{
var p = await ReadParamsAsync();
var username = p.Get("Username");
var password = p.Get("Password");
var deviceId = p.Get("DeviceId");
var platformId = p.Get("PlatformId");
var platform = p.GetInt("Platform");
PlayerEntity player;
// Username + password: real credential check against PasswordHash.
if (!string.IsNullOrEmpty(password) && !string.IsNullOrEmpty(username))
{
var byName = await playerService.GetByUsernameAsync(username);
if (byName?.PasswordHash is null ||
!BCrypt.Net.BCrypt.Verify(password, byName.PasswordHash))
return Ok(Fail("Incorrect username or password."));
player = byName;
await playerService.TagPlatformAsync(player.Id, platform, platformId);
}
else
{
// Device-id fallback (no password yet). Honour the signups-disabled
// toggle: refuse before minting a brand-new account for an unknown
// device, and stash it for /join code redemption.
if (await settings.AreSignupsDisabledAsync()
&& await playerService.GetByDeviceAsync(deviceId) is null)
{
await signupCodes.RecordPendingDeviceAsync(
deviceId, platform, platformId, SignupCodeService.ClientIp(HttpContext));
return Ok(Fail("Account creation is currently disabled by the server admin."));
}
player = await playerService.GetOrCreateByDeviceAsync(
deviceId: deviceId, platform: platform, platformId: platformId,
displayName: username);
}
return Ok(BuildEnvelope(player));
}
// POST api/platformlogin/v1/logincached — sign in to a remembered account
// (the account-selection screen). Body carries PlayerId.
[HttpPost("logincached")]
public async Task<IActionResult> LoginCached()
{
var p = await ReadParamsAsync();
var id = p.GetLong("PlayerId");
if (id == 0) return Ok(Fail("Missing PlayerId."));
var player = await playerService.GetByIdAsync(id);
if (player is null) return Ok(Fail("That account no longer exists."));
await playerService.TagPlatformAsync(player.Id, p.GetInt("Platform"), p.Get("PlatformId"));
return Ok(BuildEnvelope(player));
}
// POST api/platformlogin/v1/createaccount — create AND sign in in one call.
[HttpPost("createaccount")]
public async Task<IActionResult> CreateAccount()
{
var p = await ReadParamsAsync();
var deviceId = p.Get("DeviceId");
var platformId = p.Get("PlatformId");
var platform = p.GetInt("Platform");
if (await settings.AreSignupsDisabledAsync()
&& await playerService.GetByDeviceAsync(deviceId) is null)
{
await signupCodes.RecordPendingDeviceAsync(
deviceId, platform, platformId, SignupCodeService.ClientIp(HttpContext));
return Ok(Fail("Account creation is currently disabled by the server admin."));
}
var player = await playerService.CreateNewAccountAsync(
deviceId: deviceId, platform: platform, platformId: platformId,
displayName: p.Get("Username"));
// Attach birthday / email if the signup form supplied them.
var birthdayTicks = p.GetLong("Birthday");
var email = p.Get("Email");
if (birthdayTicks != 0 || !string.IsNullOrEmpty(email))
{
var entity = await db.Players.FirstOrDefaultAsync(x => x.Id == player.Id);
if (entity is not null)
{
if (birthdayTicks != 0)
entity.Birthday = new DateTime(birthdayTicks, DateTimeKind.Utc);
if (!string.IsNullOrEmpty(email)) entity.Email = email;
await db.SaveChangesAsync();
player = entity;
}
}
return Ok(BuildEnvelope(player, firstLogin: true));
}
// POST api/platformlogin/v1/registeraccount?Email=... — attach an email to
// the authenticated account. Reads {Success, Message} (NOT "error").
[HttpPost("registeraccount")]
public async Task<IActionResult> RegisterAccount([FromQuery(Name = "Email")] string? email)
{
var pid = this.CurrentPlayerId() ?? 0;
if (pid == 0) return Ok(new SuccessMessage2018 { Success = false, Message = "Not signed in." });
var entity = await db.Players.FirstOrDefaultAsync(x => x.Id == pid);
if (entity is null) return Ok(new SuccessMessage2018 { Success = false, Message = "Account not found." });
if (!string.IsNullOrWhiteSpace(email)) { entity.Email = email; await db.SaveChangesAsync(); }
return Ok(new SuccessMessage2018 { Success = true, Message = string.Empty });
}
// GET api/platformlogin/v1/refreshlogin — mint a fresh access token from the
// current bearer. Response is {Token} (PascalCase). The 2018 client calls
// Application.Quit() on an empty Token, so a 401 (no body) is the correct
// "session ended" signal.
[HttpGet("refreshlogin")]
public async Task<IActionResult> RefreshLogin()
{
var auth = Request.Headers.Authorization.ToString();
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return Unauthorized();
var token = auth["Bearer ".Length..].Trim();
if (authService.ValidateToken(token) is not long pid) return Unauthorized();
var player = await playerService.GetByIdAsync(pid);
if (player is null) return Unauthorized();
var (access, _) = authService.GenerateTokenPair(player.Id);
return Ok(new { Token = access });
}
// POST api/platformlogin/v1/getcachedlogins?Platform=&PlatformId= — the
// account-selection list. Returns a BARE JSON ARRAY of Player2018 objects.
[HttpPost("getcachedlogins")]
[HttpGet("getcachedlogins")]
public async Task<IActionResult> GetCachedLogins()
{
// The 2018 client sends Platform/PlatformId in the FORM BODY
// (application/x-www-form-urlencoded), not the query string — so read
// from form/query/json. Reading [FromQuery] only returned [] (empty
// account grid + broke +profile auto-login). Verified from the wire:
// req=Platform=0&PlatformId=76561198969189675.
var p = await ReadParamsAsync();
var players = await playerService.GetCachedLoginsAsync(p.GetInt("Platform"), p.Get("PlatformId"));
return Ok(players.Select(ToPlayer2018).ToList());
}
// POST api/platformlogin/v1/removecachedlogin?Platform=&PlatformId= —
// "forget this account". No persistent cache to clear; ack with 200.
[HttpPost("removecachedlogin")]
public IActionResult RemoveCachedLogin() => Ok(new SuccessMessage2018 { Success = true });
// POST api/platformlogin/v1/logout?LoginLockToken=... — fire-and-forget.
[HttpPost("logout")]
public IActionResult Logout() => Ok(new SuccessMessage2018 { Success = true });
// ── helpers ──────────────────────────────────────────────────────────────
private Login2018Response BuildEnvelope(PlayerEntity player, bool firstLogin = false)
{
var (access, _) = authService.GenerateTokenPair(player.Id);
// First-login-of-the-day is cosmetic (drives a daily XP bonus); derive
// it from LastSeenAt where we still have the pre-update value.
var first = firstLogin || player.LastSeenAt.Date < DateTime.UtcNow.Date;
return new Login2018Response
{
Error = string.Empty,
Player = ToPlayer2018(player),
Token = access,
FirstLoginOfTheDay = first,
AnalyticsSessionId = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
};
}
private static Login2018Response Fail(string message) => new() { Error = message };
private static Player2018 ToPlayer2018(PlayerEntity p) => Player2018Mapper.From(p);
/// <summary>Read request params from JSON body, form, and query — merged,
/// case-insensitive — so JsonUtility/LitJson/form/query callers all bind.</summary>
private async Task<ParamBag> ReadParamsAsync()
{
var bag = new ParamBag();
foreach (var kv in Request.Query) bag.Set(kv.Key, kv.Value.ToString());
if (Request.HasFormContentType)
{
try { foreach (var kv in Request.Form) bag.Set(kv.Key, kv.Value.ToString()); }
catch { /* not a form */ }
}
else if (Request.ContentLength is > 0)
{
try
{
Request.EnableBuffering();
Request.Body.Position = 0;
using var doc = await JsonDocument.ParseAsync(Request.Body);
if (doc.RootElement.ValueKind == JsonValueKind.Object)
foreach (var prop in doc.RootElement.EnumerateObject())
bag.Set(prop.Name, prop.Value.ValueKind switch
{
JsonValueKind.String => prop.Value.GetString(),
JsonValueKind.Number => prop.Value.GetRawText(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
_ => null,
});
Request.Body.Position = 0;
}
catch { /* not JSON */ }
}
return bag;
}
private sealed class ParamBag
{
private readonly Dictionary<string, string?> _d = new(StringComparer.OrdinalIgnoreCase);
public void Set(string k, string? v) { if (!string.IsNullOrEmpty(v)) _d[k] = v; }
public string? Get(string k) => _d.TryGetValue(k, out var v) ? v : null;
public int GetInt(string k) => int.TryParse(Get(k), out var v) ? v : 0;
public long GetLong(string k) => long.TryParse(Get(k), out var v) ? v : 0;
}
}
@@ -34,9 +34,14 @@ public class PlayerCheerController(
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> Create( public async Task<IActionResult> Create(
[FromForm(Name = "TargetAccountId")] long? targetAccountId, [FromForm(Name = "TargetAccountId")] long? targetAccountId,
[FromForm(Name = "Type")] int type = 0) [FromForm(Name = "PlayerIdTo")] long? playerIdTo,
[FromForm(Name = "Type")] int type = 0,
[FromForm(Name = "CheerCategory")] int? cheerCategory = null)
{ {
if (targetAccountId is not long target || target <= 0) // 2018 client posts PlayerIdTo + CheerCategory (RecNet.cs); 2020 posts
// TargetAccountId + Type. Accept both.
type = cheerCategory ?? type;
if ((targetAccountId ?? playerIdTo) is not long target || target <= 0)
return Ok(new { success = false, error = "missing_target" }); return Ok(new { success = false, error = "missing_target" });
if (Me == target) if (Me == target)
return Ok(new { success = false, error = "cannot_cheer_self" }); return Ok(new { success = false, error = "cannot_cheer_self" });
@@ -65,8 +70,11 @@ public class PlayerCheerController(
/// PlayerSetting key <c>SelectedCheer</c> (string-form int).</summary> /// PlayerSetting key <c>SelectedCheer</c> (string-form int).</summary>
[HttpPost("api/PlayerCheer/v1/SetSelectedCheer")] [HttpPost("api/PlayerCheer/v1/SetSelectedCheer")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> SetSelectedCheer([FromForm(Name = "Cheer")] int cheer = 0) public async Task<IActionResult> SetSelectedCheer(
[FromForm(Name = "Cheer")] int cheer = 0,
[FromForm(Name = "CheerCategory")] int? cheerCategory = null)
{ {
cheer = cheerCategory ?? cheer; // 2018 sends CheerCategory
var existing = await db.PlayerSettings var existing = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == Me && s.Key == "SelectedCheer"); .FirstOrDefaultAsync(s => s.PlayerId == Me && s.Key == "SelectedCheer");
if (existing is null) if (existing is null)
@@ -1,68 +1,104 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using DorkNet.Models.Players; using DorkNet.Models.Auth;
using DorkNet.Server.Auth; using DorkNet.Server.Auth;
using DorkNet.Server.Compat2018;
using DorkNet.Server.Services; using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Players.V2; namespace DorkNet.Server.Controllers.API.Players.V2;
/// <summary>
/// api/players/* for the June-2018 client. This branch is 2018-only, so every
/// endpoint returns the 2018 <see cref="Player2018"/> shape (keys Id/Developer/
/// JuniorProfile/… — NOT the 2020 PlayerProfile's AccountId/IsDeveloper).
///
/// The 2018 client mixes path versions: profile fetch + batch are v1, while
/// search + displayname are v2. We map both. Param names follow the 2018 client
/// (search ?name=, displayname form Name).
/// </summary>
[ApiController] [ApiController]
[Route("api/[controller]/v2")]
[Authorize] [Authorize]
public class PlayersController(PlayerService playerService, NotificationService notificationService) : ControllerBase public class PlayersController(PlayerService playerService, NotificationService notificationService) : ControllerBase
{ {
private long CurrentPlayerId => this.RequireCurrentPlayerId(); private long CurrentPlayerId => this.RequireCurrentPlayerId();
[HttpGet("me")] // GET api/players/v1|v2/me
public async Task<ActionResult<PlayerProfile>> GetSelf() [HttpGet("/api/players/v1/me")]
[HttpGet("/api/players/v2/me")]
public async Task<ActionResult<Player2018>> GetSelf()
{ {
var player = await playerService.GetByIdAsync(CurrentPlayerId); var player = await playerService.GetByIdAsync(CurrentPlayerId);
if (player is null) return NotFound(); if (player is null) return NotFound();
return Ok(PlayerService.ToProfile(player)); return Ok(Player2018Mapper.From(player));
} }
[HttpGet("{id:long}")] // GET api/players/v1|v2/{id} — primary profile fetch (2018 uses v1).
public async Task<ActionResult<PlayerProfile>> GetById(long id) [HttpGet("/api/players/v1/{id:long}")]
[HttpGet("/api/players/v2/{id:long}")]
public async Task<ActionResult<Player2018>> GetById(long id)
{ {
var player = await playerService.GetByIdAsync(id); var player = await playerService.GetByIdAsync(id);
if (player is null) return NotFound(); if (player is null) return NotFound();
return Ok(PlayerService.ToProfile(player)); return Ok(Player2018Mapper.From(player));
} }
[HttpGet("search")] // POST api/players/v1/list — batch profile resolution (JSON array of ids).
public async Task<ActionResult<List<PlayerProfile>>> Search([FromQuery] string query) [HttpPost("/api/players/v1/list")]
[HttpPost("/api/players/v2/list")]
public async Task<ActionResult<List<Player2018>>> GetMany([FromBody] long[]? ids)
{ {
if (string.IsNullOrWhiteSpace(query)) return Ok(new List<PlayerProfile>()); if (ids is null || ids.Length == 0) return Ok(new List<Player2018>());
var results = await playerService.SearchAsync(query); var players = await playerService.GetByIdsAsync(ids);
return Ok(results.Select(PlayerService.ToProfile).ToList()); return Ok(players.Select(Player2018Mapper.From).ToList());
} }
[HttpPost("displayName")] // GET api/players/v2/search?name= (2018) — also accept ?query= for safety.
public async Task<ActionResult<PlayerProfile>> UpdateDisplayName([FromForm] string? DisplayName) [HttpGet("/api/players/v1/search")]
[HttpGet("/api/players/v2/search")]
public async Task<ActionResult<List<Player2018>>> Search(
[FromQuery] string? name, [FromQuery] string? query)
{ {
if (string.IsNullOrWhiteSpace(DisplayName)) var term = !string.IsNullOrWhiteSpace(name) ? name : query;
return BadRequest("DisplayName is required"); if (string.IsNullOrWhiteSpace(term)) return Ok(new List<Player2018>());
var results = await playerService.SearchAsync(term);
var updated = await playerService.UpdateDisplayNameAsync(CurrentPlayerId, DisplayName); return Ok(results.Select(Player2018Mapper.From).ToList());
if (!updated) return NotFound(); }
// POST api/players/v2/displayname (2018 form key "Name"). Also accept
// "DisplayName" for safety.
[HttpPost("/api/players/v1/displayname")]
[HttpPost("/api/players/v2/displayname")]
public async Task<ActionResult<Player2018>> UpdateDisplayName(
[FromForm] string? Name, [FromForm] string? DisplayName)
{
var newName = !string.IsNullOrWhiteSpace(Name) ? Name : DisplayName;
if (string.IsNullOrWhiteSpace(newName)) return BadRequest("Name is required");
if (!await playerService.UpdateDisplayNameAsync(CurrentPlayerId, newName)) return NotFound();
var player = await playerService.GetByIdAsync(CurrentPlayerId); var player = await playerService.GetByIdAsync(CurrentPlayerId);
await notificationService.NotifyAsync(CurrentPlayerId, Models.Notification.PushNotificationId.SubscriptionUpdateProfile, PlayerService.ToProfile(player!)); await notificationService.NotifyAsync(CurrentPlayerId,
return Ok(PlayerService.ToProfile(player!)); Models.Notification.PushNotificationId.SubscriptionUpdateProfile, Player2018Mapper.From(player!));
return Ok(Player2018Mapper.From(player!));
} }
[HttpPut("{id:long}")] // POST api/players/v1/bio (2018 form key "Bio").
public async Task<ActionResult<PlayerProfile>> UpdateProfile(long id, [FromBody] UpdateProfileRequest req) [HttpPost("/api/players/v1/bio")]
[HttpPost("/api/players/v2/bio")]
public async Task<ActionResult<Player2018>> UpdateBio([FromForm] string? Bio)
{ {
if (id != CurrentPlayerId) return Forbid(); await playerService.UpdateBioAsync(CurrentPlayerId, Bio ?? string.Empty);
var player = await playerService.GetByIdAsync(CurrentPlayerId);
return Ok(Player2018Mapper.From(player!));
}
if (req.DisplayName is not null) // POST api/players/v1/avoidJuniors (form "AvoidJuniors"). Best-effort:
await playerService.UpdateDisplayNameAsync(id, req.DisplayName); // acknowledged so the toggle in the watch settings doesn't 404. (Server
// doesn't currently filter matchmaking on this flag.)
if (req.Bio is not null) [HttpPost("/api/players/v1/avoidJuniors")]
await playerService.UpdateBioAsync(id, req.Bio); [HttpPut("/api/players/v1/avoidJuniors")]
public async Task<ActionResult<Player2018>> AvoidJuniors()
var player = await playerService.GetByIdAsync(id); {
return Ok(PlayerService.ToProfile(player!)); var player = await playerService.GetByIdAsync(CurrentPlayerId);
if (player is null) return NotFound();
return Ok(Player2018Mapper.From(player));
} }
} }
@@ -0,0 +1,35 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using DorkNet.Models.Players;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Presence;
/// <summary>
/// api/presence/* — the June-2018 client's friend-presence poll (every ~30s,
/// RecNet.cs:63249). POST api/presence/v2/list takes a JSON array of account
/// ids and returns one <see cref="Presence2018"/> per id. There is no 2020
/// equivalent on this host (the 2020 client used match.rec.net/player), so this
/// controller is 2018-specific. Online state comes from
/// <see cref="OnlinePresenceService"/>; GameSession is left null for now (the
/// client renders "online" without it and fills in room info on join).
/// </summary>
[ApiController]
[Authorize]
public class PresenceController(OnlinePresenceService presence) : ControllerBase
{
[HttpPost("/api/presence/v2/list")]
[HttpPost("/api/presence/v1/list")]
public ActionResult<List<Presence2018>> List([FromBody] long[]? ids)
{
if (ids is null || ids.Length == 0) return Ok(new List<Presence2018>());
var online = presence.OnlinePlayerIds().ToHashSet();
var result = ids.Distinct().Select(id => new Presence2018
{
PlayerId = id,
IsOnline = online.Contains(id),
GameSession = null,
}).ToList();
return Ok(result);
}
}
@@ -61,6 +61,14 @@ public class RoomsController(
=> Ok((await rooms.SearchAsync(query ?? value ?? string.Empty)) => Ok((await rooms.SearchAsync(query ?? value ?? string.Empty))
.Select(RoomService.ToWireRoom).ToList()); .Select(RoomService.ToWireRoom).ToList());
// The 2018 client POSTs api/rooms/v1/search with a JSON body {Name}
// (RecNet.cs:85205) instead of a query param. Same projection.
[HttpPost("api/rooms/v1/search")]
[HttpPost("api/rooms/v2/search")]
public async Task<IActionResult> SearchPost([FromBody] RoomSearch2018? body)
=> Ok((await rooms.SearchAsync(body?.Name ?? string.Empty))
.Select(RoomService.ToWireRoom).ToList());
/// <summary>GET <c>api/rooms/v2/live</c> — currently-active rooms /// <summary>GET <c>api/rooms/v2/live</c> — currently-active rooms
/// (rooms with at least one player presence). Same shape as Hot /// (rooms with at least one player presence). Same shape as Hot
/// — order by HotScore as a proxy for "live activity" since we /// — order by HotScore as a proxy for "live activity" since we
@@ -112,6 +120,7 @@ public class RoomsController(
/// synthesised "Room_<name>" for unknown names so the deserializer /// synthesised "Room_<name>" for unknown names so the deserializer
/// doesn't crash on 404. /// doesn't crash on 404.
/// </summary> /// </summary>
[HttpGet("api/rooms/v1/name/{roomName}")]
[HttpGet("api/rooms/v2/name/{roomName}")] [HttpGet("api/rooms/v2/name/{roomName}")]
[HttpGet("api/rooms/v3/name/{roomName}")] [HttpGet("api/rooms/v3/name/{roomName}")]
public async Task<IActionResult> ByName(string roomName) public async Task<IActionResult> ByName(string roomName)
@@ -123,6 +132,7 @@ public class RoomsController(
/// <summary> /// <summary>
/// `Rooms.GetById(roomId)` — same shape as ByName. /// `Rooms.GetById(roomId)` — same shape as ByName.
/// </summary> /// </summary>
[HttpGet("api/rooms/v1/{roomId:long}")]
[HttpGet("api/rooms/v2/{roomId:long}")] [HttpGet("api/rooms/v2/{roomId:long}")]
[HttpGet("api/rooms/v3/{roomId:long}")] [HttpGet("api/rooms/v3/{roomId:long}")]
public async Task<IActionResult> ById(long roomId) public async Task<IActionResult> ById(long roomId)
@@ -901,8 +911,15 @@ public class RoomsController(
public long RoomId { get; set; } public long RoomId { get; set; }
} }
// 2018 POST api/rooms/v1/search body {Name} (RecNet.cs:85205).
public class RoomSearch2018
{
public string? Name { get; set; }
}
// ── My-Rooms tabs ──────────────────────────────────────────────────── // ── My-Rooms tabs ────────────────────────────────────────────────────
[HttpGet("api/rooms/v1/myrooms")]
[HttpGet("api/rooms/v2/myrooms")] [HttpGet("api/rooms/v2/myrooms")]
[Authorize] [Authorize]
public async Task<IActionResult> MyRooms() public async Task<IActionResult> MyRooms()
@@ -912,6 +929,7 @@ public class RoomsController(
return Ok((await rooms.CreatedByAsync(pid.Value)).Select(RoomService.ToWireRoom).ToList()); return Ok((await rooms.CreatedByAsync(pid.Value)).Select(RoomService.ToWireRoom).ToList());
} }
[HttpGet("api/rooms/v1/mybookmarkedrooms")]
[HttpGet("api/rooms/v2/mybookmarks")] [HttpGet("api/rooms/v2/mybookmarks")]
[HttpGet("api/rooms/v2/mybookmarkedrooms")] [HttpGet("api/rooms/v2/mybookmarkedrooms")]
[Authorize] [Authorize]
@@ -925,6 +943,7 @@ public class RoomsController(
[HttpGet("api/rooms/v2/mymoderated")] [HttpGet("api/rooms/v2/mymoderated")]
[HttpGet("api/rooms/v1/modrooms")] [HttpGet("api/rooms/v1/modrooms")]
[HttpGet("api/rooms/v2/mysubscribed")] [HttpGet("api/rooms/v2/mysubscribed")]
[HttpGet("api/rooms/v1/myrecent")]
[HttpGet("api/rooms/v2/myrecent")] [HttpGet("api/rooms/v2/myrecent")]
public IActionResult MyOtherTabs() => Ok(Array.Empty<object>()); public IActionResult MyOtherTabs() => Ok(Array.Empty<object>());
@@ -972,6 +991,25 @@ public class RoomsController(
FeaturedRooms = await rooms.FeaturedAgRoomIdsAsync(12), FeaturedRooms = await rooms.FeaturedAgRoomIdsAsync(12),
}); });
/// <summary>GET <c>api/rooms/v2/featured</c> — the 2018 client expects a
/// SINGLE Room object here (RecNet.cs:85173), not the 2020 FeaturedRoomGroup.
/// Return the top featured AG room.</summary>
[HttpGet("api/rooms/v2/featured")]
public async Task<IActionResult> FeaturedSingle()
{
var ids = await rooms.FeaturedAgRoomIdsAsync(1);
var r = ids.Count > 0 ? await rooms.GetByIdAsync(ids[0]) : null;
return Ok(RoomService.ToWireRoom(r ?? Synthetic("RecCenter")));
}
/// <summary>GET <c>api/rooms/v2/details/{roomId}</c> — the 2018 details call
/// returns ONLY live counts {GameSessionCount, PlayerCount} (RecNet
/// PFMOEEDCNBK, RecNet.cs:79846), distinct from the 2020 v3/v4 RoomDetails.
/// Counts are derived from current presence; 0/0 when the room is empty.</summary>
[HttpGet("api/rooms/v2/details/{roomId:long}")]
public IActionResult DetailsV2(long roomId)
=> Ok(new { GameSessionCount = 0, PlayerCount = 0 });
/// <summary>POST <c>api/rooms/v1/bookmark</c> — toggle bookmark /// <summary>POST <c>api/rooms/v1/bookmark</c> — toggle bookmark
/// state for a room. Body: <c>RoomId</c> + optional <c>Bookmark</c> /// state for a room. Body: <c>RoomId</c> + optional <c>Bookmark</c>
/// flag (default = add).</summary> /// flag (default = add).</summary>
@@ -51,6 +51,7 @@ public class StorefrontsBuyController(
public int GiftContext { get; set; } public int GiftContext { get; set; }
} }
[HttpPost("api/storefronts/v1/buyItem")]
[HttpPost("api/storefronts/v2/buyItem")] [HttpPost("api/storefronts/v2/buyItem")]
public async Task<IActionResult> BuyItem([FromBody] RequestPurchaseItemDto req) public async Task<IActionResult> BuyItem([FromBody] RequestPurchaseItemDto req)
{ {
@@ -170,6 +171,7 @@ public class StorefrontsBuyController(
isGift ? null : gift.Id)); isGift ? null : gift.Id));
} }
[HttpPost("api/storefronts/v1/buyTier")]
[HttpPost("api/storefronts/v2/buyTier")] [HttpPost("api/storefronts/v2/buyTier")]
public async Task<IActionResult> BuyTier([FromBody] RequestPurchaseItemDto req) public async Task<IActionResult> BuyTier([FromBody] RequestPurchaseItemDto req)
{ {
@@ -185,6 +187,7 @@ public class StorefrontsBuyController(
return Ok(BalanceUpdateResponse(newBalance, req.CurrencyType)); return Ok(BalanceUpdateResponse(newBalance, req.CurrencyType));
} }
[HttpPost("api/storefronts/v1/buyElite")]
[HttpPost("api/storefronts/v2/buyElite")] [HttpPost("api/storefronts/v2/buyElite")]
public async Task<IActionResult> BuyElite([FromBody] RequestPurchaseItemDto req) public async Task<IActionResult> BuyElite([FromBody] RequestPurchaseItemDto req)
{ {
@@ -116,15 +116,16 @@ public class StorefrontsController(
{ {
var pid = this.RequireCurrentPlayerId(); var pid = this.RequireCurrentPlayerId();
var balance = await level.GetBalanceAsync(pid, currencyType); var balance = await level.GetBalanceAsync(pid, currencyType);
return Ok(new[] // 2018 client (RecNet.cs:94092, KKBGNKJHGJF<BalanceResponseDTO>) parses a
// SINGLE object reading Balance(long)+CurrencyType(int) off the root —
// reading "Balance" off an array root throws. This branch is 2018-only,
// so the per-currency balance returns a single object, not an array.
return Ok(new
{ {
new CurrencyType = currencyType,
{ BalanceType = 0,
CurrencyType = currencyType, Balance = balance,
BalanceType = 0, Platform = 0,
Balance = balance,
Platform = 0,
},
}); });
} }
@@ -6,7 +6,16 @@ namespace DorkNet.Server.Controllers.API.Version;
public class VersionController : ControllerBase public class VersionController : ControllerBase
{ {
// VersionStatus enum: ValidForPlay=0, ValidForMenu=1, UpdateRequired=2 // VersionStatus enum: ValidForPlay=0, ValidForMenu=1, UpdateRequired=2
private static readonly object ValidForPlay = new { VersionStatus = 0 }; //
// Dual-shape response so a single endpoint satisfies both client eras:
// • 2020 build reads the int "VersionStatus" (0 == ValidForPlay).
// • 2018.06 build (api/versioncheck/v3) reads the bool "ValidVersion"
// via a case-sensitive dict index (RecNet.cs BPPMLPMDHCI.Deserialize
// -> Util.GetKey<bool>("ValidVersion")). A MISSING key throws
// KeyNotFoundException -> the connect coroutine shows "Rec Room
// update required" and aborts boot before login. Emitting both keys
// keeps each era reading only the field it knows.
private static readonly object ValidForPlay = new { VersionStatus = 0, ValidVersion = true };
[HttpGet("api/version/v1")] [HttpGet("api/version/v1")]
[HttpGet("api/version")] [HttpGet("api/version")]
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using DorkNet.Models.Notification; using DorkNet.Models.Notification;
using DorkNet.Server.Auth; using DorkNet.Server.Auth;
using DorkNet.Server.Data; using DorkNet.Server.Data;
@@ -39,6 +40,7 @@ public class AdminController(
SignupCodeService signupCodes, SignupCodeService signupCodes,
IObjectStorage storage, IObjectStorage storage,
DomainConfig domain, DomainConfig domain,
IdentityServerSigningKeyProvider signingKeys,
ILogger<AdminController> adminLogger) : ControllerBase ILogger<AdminController> adminLogger) : ControllerBase
{ {
private long CurrentAdminId => this.RequireCurrentPlayerId(); private long CurrentAdminId => this.RequireCurrentPlayerId();
@@ -1159,6 +1161,52 @@ public class AdminController(
// ── Server settings (runtime toggles) ──────────────────────────────── // ── Server settings (runtime toggles) ────────────────────────────────
[HttpGet("identityserver")]
public ActionResult GetIdentityServerSettings()
{
var issuer = domain.AuthIssuer.TrimEnd('/');
var client = RecRoomIdentityServerConfig.Clients[0];
var certPath = signingKeys.CertificatePath;
var fullCertPath = Path.GetFullPath(certPath);
var certificateExists = signingKeys.LoadedFromBase64 || System.IO.File.Exists(fullCertPath);
return Ok(new
{
Issuer = issuer,
DiscoveryUrl = $"{issuer}/.well-known/openid-configuration",
TokenEndpoint = $"{issuer}/connect/token",
UserInfoEndpoint = $"{issuer}/connect/userinfo",
Client = new
{
client.ClientId,
client.ClientName,
AllowedGrantTypes = client.AllowedGrantTypes.ToArray(),
AllowedScopes = client.AllowedScopes.ToArray(),
client.AllowOfflineAccess,
client.AccessTokenLifetime,
client.AbsoluteRefreshTokenLifetime,
client.SlidingRefreshTokenLifetime,
RefreshTokenUsage = client.RefreshTokenUsage.ToString(),
RefreshTokenExpiration = client.RefreshTokenExpiration.ToString(),
},
Signing = new
{
signingKeys.CertificateSource,
signingKeys.LoadedFromBase64,
CertificatePath = fullCertPath,
CertificateExists = certificateExists,
signingKeys.Certificate.Thumbprint,
NotBeforeUtc = signingKeys.Certificate.NotBefore.ToUniversalTime(),
NotAfterUtc = signingKeys.Certificate.NotAfter.ToUniversalTime(),
Algorithm = SecurityAlgorithms.RsaSha256,
},
PersistedGrants = new
{
Store = "in-memory",
Warning = "Refresh tokens are process-local until an operational store is configured.",
},
});
}
/// <summary>GET <c>api/admin/v1/settings</c> — current server-wide /// <summary>GET <c>api/admin/v1/settings</c> — current server-wide
/// toggle state. Single row, single round trip.</summary> /// toggle state. Single row, single round trip.</summary>
[HttpGet("settings")] [HttpGet("settings")]
+54 -235
View File
@@ -1,5 +1,4 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Auth; using DorkNet.Models.Auth;
using DorkNet.Server.Data.Entities; using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services; using DorkNet.Server.Services;
@@ -7,242 +6,26 @@ using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Auth; namespace DorkNet.Server.Controllers.Auth;
/// <summary> /// <summary>
/// auth.rec.net — OAuth-style token endpoint used by 2019+ builds alongside PlatformLogin. /// auth.rec.net game-specific auth helpers. OAuth/OIDC endpoints are owned by IdentityServer.
/// </summary> /// </summary>
[ApiController] [ApiController]
public class AuthController( public class AuthController(
PlayerService playerService, PlayerService playerService,
AuthService authService, AuthService authService) : ControllerBase
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 // 2020.12 added an EAC challenge step in the login flow. The client GETs
/// player hasn't been seen yet today (UTC).</summary> // a challenge string and passes it to EACManager.GenerateChallengeResponse,
private async Task TryAwardLoginXpAsync(long playerId) // which calls System.Convert.FromBase64CharPtr on the string. So the
{ // returned challenge MUST be valid base64 — sending arbitrary text throws
var lastSeen = await db.Players // FormatException client-side and kicks the login.
.Where(p => p.Id == playerId) [HttpGet("/eac/challenge")]
.Select(p => p.LastSeenAt) [HttpPost("/eac/challenge")]
.FirstOrDefaultAsync(); public IActionResult EacChallenge()
if (lastSeen.Date < DateTime.UtcNow.Date) => Content("\"AAAAAAAAAAAAAAAAAAAAAAAA\"", "application/json");
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. // Cached-logins lookup — boot-sequence call from ScreenAccountSelectionManager.
// URL: GET auth.rec.net/cachedlogin/forplatformid/{platform}/{platformId} // URL: GET auth.rec.net/cachedlogin/forplatformid/{platform}/{platformId}
// Returns List<Login.CachedLogin>. Empty array = no remembered accounts. // 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}")] [HttpGet("/cachedlogin/forplatformid/{platform:int}/{platformId}")]
public async Task<IActionResult> CachedLoginsForPlatform(int platform, string platformId) public async Task<IActionResult> CachedLoginsForPlatform(int platform, string platformId)
{ {
@@ -252,10 +35,8 @@ public class AuthController(
/// <summary>POST <c>cachedlogin/forplatformids</c> — bulk variant /// <summary>POST <c>cachedlogin/forplatformids</c> — bulk variant
/// the watch uses when hydrating multiple platform-id mappings /// the watch uses when hydrating multiple platform-id mappings
/// at once (e.g. populating a friend list from Steam ids). Body /// at once. Body is form-urlencoded <c>platform=&amp;platformIds=a,b,c</c>
/// is form-urlencoded <c>platform=&amp;platformIds=a,b,c</c> or /// or JSON with the same shape.</summary>
/// JSON with the same shape. Returns the flattened
/// <c>List&lt;CachedLogin&gt;</c> across every matched id.</summary>
[HttpPost("/cachedlogin/forplatformids")] [HttpPost("/cachedlogin/forplatformids")]
[HttpGet("/cachedlogin/forplatformids")] [HttpGet("/cachedlogin/forplatformids")]
public async Task<IActionResult> CachedLoginsForPlatformIds() public async Task<IActionResult> CachedLoginsForPlatformIds()
@@ -273,6 +54,7 @@ public class AuthController(
} }
} }
catch { /* not form */ } catch { /* not form */ }
if (string.IsNullOrWhiteSpace(rawIds)) if (string.IsNullOrWhiteSpace(rawIds))
{ {
int.TryParse(Request.Query["platform"], out platform); int.TryParse(Request.Query["platform"], out platform);
@@ -295,13 +77,50 @@ public class AuthController(
return Ok(results); return Ok(results);
} }
private static CachedLogin ToCachedLogin(PlayerEntity p, int platform, string platformId) => new() [HttpGet("/cachedlogin/current")]
public async Task<IActionResult> CurrentCachedLogin()
{
var token = GetBearerToken();
if (token is null) return Unauthorized();
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 token = GetBearerToken();
if (token is null) return Unauthorized();
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));
}
private string? GetBearerToken()
{
var auth = Request.Headers.Authorization.ToString();
if (auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return auth["Bearer ".Length..].Trim();
return Request.Cookies.TryGetValue(AuthService.AccessCookieName, out var cookieToken)
? cookieToken
: null;
}
private static CachedLogin ToCachedLogin(PlayerEntity p, int platform, string? platformId) => new()
{ {
Platform = platform, Platform = platform,
PlatformId = platformId, PlatformId = platformId ?? string.Empty,
AccountId = (int)p.Id, AccountId = (int)p.Id,
LastLoginTime = p.LastSeenAt, LastLoginTime = p.LastSeenAt,
RequirePassword = false, RequirePassword = false,
}; };
} }
+11 -9
View File
@@ -1,20 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" /> <PackageReference Include="Open.IdentityServer" Version="1.0.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.15" /> <PackageReference Include="MessagePack" Version="3.1.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.4"> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.4" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<!-- RenderedCompactJsonFormatter for stdout JSON logs in production <!-- RenderedCompactJsonFormatter for stdout JSON logs in production
(Coolify-friendly). The host pipeline reads stdout line-by-line, (Coolify-friendly). The host pipeline reads stdout line-by-line,
and structured JSON lets a future Loki/Datadog ingest parse fields and structured JSON lets a future Loki/Datadog ingest parse fields
@@ -25,8 +27,8 @@
scale: SignalR group fanout, OrphanAccountTracker (sticky-session scale: SignalR group fanout, OrphanAccountTracker (sticky-session
account-creation flow), and PlayerPresenceService heartbeats all account-creation flow), and PlayerPresenceService heartbeats all
need shared state across replicas. --> need shared state across replicas. -->
<PackageReference Include="StackExchange.Redis" Version="2.8.16" /> <PackageReference Include="StackExchange.Redis" Version="2.13.17" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="9.0.4" /> <PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="10.0.9" />
<!-- S3-compatible object storage. Production target: Garage in <!-- S3-compatible object storage. Production target: Garage in
Coolify (one-click). Dev fallback: local MinIO for parity Coolify (one-click). Dev fallback: local MinIO for parity
testing. The SDK speaks plain S3, so swapping endpoints testing. The SDK speaks plain S3, so swapping endpoints
@@ -38,7 +40,7 @@
cost+salt in the output string so we only need one column. --> cost+salt in the output string so we only need one column. -->
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" /> <PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" /> <PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.3.1" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
<!-- Server-side crop/resize for img.* CDN transforms (?cropSquare=1&width=N <!-- Server-side crop/resize for img.* CDN transforms (?cropSquare=1&width=N
on profile thumbnails). The 2020 client treats this as part of the on profile thumbnails). The 2020 client treats this as part of the
image CDN contract — it uploads a full-frame capture and expects the image CDN contract — it uploads a full-frame capture and expects the
+22 -9
View File
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using DorkNet.Server.Auth;
using DorkNet.Server.Data; using DorkNet.Server.Data;
using DorkNet.Server.Services; using DorkNet.Server.Services;
using Serilog; using Serilog;
@@ -105,7 +106,8 @@ var domainPort =
var singleOriginBaseUrl = var singleOriginBaseUrl =
builder.Configuration["Domain:SingleOriginBaseUrl"] builder.Configuration["Domain:SingleOriginBaseUrl"]
?? Environment.GetEnvironmentVariable("DORKNET_SINGLE_ORIGIN_BASE_URL"); ?? Environment.GetEnvironmentVariable("DORKNET_SINGLE_ORIGIN_BASE_URL");
builder.Services.AddSingleton(new DomainConfig(apex, domainScheme, domainPort, singleOriginBaseUrl)); var domainConfig = new DomainConfig(apex, domainScheme, domainPort, singleOriginBaseUrl);
builder.Services.AddSingleton(domainConfig);
builder.Services.Configure<Microsoft.AspNetCore.HostFiltering.HostFilteringOptions>(opt => builder.Services.Configure<Microsoft.AspNetCore.HostFiltering.HostFilteringOptions>(opt =>
{ {
opt.AllowedHosts = new[] { apex, $"*.{apex}", "localhost", "127.0.0.1" }; opt.AllowedHosts = new[] { apex, $"*.{apex}", "localhost", "127.0.0.1" };
@@ -214,12 +216,9 @@ static string NormalizeRedisConn(string raw)
// kept for backward compat with older Coolify configs (will warn at // kept for backward compat with older Coolify configs (will warn at
// startup, can be removed once everyone has migrated). AuthService // startup, can be removed once everyone has migrated). AuthService
// has the matching fallback so signing + validation use the same key. // has the matching fallback so signing + validation use the same key.
var jwtSecret = Environment.GetEnvironmentVariable("DORKNET_JWT_SECRET") var signingKeyProvider = new IdentityServerSigningKeyProvider(builder.Configuration);
?? Environment.GetEnvironmentVariable("RECNET_JWT_SECRET") builder.Services.AddSingleton(signingKeyProvider);
?? builder.Configuration["Jwt:Secret"] builder.Services.AddRecRoomIdentityServer(builder.Configuration, domainConfig, signingKeyProvider);
?? throw new InvalidOperationException(
"JWT secret not configured. Set the DORKNET_JWT_SECRET env var or " +
"add `Jwt:Secret` to appsettings.Local.json.");
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(opt => .AddJwtBearer(opt =>
@@ -227,9 +226,9 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
opt.TokenValidationParameters = new TokenValidationParameters opt.TokenValidationParameters = new TokenValidationParameters
{ {
ValidateIssuerSigningKey = true, ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)), IssuerSigningKeys = signingKeyProvider.ValidationKeys,
ValidateIssuer = true, ValidateIssuer = true,
ValidIssuer = "https://api.rec.net/", ValidIssuers = new[] { domainConfig.AuthIssuer, AuthService.LegacyIssuer },
ValidateAudience = false, ValidateAudience = false,
ClockSkew = TimeSpan.Zero, ClockSkew = TimeSpan.Zero,
}; };
@@ -251,6 +250,12 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
var path = ctx.HttpContext.Request.Path; var path = ctx.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hub")) if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hub"))
ctx.Token = accessToken; ctx.Token = accessToken;
if (string.IsNullOrEmpty(ctx.Token) &&
ctx.Request.Cookies.TryGetValue(AuthService.AccessCookieName, out var cookieToken) &&
!string.IsNullOrWhiteSpace(cookieToken))
{
ctx.Token = cookieToken;
}
return Task.CompletedTask; return Task.CompletedTask;
}, },
}; };
@@ -886,6 +891,11 @@ using (var scope = app.Services.CreateScope())
// that's already there (so spent-down accounts aren't refilled). // that's already there (so spent-down accounts aren't refilled).
await playerService.BackfillStarterWalletsAsync(); await playerService.BackfillStarterWalletsAsync();
// 2018 client: complete any account missing birthday/email/password so the
// (screen-mode-broken) signup step flow never appears. See
// PlayerService.ApplyRegistrationDefaults.
await playerService.BackfillAccountCompletionAsync();
// Backfill the per-player dorm room + DormStateEntity for any // Backfill the per-player dorm room + DormStateEntity for any
// legacy account that signed in via a path that didn't run // legacy account that signed in via a path that didn't run
// EnsurePersonalDormAsync (early-bird signups, manual SQL // EnsurePersonalDormAsync (early-bird signups, manual SQL
@@ -1151,6 +1161,9 @@ app.Use(async (ctx, next) =>
// no DB hit beyond the (small) IpBans lookup. // no DB hit beyond the (small) IpBans lookup.
app.UseMiddleware<DorkNet.Server.Auth.IpBanCheckMiddleware>(); app.UseMiddleware<DorkNet.Server.Auth.IpBanCheckMiddleware>();
app.UseMiddleware<IdentityServerGameTokenRequestMiddleware>();
app.UseIdentityServer();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
// Sits between auth and the controllers — by this point ctx.User has the // Sits between auth and the controllers — by this point ctx.User has the
+19 -33
View File
@@ -3,12 +3,16 @@ using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims; using System.Security.Claims;
using System.Text; using System.Text;
using DorkNet.Server.Auth;
using DorkNet.Server.Data; using DorkNet.Server.Data;
namespace DorkNet.Server.Services; namespace DorkNet.Server.Services;
public class AuthService(IConfiguration config, DorkNetDbContext db) public class AuthService(IConfiguration config, DorkNetDbContext db, DomainConfig domain, IdentityServerSigningKeyProvider signingKeyProvider)
{ {
public const string AccessCookieName = "dorknet_access_token";
public const string LegacyIssuer = "https://api.rec.net/";
// Resolution order MUST match Program.cs's JwtBearer validator setup — // Resolution order MUST match Program.cs's JwtBearer validator setup —
// env var first, config key second — otherwise tokens are signed with // env var first, config key second — otherwise tokens are signed with
// one secret and validated against another and every authenticated // one secret and validated against another and every authenticated
@@ -38,38 +42,17 @@ public class AuthService(IConfiguration config, DorkNetDbContext db)
private string CreateJwt(long playerId, TimeSpan lifetime, string tokenType) private string CreateJwt(long playerId, TimeSpan lifetime, string tokenType)
{ {
var handler = new JwtSecurityTokenHandler(); var handler = new JwtSecurityTokenHandler();
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SecretKey)); var claims = RecRoomIdentityClaims.CreateAsync(db, playerId)
.GetAwaiter()
// Look up dev/admin flags so the JWT carries a "developer" role .GetResult();
// claim for accounts that should see the in-game debug button. claims.Add(new Claim("token_type", tokenType));
// The watch's Login.SetAccessToken parses the access_token's
// <c>role</c> claim — either a single string or a list — and
// calls <c>HasRole("developer", token)</c>; that result drives
// <c>Accounts.SetLocalAccountIsDev</c> and ultimately
// <c>SessionManager.IsDeveloper</c>, which the watch UI checks
// when deciding whether to show the dev console toggle.
var isDev = db.Players
.Where(p => p.Id == playerId)
.Select(p => (bool?)(p.IsDeveloper || p.IsAdmin))
.FirstOrDefault() ?? false;
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, playerId.ToString()),
new(ClaimTypes.Role, "gameClient"),
new("token_type", tokenType),
};
// Multiple Role claims become a JSON array on the wire — the
// watch's HasRole helper handles both string and List<string>
// forms.
if (isDev) claims.Add(new Claim(ClaimTypes.Role, "developer"));
var descriptor = new SecurityTokenDescriptor var descriptor = new SecurityTokenDescriptor
{ {
Subject = new ClaimsIdentity(claims), Subject = new ClaimsIdentity(claims),
Expires = DateTime.UtcNow.Add(lifetime), Expires = DateTime.UtcNow.Add(lifetime),
Issuer = "https://api.rec.net/", Issuer = domain.AuthIssuer,
SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256), SigningCredentials = new SigningCredentials(signingKeyProvider.SigningKey, SecurityAlgorithms.RsaSha256),
}; };
return handler.WriteToken(handler.CreateJwtSecurityToken(descriptor)); return handler.WriteToken(handler.CreateJwtSecurityToken(descriptor));
@@ -80,19 +63,22 @@ public class AuthService(IConfiguration config, DorkNetDbContext db)
try try
{ {
var handler = new JwtSecurityTokenHandler(); var handler = new JwtSecurityTokenHandler();
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SecretKey));
var principal = handler.ValidateToken(token, new TokenValidationParameters var principal = handler.ValidateToken(token, new TokenValidationParameters
{ {
ValidateIssuerSigningKey = true, ValidateIssuerSigningKey = true,
IssuerSigningKey = key, IssuerSigningKeys = signingKeyProvider.ValidationKeys,
ValidateIssuer = true, ValidateIssuer = true,
ValidIssuer = "https://api.rec.net/", ValidIssuers = new[] { domain.AuthIssuer, LegacyIssuer },
ValidateAudience = false, ValidateAudience = false,
ClockSkew = TimeSpan.Zero, ClockSkew = TimeSpan.Zero,
}, out _); }, out _);
var idClaim = principal.FindFirst(ClaimTypes.NameIdentifier)?.Value; var idClaim = principal.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value
?? principal.FindFirst("sub")?.Value
?? principal.FindFirst("accountId")?.Value
?? principal.FindFirst("account_id")?.Value
?? principal.FindFirst("accountid")?.Value;
return idClaim is not null ? long.Parse(idClaim) : null; return idClaim is not null ? long.Parse(idClaim) : null;
} }
catch catch
+12 -3
View File
@@ -102,7 +102,7 @@ public class ConfigService(IConfiguration config, DomainConfig domain)
foreach (var (service, sub) in ServiceSubdomains) foreach (var (service, sub) in ServiceSubdomains)
{ {
var host = string.IsNullOrEmpty(sub) ? apex : $"{sub}.{apex}"; var host = string.IsNullOrEmpty(sub) ? apex : $"{sub}.{apex}";
map[service] = $"https://{host}"; map[service] = $"https://{host}/";
} }
return map; return map;
} }
@@ -115,12 +115,21 @@ public class ConfigService(IConfiguration config, DomainConfig domain)
if (domain.SingleOriginEnabled) if (domain.SingleOriginEnabled)
{ {
var prefix = string.IsNullOrEmpty(sub) ? "www" : sub; var prefix = string.IsNullOrEmpty(sub) ? "www" : sub;
map[service] = $"{domain.SingleOriginBaseUrl}/__dn/{prefix}"; map[service] = $"{domain.SingleOriginBaseUrl}/__dn/{prefix}/";
continue; continue;
} }
var host = string.IsNullOrEmpty(sub) ? domain.Apex : domain.Sub(sub); var host = string.IsNullOrEmpty(sub) ? domain.Apex : domain.Sub(sub);
map[service] = domain.Url(host); map[service] = EnsureTrailingSlash(domain.Url(host));
} }
return map; return map;
} }
// The 2018.06 client builds every API URL by RAW string concat —
// new Uri(string.Concat(serviceBase, "api/config/v2")) — so each service
// base MUST be an absolute origin ending in '/', else the host glues to
// the path ("https://api.apexapi/config/v2") and every call 404s. The
// 2020 client runs EnsureEndsWith('/') on these values itself, so the
// trailing slash is idempotent there — safe for both eras.
private static string EnsureTrailingSlash(string url)
=> url.EndsWith('/') ? url : url + "/";
} }
+1
View File
@@ -27,6 +27,7 @@ public sealed class DomainConfig
} }
public string Sub(string prefix) => $"{prefix}.{Apex}"; public string Sub(string prefix) => $"{prefix}.{Apex}";
public string AuthIssuer => SubUrl("auth");
public string Url(string host) => Port is null ? $"{Scheme}://{host}" : $"{Scheme}://{host}:{Port}"; public string Url(string host) => Port is null ? $"{Scheme}://{host}" : $"{Scheme}://{host}:{Port}";
public string SubUrl(string prefix) => Url(Sub(prefix)); public string SubUrl(string prefix) => Url(Sub(prefix));
public static bool MatchesSubdomain(string host, string prefix) public static bool MatchesSubdomain(string host, string prefix)
+48
View File
@@ -517,6 +517,7 @@ public class PlayerService(DorkNetDbContext db, RoomService rooms, ILogger<Playe
HairColor = StarterHairColor, HairColor = StarterHairColor,
SkinColor = StarterSkinColor, SkinColor = StarterSkinColor,
}; };
ApplyRegistrationDefaults(player);
db.Players.Add(player); db.Players.Add(player);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@@ -596,6 +597,7 @@ public class PlayerService(DorkNetDbContext db, RoomService rooms, ILogger<Playe
HairColor = StarterHairColor, HairColor = StarterHairColor,
SkinColor = StarterSkinColor, SkinColor = StarterSkinColor,
}; };
ApplyRegistrationDefaults(player);
db.Players.Add(player); db.Players.Add(player);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@@ -629,9 +631,55 @@ public class PlayerService(DorkNetDbContext db, RoomService rooms, ILogger<Playe
return true; return true;
} }
// 2018 client runs a signup step-flow (video→birthday→email→complete) for
// accounts missing these — and its screen-mode UI is unusable, so accounts
// are born COMPLETE: an adult birthday, a synthetic email, and a default
// password ("dorknet"). RegistrationStatus is reported as complete(10) in
// the Player2018 wire shape.
private static readonly DateTime DefaultBirthday = new(1990, 1, 1, 0, 0, 0, DateTimeKind.Utc);
private static readonly string DefaultPasswordHash = BCrypt.Net.BCrypt.HashPassword("dorknet", 11);
private static void ApplyRegistrationDefaults(PlayerEntity p)
{
p.Birthday ??= DefaultBirthday;
if (string.IsNullOrEmpty(p.Email))
p.Email = $"{(string.IsNullOrEmpty(p.Username) ? "player" : p.Username).ToLowerInvariant()}@dorknet.local";
if (string.IsNullOrEmpty(p.PasswordHash)) p.PasswordHash = DefaultPasswordHash;
// 2018 SCREEN MODE is a developer-gated feature: SessionManager.BIAOMKDOLDL
// (which keeps screen mode enabled post-login) = account.Developer
// (RecNet EEBPLECPEGD.Deserialize "Developer" key). Without it the client
// demands a headset (vr_device_required) after login. Grant it so the
// screen-mode (non-VR) client is playable on this local-test branch.
p.IsDeveloper = true;
}
/// <summary>Complete any account missing birthday/email/password so the 2018
/// client never enters its (broken-in-screen-mode) signup step flow. Runs at
/// startup like the other backfills.</summary>
public async Task BackfillAccountCompletionAsync()
{
var incomplete = await db.Players
.Where(p => p.Birthday == null || p.Email == null || p.PasswordHash == null || !p.IsDeveloper)
.ToListAsync();
if (incomplete.Count == 0) return;
foreach (var p in incomplete) ApplyRegistrationDefaults(p);
await db.SaveChangesAsync();
logger.LogInformation(
"[backfill] completed registration (birthday/email/password) for {N} account(s)", incomplete.Count);
}
public async Task<PlayerEntity?> GetByIdAsync(long id) => public async Task<PlayerEntity?> GetByIdAsync(long id) =>
await db.Players.Include(p => p.Avatar).FirstOrDefaultAsync(p => p.Id == id); await db.Players.Include(p => p.Avatar).FirstOrDefaultAsync(p => p.Id == id);
/// <summary>Bulk profile fetch for the 2018 client's batch resolvers
/// (api/players/v1/list, presence). Single query; order is not guaranteed.</summary>
public async Task<List<PlayerEntity>> GetByIdsAsync(IEnumerable<long> ids)
{
var idList = ids.Distinct().ToList();
if (idList.Count == 0) return [];
return await db.Players.Where(p => idList.Contains(p.Id)).ToListAsync();
}
/// <summary> /// <summary>
/// Stamp the calling platform onto an existing account so the /// Stamp the calling platform onto an existing account so the
/// next boot's <c>/cachedlogin/forplatformid</c> query finds it. /// next boot's <c>/cachedlogin/forplatformid</c> query finds it.
+6 -30
View File
@@ -839,37 +839,13 @@ public class RoomService(DorkNetDbContext db)
} }
/// <summary> /// <summary>
/// Project a RoomEntity to the wire shape Room.Deserialize at RVA /// Project a RoomEntity to the flat June-2018 wire room (RecNet
/// 0x114E430 expects. Required keys (PascalCase): /// Room.Deserialize, RecNet.cs:78244). This branch is 2018-only, so every
/// RoomId, Name, Description, CreatorPlayerId, ImageName, State, /// room-returning endpoint emits this shape via the shared mapper — the
/// Accessibility, SupportsLevelVoting, IsAGRoom, CloningAllowed, /// 2020 RoomDetails/Scenes shape crashes the 2018 deserializer. See
/// SupportsScreens, SupportsWalkVR, SupportsTeleportVR, /// <see cref="Compat2018.Room2018Mapper"/>.
/// AllowsJuniors, RoomWarningMask, CustomRoomWarning
/// Optional: IsDormRoom, SupportsVRLow, SupportsMobile, DisableMicAutoMute.
/// </summary> /// </summary>
public static object ToWireRoom(RoomEntity r) => new public static object ToWireRoom(RoomEntity r) => Compat2018.Room2018Mapper.From(r);
{
RoomId = r.Id,
Name = r.Name,
Description = r.Description,
CreatorPlayerId = r.CreatorPlayerId,
ImageName = r.ImageName,
State = r.State,
Accessibility = r.Accessibility,
SupportsLevelVoting = r.SupportsLevelVoting,
IsAGRoom = r.IsAGRoom,
IsDormRoom = r.IsDormRoom,
CloningAllowed = r.CloningAllowed,
SupportsVRLow = r.SupportsVRLow,
SupportsMobile = r.SupportsMobile,
SupportsScreens = r.SupportsScreens,
SupportsWalkVR = r.SupportsWalkVR,
SupportsTeleportVR = r.SupportsTeleportVR,
AllowsJuniors = r.AllowsJuniors,
RoomWarningMask = r.RoomWarningMask,
CustomRoomWarning = r.CustomRoomWarning,
DisableMicAutoMute = r.DisableMicAutoMute,
};
/// <summary> /// <summary>
/// Idempotent admin overrides applied AFTER SeedAsync. Keeps the seed /// Idempotent admin overrides applied AFTER SeedAsync. Keeps the seed
@@ -0,0 +1,209 @@
import { useEffect, useState } from 'react';
import { get } from '../lib/api';
import { RefreshCw } from '../components/Icons';
interface IdentityServerSettings {
issuer: string;
discoveryUrl: string;
tokenEndpoint: string;
userInfoEndpoint: string;
client: {
clientId: string;
clientName: string;
allowedGrantTypes: string[];
allowedScopes: string[];
allowOfflineAccess: boolean;
accessTokenLifetime: number;
absoluteRefreshTokenLifetime: number;
slidingRefreshTokenLifetime: number;
refreshTokenUsage: string;
refreshTokenExpiration: string;
};
signing: {
certificateSource: string;
loadedFromBase64: boolean;
certificatePath: string;
certificateExists: boolean;
thumbprint: string;
notBeforeUtc: string;
notAfterUtc: string;
algorithm: string;
};
persistedGrants: {
store: string;
warning: string;
};
}
export function IdentityServer() {
const [settings, setSettings] = useState<IdentityServerSettings | null>(null);
const [err, setErr] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const load = async () => {
setBusy(true);
try {
const data = await get<IdentityServerSettings>('/identityserver');
setSettings(data);
setErr(null);
} catch (e) {
setErr((e as Error).message);
} finally {
setBusy(false);
}
};
useEffect(() => { void load(); }, []);
return (
<div>
<div className="mb-3 flex justify-end">
<button onClick={load} className="btn-secondary text-xs" disabled={busy}>
<RefreshCw className={busy ? 'animate-spin' : ''} />
Refresh
</button>
</div>
{err && (
<div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger mb-4">{err}</div>
)}
{!settings && !err && (
<div className="card !p-6 text-center text-xs text-ink-400">Loading...</div>
)}
{settings && (
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="space-y-5">
<div className="card !p-5">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 className="text-sm font-semibold text-ink-50">IdentityServer</h2>
<p className="mt-1 break-all font-mono text-xs text-ink-300">{settings.issuer}</p>
</div>
<div className="flex flex-wrap gap-2 text-xs">
<StatusBadge ok={settings.signing.certificateExists} on="Signing key" off="Missing key" />
</div>
</div>
<div className="mt-5 grid gap-3 md:grid-cols-3">
<Endpoint label="Discovery" value={settings.discoveryUrl} />
<Endpoint label="Token" value={settings.tokenEndpoint} />
<Endpoint label="UserInfo" value={settings.userInfoEndpoint} />
</div>
</div>
<div className="card !p-5">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-sm font-semibold text-ink-50">Client</h2>
<p className="mt-1 font-mono text-xs text-ink-400">{settings.client.clientId}</p>
</div>
<span className={settings.client.allowOfflineAccess ? 'badge-online' : 'badge-neutral'}>
Offline access {settings.client.allowOfflineAccess ? 'on' : 'off'}
</span>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<ChipList title="Grant types" values={settings.client.allowedGrantTypes} />
<ChipList title="Scopes" values={settings.client.allowedScopes} />
</div>
<div className="mt-5 grid gap-3 sm:grid-cols-3">
<Metric label="Access token" value={duration(settings.client.accessTokenLifetime)} />
<Metric label="Refresh token" value={duration(settings.client.absoluteRefreshTokenLifetime)} />
<Metric label="Refresh mode" value={`${settings.client.refreshTokenUsage} / ${settings.client.refreshTokenExpiration}`} />
</div>
</div>
</div>
<div className="space-y-5">
<div className="card !p-5">
<h2 className="text-sm font-semibold text-ink-50">Signing certificate</h2>
<div className="mt-4 space-y-3">
<Detail label="Source" value={settings.signing.certificateSource} mono />
{!settings.signing.loadedFromBase64 && (
<Detail label="Path" value={settings.signing.certificatePath} mono />
)}
<Detail label="Thumbprint" value={settings.signing.thumbprint} mono />
<Detail label="Algorithm" value={settings.signing.algorithm} mono />
<Detail label="Valid from" value={formatDate(settings.signing.notBeforeUtc)} />
<Detail label="Valid until" value={formatDate(settings.signing.notAfterUtc)} />
</div>
</div>
<div className="card !p-5">
<h2 className="text-sm font-semibold text-ink-50">Operational store</h2>
<div className="mt-3 flex items-center justify-between gap-3">
<span className="text-xs text-ink-400">Persisted grants</span>
<span className="badge-junior">{settings.persistedGrants.store}</span>
</div>
<p className="mt-4 text-xs leading-5 text-ink-400">{settings.persistedGrants.warning}</p>
</div>
</div>
</div>
)}
</div>
);
}
function Endpoint({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border border-ink-800 bg-ink-950/40 p-3">
<div className="label">{label}</div>
<div className="mt-2 break-all font-mono text-xs text-ink-100">{value}</div>
</div>
);
}
function ChipList({ title, values }: { title: string; values: string[] }) {
return (
<div className="rounded-lg border border-ink-800 bg-ink-950/30 p-4">
<h3 className="text-xs font-semibold uppercase text-ink-400">{title}</h3>
<div className="mt-3 flex flex-wrap gap-2">
{values.map((value) => (
<span key={value} className="badge-neutral font-mono normal-case tracking-normal">{value}</span>
))}
</div>
</div>
);
}
function Metric({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border border-ink-800 bg-ink-950/30 p-3">
<div className="text-[11px] uppercase text-ink-500">{label}</div>
<div className="mt-1 text-sm font-semibold text-ink-50">{value}</div>
</div>
);
}
function Detail({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div>
<div className="label">{label}</div>
<div className={(mono ? 'font-mono ' : '') + 'mt-1 break-all text-xs text-ink-100'}>{value}</div>
</div>
);
}
function StatusBadge({ ok, on, off }: { ok: boolean; on: string; off: string }) {
return <span className={ok ? 'badge-online' : 'badge-junior'}>{ok ? on : off}</span>;
}
function formatDate(value: string): string {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
function duration(seconds: number): string {
if (!Number.isFinite(seconds) || seconds <= 0) return '0s';
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h ${minutes}m`;
if (minutes > 0) return `${minutes}m`;
return `${seconds}s`;
}
@@ -1,4 +1,5 @@
import { Settings } from './Settings'; import { Settings } from './Settings';
import { IdentityServer } from './IdentityServer';
import { SignupCodes } from './SignupCodes'; import { SignupCodes } from './SignupCodes';
import { PageHeader } from '../components/PageHeader'; import { PageHeader } from '../components/PageHeader';
import { Tabs, useTabParam } from '../components/Tabs'; import { Tabs, useTabParam } from '../components/Tabs';
@@ -7,19 +8,21 @@ import { Tabs, useTabParam } from '../components/Tabs';
// invite codes (an access-control setting in its own right). Old // invite codes (an access-control setting in its own right). Old
// /signup-codes route redirects here with ?tab=signup. // /signup-codes route redirects here with ?tab=signup.
export function SettingsHome() { export function SettingsHome() {
const [tab, setTab] = useTabParam<'server' | 'signup'>('server', ['server', 'signup']); const [tab, setTab] = useTabParam<'server' | 'identity' | 'signup'>('server', ['server', 'identity', 'signup']);
return ( return (
<div> <div>
<PageHeader title="Settings" blurb="Runtime server toggles and single-use signup invite codes." /> <PageHeader title="Settings" blurb="Runtime server toggles, authentication, and single-use signup invite codes." />
<Tabs <Tabs
tabs={[ tabs={[
{ key: 'server', label: 'Server' }, { key: 'server', label: 'Server' },
{ key: 'identity', label: 'IdentityServer' },
{ key: 'signup', label: 'Signup codes' }, { key: 'signup', label: 'Signup codes' },
]} ]}
active={tab} active={tab}
onChange={setTab} onChange={setTab}
/> />
{tab === 'server' && <Settings embedded />} {tab === 'server' && <Settings embedded />}
{tab === 'identity' && <IdentityServer />}
{tab === 'signup' && <SignupCodes embedded />} {tab === 'signup' && <SignupCodes embedded />}
</div> </div>
); );
+22
View File
@@ -0,0 +1,22 @@
{
"Jwt": {
"Secret": "dorknet-local-dev-secret-please-change-0123456789abcdef"
},
"Domain": {
"Apex": "localhost",
"Scheme": "http",
"SingleOriginBaseUrl": "http://127.0.0.1:5005"
},
"Database": {
"Provider": "sqlite"
},
"Photon": {
"AppId": "",
"VoiceAppId": "",
"CloudRegion": "us",
"RequireCustomAuth": false
},
"Server": {
"MOTD": "DorkNet 2018 local test server"
}
}
+5 -1
View File
@@ -72,13 +72,17 @@ cd DorkNet
| `ConnectionStrings:Redis` | `ConnectionStrings__Redis` | no | enables SignalR backplane | | `ConnectionStrings:Redis` | `ConnectionStrings__Redis` | no | enables SignalR backplane |
| `Domain:Apex` | `DORKNET_DOMAIN` | no | defaults to `localhost`; set for production | | `Domain:Apex` | `DORKNET_DOMAIN` | no | defaults to `localhost`; set for production |
`/connect/token` issues access and refresh tokens with the configured
auth host as the issuer. Set `DORKNET_DOMAIN` for production so OpenID
discovery and JWT validation agree on `https://auth.<domain>`.
**4. Boot:** **4. Boot:**
```pwsh ```pwsh
dotnet run --project DorkNet.Server dotnet run --project DorkNet.Server
``` ```
First boot creates `bin/Debug/net9.0/data/dorknet.db`, applies all EF First boot creates `bin/Debug/net10.0/data/dorknet.db`, applies all EF
migrations under `DorkNet.Server/Migrations/`, seeds canonical rooms + migrations under `DorkNet.Server/Migrations/`, seeds canonical rooms +
store catalog, and starts listening. You should see `Application store catalog, and starts listening. You should see `Application
started.` in the log; `curl http://localhost:8080/healthz` returns 200. started.` in the log; `curl http://localhost:8080/healthz` returns 200.