mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
557 lines
20 KiB
C#
557 lines
20 KiB
C#
using System.IdentityModel.Tokens.Jwt;
|
|
using System.Security.Claims;
|
|
using System.Security.Cryptography;
|
|
using System.Security.Cryptography.X509Certificates;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using Duende.IdentityServer;
|
|
using Duende.IdentityServer.Models;
|
|
using Duende.IdentityServer.Services;
|
|
using Duende.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);
|
|
Certificate = LoadOrCreateCertificate(CertificatePath, config, _legacySecret);
|
|
SigningKey = new X509SecurityKey(Certificate);
|
|
LegacyJwtKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_legacySecret));
|
|
}
|
|
|
|
public string CertificatePath { get; }
|
|
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 LoadOrCreateCertificate(string path, IConfiguration config, string fallbackPassword)
|
|
{
|
|
var password = config["IdentityServer:SigningCertificatePassword"] ?? fallbackPassword;
|
|
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('/');
|
|
options.KeyManagement.Enabled = false;
|
|
var licenseKey =
|
|
Environment.GetEnvironmentVariable("DUENDE_IDENTITYSERVER_LICENSE_KEY")
|
|
?? config["IdentityServer:LicenseKey"];
|
|
if (!string.IsNullOrWhiteSpace(licenseKey))
|
|
options.LicenseKey = licenseKey;
|
|
})
|
|
.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.client";
|
|
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 Client",
|
|
RequireClientSecret = false,
|
|
AllowedGrantTypes = { GrantType.ResourceOwnerPassword, CachedLoginGrantType },
|
|
AllowedScopes =
|
|
{
|
|
IdentityServerConstants.StandardScopes.OpenId,
|
|
IdentityServerConstants.StandardScopes.Profile,
|
|
IdentityServerConstants.StandardScopes.OfflineAccess,
|
|
GameClientScope,
|
|
"api",
|
|
},
|
|
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,
|
|
},
|
|
};
|
|
}
|
|
|
|
public sealed class IdentityServerLegacyTokenRequestMiddleware(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 (!values.ContainsKey("client_id"))
|
|
{
|
|
values["client_id"] = RecRoomIdentityServerConfig.ClientId;
|
|
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 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 IdentityServerTokenResponseMiddleware(RequestDelegate next, DomainConfig domain)
|
|
{
|
|
public async Task InvokeAsync(HttpContext context)
|
|
{
|
|
if (!IsTokenRequest(context))
|
|
{
|
|
await next(context);
|
|
return;
|
|
}
|
|
|
|
var originalBody = context.Response.Body;
|
|
await using var buffer = new MemoryStream();
|
|
context.Response.Body = buffer;
|
|
|
|
await next(context);
|
|
|
|
context.Response.Body = originalBody;
|
|
buffer.Position = 0;
|
|
|
|
if (context.Response.StatusCode != StatusCodes.Status200OK ||
|
|
!IsJson(context.Response.ContentType))
|
|
{
|
|
await buffer.CopyToAsync(originalBody);
|
|
return;
|
|
}
|
|
|
|
using var document = await JsonDocument.ParseAsync(buffer);
|
|
var response = new Dictionary<string, object?>(StringComparer.Ordinal);
|
|
foreach (var property in document.RootElement.EnumerateObject())
|
|
response[property.Name] = ConvertJsonValue(property.Value);
|
|
|
|
if (response.TryGetValue("access_token", out var accessValue) &&
|
|
accessValue is string accessToken &&
|
|
!string.IsNullOrWhiteSpace(accessToken))
|
|
{
|
|
AddLegacyTokenAliases(response, accessToken);
|
|
SetAccessCookie(context, accessToken);
|
|
}
|
|
|
|
var bytes = JsonSerializer.SerializeToUtf8Bytes(response);
|
|
context.Response.ContentLength = bytes.Length;
|
|
context.Response.ContentType = "application/json; charset=utf-8";
|
|
await originalBody.WriteAsync(bytes);
|
|
}
|
|
|
|
private static bool IsTokenRequest(HttpContext context) =>
|
|
HttpMethods.IsPost(context.Request.Method)
|
|
&& context.Request.Path.Equals("/connect/token", StringComparison.OrdinalIgnoreCase);
|
|
|
|
private static bool IsJson(string? contentType) =>
|
|
contentType?.StartsWith("application/json", StringComparison.OrdinalIgnoreCase) == true;
|
|
|
|
private static object? ConvertJsonValue(JsonElement value) =>
|
|
value.ValueKind switch
|
|
{
|
|
JsonValueKind.String => value.GetString(),
|
|
JsonValueKind.Number when value.TryGetInt64(out var l) => l,
|
|
JsonValueKind.Number => value.GetDouble(),
|
|
JsonValueKind.True => true,
|
|
JsonValueKind.False => false,
|
|
JsonValueKind.Null => null,
|
|
_ => JsonSerializer.Deserialize<object>(value.GetRawText()),
|
|
};
|
|
|
|
private static void AddLegacyTokenAliases(Dictionary<string, object?> response, string accessToken)
|
|
{
|
|
response["Access_token"] = accessToken;
|
|
response["accessToken"] = accessToken;
|
|
response["AccessToken"] = accessToken;
|
|
response["token"] = accessToken;
|
|
response["Token"] = accessToken;
|
|
response["tokenType"] = "Bearer";
|
|
response["TokenType"] = "Bearer";
|
|
|
|
if (response.TryGetValue("expires_in", out var expiresIn))
|
|
{
|
|
response["expiresIn"] = expiresIn;
|
|
response["ExpiresIn"] = expiresIn;
|
|
}
|
|
|
|
if (response.TryGetValue("refresh_token", out var refreshToken))
|
|
{
|
|
response["Refresh_token"] = refreshToken;
|
|
response["refreshToken"] = refreshToken;
|
|
response["RefreshToken"] = refreshToken;
|
|
}
|
|
|
|
var signingKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
|
response["key"] = signingKey;
|
|
response["Key"] = signingKey;
|
|
}
|
|
|
|
private void SetAccessCookie(HttpContext context, string accessToken)
|
|
{
|
|
var cookie = new CookieOptions
|
|
{
|
|
HttpOnly = true,
|
|
Secure = string.Equals(domain.Scheme, "https", StringComparison.OrdinalIgnoreCase),
|
|
SameSite = SameSiteMode.None,
|
|
Expires = DateTimeOffset.UtcNow.AddHours(12),
|
|
IsEssential = true,
|
|
};
|
|
if (domain.Apex.Contains('.'))
|
|
cookie.Domain = "." + domain.Apex;
|
|
context.Response.Cookies.Append(AuthService.AccessCookieName, accessToken, cookie);
|
|
}
|
|
}
|
|
|
|
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;
|
|
|
|
if (!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;
|
|
}
|
|
}
|