Compare commits

...

14 Commits

Author SHA1 Message Date
Alexa 399f49fa8a Migrate auth to Open.IdentityServer 2026-06-30 15:47:27 +03:00
Alexa c192d9c051 Handle signing certificate decode failures 2026-06-30 14:45:10 +03:00
Alexa c80b5c56eb Harden signing certificate base64 parsing 2026-06-30 14:34:48 +03:00
Alexa efad7e10d0 Use native game auth client only 2026-06-30 14:29:55 +03:00
Alexa 88740b1102 Support native game auth client 2026-06-30 14:16:34 +03:00
Alexa af1ec31fab Restore device password grant compatibility 2026-06-30 14:05:59 +03:00
Alexa 5f2fd2875f Support env-based signing certificate 2026-06-30 13:54:57 +03:00
Alexa 2e7b39c2cd Migrate server auth to IdentityServer 2026-06-30 13:27:55 +03:00
Alexa 3e4d618aa7 feat: Matchmade sessions respect room's subroom Max Player Count
GameSessionService previously hardcoded MaxCapacity=8 for new sessions,
fragmenting matchmade joiners 8-per-instance regardless of the host's
'Max Player Count in This Subroom' slider. New sessions now resolve the
room's entry subroom (lowest OrderIndex) RoomSceneEntity.MaxPlayers,
clamped 1-80, falling back to 8 for system rooms (dorm GUID roomIds).
2026-06-28 01:35:57 +03:00
Alexa 72c076535c Merge branch 'december-2020-12-18' of https://github.com/DorkSquadRR/DorkNet into december-2020-12-18 2026-06-28 01:30:40 +03:00
Alexa 97f6522497 Add DorkNet.Contracts reference to monolith Server build (#33)
* Route microservices through Traefik behind Cloudflare Tunnel

Add scoped Traefik host labels to the gateway and put cloudflared on the
Traefik network so the tunnel hands off to Traefik instead of forwarding
every host straight to the app. Keep backend services internal-only.

* Add DorkNet.Contracts reference to monolith Server build

DorkNet.Server uses DorkNetRouteOwnership from DorkNet.Contracts but the
project never referenced it and the Dockerfile never copied it, so the
monolith image failed to compile (CS0234). Add the project reference and
copy the project into the build stage.
2026-06-25 12:43:52 +03:00
Alexa e3bbcbe4a4 Route microservices through Traefik behind Cloudflare Tunnel (#32)
Add scoped Traefik host labels to the gateway and put cloudflared on the
Traefik network so the tunnel hands off to Traefik instead of forwarding
every host straight to the app. Keep backend services internal-only.
2026-06-25 12:05:45 +03:00
Alexa 02365f5baf Merge branch 'december-2020-12-18' of https://github.com/DorkSquadRR/DorkNet into december-2020-12-18 2026-06-20 00:18:31 +03:00
Alexa 3755593dbb Avoid login kick broadcast during reconnect 2026-06-15 00:20:05 +03:00
38 changed files with 1056 additions and 356 deletions
+6 -4
View File
@@ -1,14 +1,14 @@
# DorkNet.Server container image — Coolify deploy target.
# ────────────────────────────────────────────────────────────────────
# 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
# to ProjectReference + dotnet restore). The .dockerignore at the
# repo root keeps the bin/, obj/, and other dev junk out of the
# context.
ARG DOTNET_VERSION=9.0
ARG DOTNET_VERSION=10.0
ARG NODE_VERSION=20
# ── admin SPA build stage ──────────────────────────────────────────
@@ -53,12 +53,14 @@ WORKDIR /src
# DorkNet.Models project ref, so its csproj needs to be present too.
COPY DorkNet.Server/DorkNet.Server.csproj DorkNet.Server/
COPY DorkNet.Models/DorkNet.Models.csproj DorkNet.Models/
COPY DorkNet.Contracts/DorkNet.Contracts.csproj DorkNet.Contracts/
RUN dotnet restore DorkNet.Server/DorkNet.Server.csproj
# Now copy the rest of the .NET source, then drop the prebuilt admin
# SPA into wwwroot/admin so the publish step ships it as static content.
COPY DorkNet.Server/ DorkNet.Server/
COPY DorkNet.Models/ DorkNet.Models/
COPY DorkNet.Contracts/ DorkNet.Contracts/
COPY --from=admin-ui /wwwroot/admin/ DorkNet.Server/wwwroot/admin/
COPY --from=site /wwwroot/site/ DorkNet.Server/wwwroot/site/
@@ -76,13 +78,13 @@ WORKDIR /app
# curl + wget for healthcheck probes — Coolify v4 defaults to wget for
# 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.
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl wget \
&& 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.
USER app
+1 -1
View File
@@ -1,4 +1,4 @@
ARG DOTNET_VERSION=9.0
ARG DOTNET_VERSION=10.0
ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine AS admin-ui
+1 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
+1 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
+2 -2
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
@@ -9,7 +9,7 @@
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
@@ -1,11 +1,8 @@
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.HostFiltering;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
namespace DorkNet.Server.Tests;
@@ -56,18 +53,6 @@ public sealed class DorkNetServerFactory : WebApplicationFactory<Program>
options.AllowEmptyHosts = true;
options.IncludeFailureMessage = true;
});
services.PostConfigure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(JwtSecret)),
ValidateIssuer = true,
ValidIssuer = "https://api.rec.net/",
ValidateAudience = false,
ClockSkew = TimeSpan.Zero,
};
});
});
}
@@ -26,6 +26,8 @@ public static class GameClientSessionFactory
tokenRequest.Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "password",
["client_id"] = "recroom",
["client_secret"] = "VxZ53kgbbEaRoZAeMe00MagtgD12GLL2",
["username"] = "EndpointContract",
["device_id"] = deviceId,
["platform"] = "0",
@@ -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;
}
}
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
@@ -39,6 +40,7 @@ public class AdminController(
SignupCodeService signupCodes,
IObjectStorage storage,
DomainConfig domain,
IdentityServerSigningKeyProvider signingKeys,
ILogger<AdminController> adminLogger) : ControllerBase
{
private long CurrentAdminId => this.RequireCurrentPlayerId();
@@ -1460,6 +1462,52 @@ public class AdminController(
// ── 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
/// toggle state. Single row, single round trip.</summary>
[HttpGet("settings")]
+47 -240
View File
@@ -1,5 +1,4 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Auth;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
@@ -7,241 +6,18 @@ using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Auth;
/// <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>
[ApiController]
public class AuthController(
PlayerService playerService,
AuthService authService,
LevelService level,
OrphanAccountTracker orphans,
ServerSettingsService settings,
DorkNet.Server.Data.DorkNetDbContext db,
DomainConfig domain,
ILogger<AuthController> logger) : ControllerBase
AuthService authService) : ControllerBase
{
/// <summary>Award the per-day first-login XP if the calling
/// player hasn't been seen yet today (UTC).</summary>
private async Task TryAwardLoginXpAsync(long playerId)
{
var lastSeen = await db.Players
.Where(p => p.Id == playerId)
.Select(p => p.LastSeenAt)
.FirstOrDefaultAsync();
if (lastSeen.Date < DateTime.UtcNow.Date)
await level.AwardXpAsync(playerId, LevelService.FirstLoginXp, "first_login_today");
}
// OAuth token endpoint. The 2020 client uses three grant types here:
// - grant_type=cached_login → user clicked a remembered account on the
// account-selection screen. Body includes `account_id` (and platform
// creds). MUST issue a token for THAT specific account; otherwise the
// client receives a JWT for a different account and prompts the user
// to create a new one (because the JWT identity doesn't match what
// they picked).
// - grant_type=refresh_token → re-issue access token from the existing
// refresh JWT.
// - grant_type=password → first-time sign-in, resolve via deviceId
// (preferred) or fall back to a stable synthetic id derived from
// username so legacy callers don't share accounts.
[HttpPost("/connect/token")]
[Consumes("application/x-www-form-urlencoded")]
public async Task<IActionResult> Token(
[FromForm] string? grant_type,
[FromForm] string? username,
[FromForm] string? password,
[FromForm] string? refresh_token,
[FromForm] string? deviceId,
[FromForm(Name = "device_id")] string? device_id_snake,
[FromForm] int platform = 0,
[FromForm] string? platformId = null,
[FromForm(Name = "platform_id")] string? platform_id_snake = null,
[FromForm] long? account_id = null)
{
// The client mixes camelCase and snake_case parameter naming
// depending on grant type — accept both and merge.
var effDeviceId = !string.IsNullOrWhiteSpace(deviceId) ? deviceId : device_id_snake;
var effPlatformId = !string.IsNullOrWhiteSpace(platformId) ? platformId : platform_id_snake;
if (string.Equals(grant_type, "cached_login", StringComparison.OrdinalIgnoreCase))
{
if (account_id is not long pickedId)
return BadRequest(new { error = "invalid_request", error_description = "missing account_id" });
var picked = await playerService.GetByIdAsync(pickedId);
if (picked is null)
return Unauthorized(new { error = "invalid_grant", error_description = "unknown account_id" });
// Orphan cleanup: if the watch just created a brand-new
// account on account/create as part of its boot probe, but
// the user actually picked a different cached account here,
// the just-created row is unused. Delete it so the DB
// doesn't accumulate one orphan per game launch. When the
// user is genuinely creating a new account, the picked id
// matches the just-created id and we leave it.
var pendingId = orphans.PeekPending(effDeviceId, effPlatformId);
if (pendingId is long orphanId && orphanId != pickedId)
{
await playerService.DeleteOrphanAsync(orphanId);
logger.LogInformation(
"[orphan-cleanup] cached_login picked {PickedId}; deleted just-created {OrphanId}",
pickedId, orphanId);
}
orphans.Clear(effDeviceId, effPlatformId);
await TryAwardLoginXpAsync(picked.Id);
await playerService.TagPlatformAsync(picked.Id, platform, effPlatformId);
var (acc1, refr1) = authService.GenerateTokenPair(picked.Id);
return Ok(new
{
access_token = acc1,
token_type = "Bearer",
expires_in = 43200,
refresh_token = refr1,
scope = "openid profile",
});
}
if (string.Equals(grant_type, "refresh_token", StringComparison.OrdinalIgnoreCase))
{
if (string.IsNullOrWhiteSpace(refresh_token))
return BadRequest(new { error = "invalid_request" });
var resolved = authService.ValidateToken(refresh_token);
if (resolved is not long refreshedId)
return Unauthorized(new { error = "invalid_grant" });
var existing = await playerService.GetByIdAsync(refreshedId);
if (existing is null)
return Unauthorized(new { error = "invalid_grant" });
var (acc, refr) = authService.GenerateTokenPair(existing.Id);
return Ok(new
{
access_token = acc,
token_type = "Bearer",
expires_in = 43200,
refresh_token = refr,
scope = "openid profile",
});
}
// password grant: two paths.
// • Username + password supplied: real password verify against
// PlayerEntity.PasswordHash. Wrong password → 401.
// • No password: legacy device-id synth path for the 2020
// client's first-launch flow where the player hasn't set a
// password yet (HasPassword returns false in the watch UI
// and prompts them to create one). Resolves the existing
// account (or creates a fresh one) by deviceId.
if (!string.IsNullOrEmpty(password) && !string.IsNullOrEmpty(username))
{
var byName = await playerService.GetByUsernameAsync(username);
if (byName is null || byName.PasswordHash is null)
return Unauthorized(new
{
error = "invalid_grant",
error_description = "unknown_user_or_no_password",
});
if (!BCrypt.Net.BCrypt.Verify(password, byName.PasswordHash))
return Unauthorized(new
{
error = "invalid_grant",
error_description = "password_mismatch",
});
// Tag the platform so the next boot's cached-login lookup
// finds this account — without this the watch's "remembered
// accounts" prompt would stay empty after a manual sign-in.
await playerService.TagPlatformAsync(byName.Id, platform, effPlatformId);
await TryAwardLoginXpAsync(byName.Id);
var pair = authService.GenerateTokenPair(byName.Id);
return Ok(new
{
access_token = pair.AccessToken,
token_type = "Bearer",
expires_in = 43200,
refresh_token = pair.RefreshToken,
scope = "openid profile",
});
}
// Device-id fallback path. Prefer the real deviceId; fall back
// to a stable legacy synthesis from username so older callers
// don't accidentally share an account.
var effectiveDeviceId = !string.IsNullOrWhiteSpace(effDeviceId)
? effDeviceId
: $"legacy-{(username ?? "anonymous").ToLowerInvariant()}";
// Honour the admin "signups disabled" toggle. If the device
// hasn't logged in before, GetOrCreateByDeviceAsync would mint
// a fresh account here — same outcome as hitting
// /account/create directly, which the toggle blocks. Refuse
// BEFORE creation so the flag isn't bypassable.
if (await settings.AreSignupsDisabledAsync())
{
var existing = await playerService.GetByDeviceAsync(effectiveDeviceId);
if (existing is null)
{
logger.LogInformation(
"[auth] /connect/token device fallback refused — signups disabled (device={Device})",
effectiveDeviceId);
return Unauthorized(new
{
error = "signups_disabled",
error_description = "Account creation is currently disabled by the server admin.",
});
}
}
var player = await playerService.GetOrCreateByDeviceAsync(
deviceId: effectiveDeviceId,
platform: platform,
platformId: effPlatformId,
displayName: username);
await TryAwardLoginXpAsync(player.Id);
var (access, refresh) = authService.GenerateTokenPair(player.Id);
return Ok(new
{
access_token = access,
token_type = "Bearer",
expires_in = 43200,
refresh_token = refresh,
scope = "openid profile",
});
}
[HttpGet("/connect/userinfo")]
public async Task<IActionResult> UserInfo()
{
var auth = Request.Headers.Authorization.ToString();
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return Unauthorized();
var token = auth["Bearer ".Length..].Trim();
var id = authService.ValidateToken(token);
if (id is not long playerId) return Unauthorized();
var player = await playerService.GetByIdAsync(playerId);
if (player is null) return Unauthorized();
return Ok(new { sub = player.Id.ToString(), name = player.Username });
}
// Discovery document — issuer + endpoints derived from
// DomainConfig so a single DORKNET_DOMAIN env var keeps the
// OpenID document pointed at the live deployment.
[HttpGet("/.well-known/openid-configuration")]
public IActionResult OpenIdConfig()
{
var base_ = $"https://{domain.Sub("auth")}";
return Ok(new
{
issuer = base_,
token_endpoint = $"{base_}/connect/token",
userinfo_endpoint = $"{base_}/connect/userinfo",
});
}
// 2020.12 added an EAC challenge step in the login flow. The client GETs
// a challenge string and passes it to EACManager.GenerateChallengeResponse,
// which calls System.Convert.FromBase64CharPtr on the string. So the
// returned challenge MUST be valid base64 — sending arbitrary text like
// "dorknet-eac-disabled" throws FormatException client-side and kicks
// the login. 24 chars of base64 'A's decodes to 18 zero bytes; matches
// the rough size of a real EAC handshake nonce.
// returned challenge MUST be valid base64 — sending arbitrary text throws
// FormatException client-side and kicks the login.
[HttpGet("/eac/challenge")]
[HttpPost("/eac/challenge")]
public IActionResult EacChallenge()
@@ -250,11 +26,6 @@ public class AuthController(
// Cached-logins lookup — boot-sequence call from ScreenAccountSelectionManager.
// URL: GET auth.rec.net/cachedlogin/forplatformid/{platform}/{platformId}
// Returns List<Login.CachedLogin>. Empty array = no remembered accounts.
//
// Real lookup: any account whose last platformlogin matched
// (platform, platformId) shows up here, sorted most-recent-first. On a
// single-Steam-emu setup that's one row; on a multi-account dev box
// it's the full list the user has cycled through.
[HttpGet("/cachedlogin/forplatformid/{platform:int}/{platformId}")]
public async Task<IActionResult> CachedLoginsForPlatform(int platform, string platformId)
{
@@ -264,10 +35,8 @@ public class AuthController(
/// <summary>POST <c>cachedlogin/forplatformids</c> — bulk variant
/// the watch uses when hydrating multiple platform-id mappings
/// at once (e.g. populating a friend list from Steam ids). Body
/// is form-urlencoded <c>platform=&amp;platformIds=a,b,c</c> or
/// JSON with the same shape. Returns the flattened
/// <c>List&lt;CachedLogin&gt;</c> across every matched id.</summary>
/// at once. Body is form-urlencoded <c>platform=&amp;platformIds=a,b,c</c>
/// or JSON with the same shape.</summary>
[HttpPost("/cachedlogin/forplatformids")]
[HttpGet("/cachedlogin/forplatformids")]
public async Task<IActionResult> CachedLoginsForPlatformIds()
@@ -285,6 +54,7 @@ public class AuthController(
}
}
catch { /* not form */ }
if (string.IsNullOrWhiteSpace(rawIds))
{
int.TryParse(Request.Query["platform"], out platform);
@@ -307,13 +77,50 @@ public class AuthController(
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,
PlatformId = platformId,
PlatformId = platformId ?? string.Empty,
AccountId = (int)p.Id,
LastLoginTime = p.LastSeenAt,
RequirePassword = false,
};
}
@@ -20,7 +20,6 @@ namespace DorkNet.Server.Controllers.Match;
[ApiController]
public class MatchPlayerController(
PlayerPresenceService presence,
NotificationService notifications,
RoomService rooms,
PrivateInstanceService privateInstances,
IConfiguration config,
@@ -37,7 +36,7 @@ public class MatchPlayerController(
/// </summary>
[HttpPost("/player/login")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> PlayerLogin([FromForm(Name = "LoginLock")] string? loginLock)
public IActionResult PlayerLogin([FromForm(Name = "LoginLock")] string? loginLock)
{
var playerId = TryGetCurrentPlayerId();
if (playerId != 0 && !string.IsNullOrWhiteSpace(loginLock))
@@ -46,17 +45,15 @@ public class MatchPlayerController(
if (replaced)
{
log.LogWarning(
"[player-login] concurrent session for player={PlayerId}; kicking old session(s) via ModerationKick push",
"[player-login] concurrent session marker replaced for player={PlayerId}; stale SignalR connections will be evicted on hub connect",
playerId);
// The new session hasn't opened its SignalR socket yet
// (negotiate fires AFTER /player/login on the watch's
// boot sequence — verified in the live trace), so the
// ModerationKick push hits only the OLDER session's
// existing connections. They'll show the "you've been
// kicked" dialog and gracefully disconnect via the
// watch's LocalModerationKickSelf path.
await notifications.KickStaleSession(playerId,
"Your account was signed in on another device.");
// Do not broadcast ModerationKick to the whole player group
// here. During reconnects the active watch can already have a
// SignalR connection in that group, and the 2020 client crashes
// hard if it receives a malformed/stale moderation payload.
// NotifyHub.OnConnectedAsync snapshots existing connection ids
// before adding the new connection and sends a targeted kick to
// those ids only.
}
}
return Ok(Array.Empty<object>());
+12 -9
View File
@@ -1,20 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.15" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.4">
<PackageReference Include="Open.IdentityServer" Version="1.0.0" />
<PackageReference Include="MessagePack" Version="3.1.7" />
<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>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<!-- RenderedCompactJsonFormatter for stdout JSON logs in production
(Coolify-friendly). The host pipeline reads stdout line-by-line,
and structured JSON lets a future Loki/Datadog ingest parse fields
@@ -25,8 +27,8 @@
scale: SignalR group fanout, OrphanAccountTracker (sticky-session
account-creation flow), and PlayerPresenceService heartbeats all
need shared state across replicas. -->
<PackageReference Include="StackExchange.Redis" Version="2.8.16" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="9.0.4" />
<PackageReference Include="StackExchange.Redis" Version="2.13.17" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="10.0.9" />
<!-- S3-compatible object storage. Production target: Garage in
Coolify (one-click). Dev fallback: local MinIO for parity
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. -->
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<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
on profile thumbnails). The 2020 client treats this as part of the
image CDN contract — it uploads a full-frame capture and expects the
@@ -73,6 +75,7 @@
<ItemGroup>
<ProjectReference Include="..\DorkNet.Models\DorkNet.Models.csproj" />
<ProjectReference Include="..\DorkNet.Contracts\DorkNet.Contracts.csproj" />
</ItemGroup>
<!-- Service-slice containers reference DorkNet.Server for shared controllers
+19 -33
View File
@@ -3,12 +3,16 @@ using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
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 —
// env var first, config key second — otherwise tokens are signed with
// 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)
{
var handler = new JwtSecurityTokenHandler();
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SecretKey));
// Look up dev/admin flags so the JWT carries a "developer" role
// claim for accounts that should see the in-game debug button.
// 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 claims = RecRoomIdentityClaims.CreateAsync(db, playerId)
.GetAwaiter()
.GetResult();
claims.Add(new Claim("token_type", tokenType));
var descriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(claims),
Expires = DateTime.UtcNow.Add(lifetime),
Issuer = "https://api.rec.net/",
SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256),
Issuer = domain.AuthIssuer,
SigningCredentials = new SigningCredentials(signingKeyProvider.SigningKey, SecurityAlgorithms.RsaSha256),
};
return handler.WriteToken(handler.CreateJwtSecurityToken(descriptor));
@@ -80,19 +63,22 @@ public class AuthService(IConfiguration config, DorkNetDbContext db)
try
{
var handler = new JwtSecurityTokenHandler();
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SecretKey));
var principal = handler.ValidateToken(token, new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = key,
IssuerSigningKeys = signingKeyProvider.ValidationKeys,
ValidateIssuer = true,
ValidIssuer = "https://api.rec.net/",
ValidIssuers = new[] { domain.AuthIssuer, LegacyIssuer },
ValidateAudience = false,
ClockSkew = TimeSpan.Zero,
}, 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;
}
catch
+3
View File
@@ -20,6 +20,9 @@ public sealed class DomainConfig
}
public string Sub(string prefix) => $"{prefix}.{Apex}";
public string AuthIssuer => Url("auth");
public string Url(string prefix, string path = "")
{
var host = string.IsNullOrEmpty(prefix) ? Apex : Sub(prefix);
+28 -1
View File
@@ -64,7 +64,12 @@ public class GameSessionService(DorkNetDbContext db)
// in, so we save once with a placeholder, then patch and save
// again. Two SaveChangesAsync calls is cheap at our QPS.
PhotonRoomName = "pending",
MaxCapacity = 8,
// Track the room's "Max Player Count in This Subroom" slider
// instead of a hardcoded 8, so matchmade joiners fill the room to
// the host's chosen limit rather than fragmenting into a new
// instance every 8 players. (Existing sessions keep whatever cap
// they were created with.)
MaxCapacity = await ResolveRoomMaxCapacityAsync(effectiveRoom),
PlayerCount = 1,
CreatedAt = DateTime.UtcNow,
};
@@ -75,6 +80,28 @@ public class GameSessionService(DorkNetDbContext db)
return ToDto(session);
}
// Default session cap when we can't resolve a room-specific value (system
// rooms like the dorm use a GUID roomId that doesn't map to a RoomEntity).
private const int DefaultMaxCapacity = 8;
/// <summary>Resolve a new session's capacity from the room's entry
/// subroom (lowest <c>OrderIndex</c>) <see cref="RoomSceneEntity.MaxPlayers"/>
/// — the value the in-game "Max Player Count in This Subroom" slider
/// writes. Matchmade sessions land in the entry subroom, so its cap is the
/// right ceiling. GameSession roomIds are the room's numeric Id as a
/// string for user rooms; non-numeric (dorm GUID, etc.) → default. Clamped
/// to a sane Photon ceiling.</summary>
private async Task<int> ResolveRoomMaxCapacityAsync(string roomId)
{
if (!long.TryParse(roomId, out var roomLongId)) return DefaultMaxCapacity;
var max = await db.RoomScenes
.Where(s => s.RoomId == roomLongId)
.OrderBy(s => s.OrderIndex)
.Select(s => (int?)s.MaxPlayers)
.FirstOrDefaultAsync();
return max is int v && v > 0 ? Math.Clamp(v, 1, 80) : DefaultMaxCapacity;
}
public async Task<GameSession?> GetByIdAsync(long id)
{
var s = await db.GameSessions.AsNoTracking()
@@ -87,6 +87,9 @@ public static class MiddlewarePipelineExtensions
// calls (login, photon custom auth) are gated too.
app.UseMiddleware<VersionDetectionMiddleware>();
app.UseMiddleware<IdentityServerGameTokenRequestMiddleware>();
app.UseIdentityServer();
app.UseAuthentication();
app.UseAuthorization();
// Sits between auth and the controllers — by this point ctx.User has
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using DorkNet.Contracts;
using DorkNet.Server.Auth;
using DorkNet.Server.Compat;
using DorkNet.Server.Data;
using DorkNet.Server.Services;
@@ -24,11 +25,11 @@ public static class ServiceCollectionExtensions
{
ConfigureLogging(builder);
AddDatabase(builder);
AddDomain(builder);
var domain = AddDomain(builder);
AddCoreServices(builder.Services);
AddVersionCompatibility(builder);
var redisConn = AddRedis(builder);
AddAuthentication(builder);
AddAuthentication(builder, domain);
AddKestrelAndSignalR(builder, redisConn);
}
@@ -101,14 +102,15 @@ public static class ServiceCollectionExtensions
// Replaces per-controller [Host(...)] filters that hard-coded
// rec.net/localhost pairs — the singleton DomainConfig is injected
// anywhere code needs to build outbound URLs.
private static void AddDomain(WebApplicationBuilder builder)
private static DomainConfig AddDomain(WebApplicationBuilder builder)
{
var apex =
builder.Configuration["Domain:Apex"]
?? Environment.GetEnvironmentVariable("DORKNET_DOMAIN")
?? "localhost";
var scheme = builder.Configuration["Domain:Scheme"] ?? "https";
builder.Services.AddSingleton(new DomainConfig(apex, scheme));
var domain = new DomainConfig(apex, scheme);
builder.Services.AddSingleton(domain);
var allowedHosts = new[] { apex, "localhost", "127.0.0.1" }
.Concat(DorkNetRouteOwnership.PublicSubdomains.Select(subdomain => $"{subdomain}.{apex}"))
.Distinct(StringComparer.OrdinalIgnoreCase)
@@ -120,6 +122,7 @@ public static class ServiceCollectionExtensions
opt.IncludeFailureMessage = true;
});
Console.WriteLine($"[domain] apex={apex}, allowedHosts=[{string.Join(", ", allowedHosts)}]");
return domain;
}
// ── Services ──────────────────────────────────────────────────────────────
@@ -252,15 +255,11 @@ public static class ServiceCollectionExtensions
// Never commit a real secret to appsettings.json. DORKNET_JWT_SECRET is the
// canonical env var name; RECNET_JWT_SECRET kept for backward compat with
// older Coolify configs.
private static void AddAuthentication(WebApplicationBuilder builder)
private static void AddAuthentication(WebApplicationBuilder builder, DomainConfig domain)
{
var jwtSecret = Environment.GetEnvironmentVariable("DORKNET_JWT_SECRET")
?? Environment.GetEnvironmentVariable("RECNET_JWT_SECRET")
?? builder.Configuration["Jwt:Secret"]
?? throw new InvalidOperationException(
"JWT secret not configured. Set the DORKNET_JWT_SECRET env var or " +
"add `Jwt:Secret` to appsettings.Local.json (the install-plugin.ps1 " +
"script generates one automatically on same-machine setup).");
var signingKeyProvider = new IdentityServerSigningKeyProvider(builder.Configuration);
builder.Services.AddSingleton(signingKeyProvider);
builder.Services.AddRecRoomIdentityServer(builder.Configuration, domain, signingKeyProvider);
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(opt =>
@@ -268,9 +267,9 @@ public static class ServiceCollectionExtensions
opt.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)),
IssuerSigningKeys = signingKeyProvider.ValidationKeys,
ValidateIssuer = true,
ValidIssuer = "https://api.rec.net/",
ValidIssuers = new[] { domain.AuthIssuer, AuthService.LegacyIssuer },
ValidateAudience = false,
ClockSkew = TimeSpan.Zero,
};
@@ -290,6 +289,12 @@ public static class ServiceCollectionExtensions
var path = ctx.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hub"))
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;
},
};
@@ -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 { IdentityServer } from './IdentityServer';
import { SignupCodes } from './SignupCodes';
import { PageHeader } from '../components/PageHeader';
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
// /signup-codes route redirects here with ?tab=signup.
export function SettingsHome() {
const [tab, setTab] = useTabParam<'server' | 'signup'>('server', ['server', 'signup']);
const [tab, setTab] = useTabParam<'server' | 'identity' | 'signup'>('server', ['server', 'identity', 'signup']);
return (
<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={[
{ key: 'server', label: 'Server' },
{ key: 'identity', label: 'IdentityServer' },
{ key: 'signup', label: 'Signup codes' },
]}
active={tab}
onChange={setTab}
/>
{tab === 'server' && <Settings embedded />}
{tab === 'identity' && <IdentityServer />}
{tab === 'signup' && <SignupCodes embedded />}
</div>
);
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
+5 -1
View File
@@ -72,13 +72,17 @@ cd DorkNet
| `S3:SecretKey` | `S3__SecretKey` | production | S3 secret key |
| `S3:Region` | `S3__Region` | no | defaults to `garage`; use `auto` for R2 |
`/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 a local standalone server:**
```pwsh
dotnet run --project DorkNet.Server
```
First boot creates `bin/Debug/net9.0/data/dorknet.db`, runs every
First boot creates `bin/Debug/net10.0/data/dorknet.db`, runs every
`LegacyUpgrades` pass, seeds canonical rooms / store catalog / club
categories / playlists, and starts listening. You should see
`Application started.` in the log; `curl http://localhost:8080/healthz`
+18 -1
View File
@@ -17,8 +17,9 @@ x-dorknet-service-env: &dorknet-service-env
x-dorknet-app-networks: &dorknet-app-networks
networks:
# Internal only — backends are reached by the gateway over `default`,
# never directly by Traefik, so keep them off dokploy-network.
- default
- dokploy-network
services:
gateway:
@@ -42,6 +43,18 @@ services:
DorkNet__Services__Monolith: http://monolith:8080
expose:
- "8080"
networks:
- default
- dokploy-network
labels:
# Cloudflare terminates TLS at the edge; the tunnel -> Traefik leg is
# internal HTTP, so route on the `web` entrypoint with no certresolver.
- traefik.enable=true
- traefik.docker.network=dokploy-network
# Scope routing to YOUR domain (apex + subdomains) instead of catch-all:
- traefik.http.routers.dorknet-gw.rule=Host(`${DORKNET_DOMAIN}`) || HostRegexp(`^.+\.${DORKNET_DOMAIN}$`)
- traefik.http.routers.dorknet-gw.entrypoints=web
- traefik.http.services.dorknet-gw.loadbalancer.server.port=8080
depends_on:
- identity
- rooms
@@ -57,6 +70,10 @@ services:
cloudflared:
image: cloudflare/cloudflared:latest
command: tunnel --no-autoupdate run --token ${CLOUDFLARE_TUNNEL_TOKEN:?Set CLOUDFLARE_TUNNEL_TOKEN in Dokploy}
# Must share dokploy-network so the tunnel can reach the Traefik
# container (dokploy-traefik) by name and hand traffic off to it.
networks:
- dokploy-network
depends_on:
- gateway
restart: unless-stopped
+5 -1
View File
@@ -38,7 +38,7 @@ DorkNet.sln
| `DorkNet.Contracts` | Service names, route ownership, service-map options, health responses, probe responses | .NET 9 |
| `DorkNet.ServiceDefaults` | Common service health checks, JSON defaults, HTTP clients, service identity, route guard | .NET 9 |
| `DorkNet.Gateway` | Edge reverse proxy; routes host/path slices to services and exposes service health probes | .NET 9 |
| `DorkNet.Services.Identity` | Accounts, auth, platform login, JWT issuance route slice | .NET 9 |
| `DorkNet.Services.Identity` | Accounts, auth, platform login, OpenID discovery, JWT issuance route slice | .NET 9 |
| `DorkNet.Services.Rooms` | Rooms, room keys, matchmaking, discovery route slice | .NET 9 |
| `DorkNet.Services.Notify` | SignalR edge, messages/chat, notification fan-out route slice | .NET 9 |
| `DorkNet.Services.Content` | CDN paths, image/photo APIs, uploads, S3-backed blobs | .NET 9 |
@@ -113,6 +113,10 @@ Compose network and forwards Cloudflare Tunnel traffic to
configured with `DORKNET_S3_*` environment variables; the compose files
do not run MinIO or Garage.
The identity slice is the token issuer in the service split. Discovery at
`/.well-known/openid-configuration` advertises the configured auth host,
and access/refresh tokens minted by `/connect/token` use that same issuer.
`Dockerfile.service` is the reusable service image. It accepts
`PROJECT_PATH` and `APP_DLL` build args, rebuilds the admin/site Vite
SPAs for service images that need static assets, publishes the selected
+5
View File
@@ -42,6 +42,11 @@ shared `DorkNet.Server` controller/service stack behind route ownership
guards, so response contracts stay identical while the architecture is
split.
Auth tokens are issued by the identity service using the configured
auth host (`https://auth.<apex>` or the equivalent configured host style)
as the JWT/OpenID issuer. `DORKNET_JWT_SECRET` still supplies the signing
key for every service that validates bearer tokens.
---
## Dokploy Microservices
@@ -2,7 +2,7 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
+2 -2
View File
@@ -51,8 +51,8 @@ static Assembly LoadServerAssembly(string[] args)
var repositoryRoot = FindRepositoryRoot();
var candidates = new[]
{
Path.Combine(repositoryRoot, "DorkNet.Server", "bin", "Debug", "net9.0", "DorkNet.Server.dll"),
Path.Combine(repositoryRoot, "DorkNet.Server", "bin", "Release", "net9.0", "DorkNet.Server.dll")
Path.Combine(repositoryRoot, "DorkNet.Server", "bin", "Debug", "net10.0", "DorkNet.Server.dll"),
Path.Combine(repositoryRoot, "DorkNet.Server", "bin", "Release", "net10.0", "DorkNet.Server.dll")
};
var existingCandidates = candidates.Where(File.Exists).ToArray();
@@ -2,7 +2,7 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
+2 -2
View File
@@ -333,8 +333,8 @@ static Assembly LoadServerAssembly(string? configuredPath)
var repositoryRoot = FindRepositoryRoot();
var candidates = new[]
{
Path.Combine(repositoryRoot, "DorkNet.Server", "bin", "Debug", "net9.0", "DorkNet.Server.dll"),
Path.Combine(repositoryRoot, "DorkNet.Server", "bin", "Release", "net9.0", "DorkNet.Server.dll")
Path.Combine(repositoryRoot, "DorkNet.Server", "bin", "Debug", "net10.0", "DorkNet.Server.dll"),
Path.Combine(repositoryRoot, "DorkNet.Server", "bin", "Release", "net10.0", "DorkNet.Server.dll")
};
var existingCandidates = candidates.Where(File.Exists).ToArray();
if (existingCandidates.Length == 0)