Radicade Public Code

This commit is contained in:
JogmansonReal
2026-10-16 23:33:13 -07:00
commit 55175acdf6
832 changed files with 363261 additions and 0 deletions
+108
View File
@@ -0,0 +1,108 @@
using System.Collections.Concurrent;
namespace RadicadeServer.Auth;
public static class AdminLoginThrottle
{
public const int MaxFailedAttempts = 5;
public static readonly TimeSpan LockoutDuration = TimeSpan.FromMinutes(15);
private sealed record Entry(int Failures, DateTime FirstFailureAt, DateTime LockedUntil);
private static readonly ConcurrentDictionary<string, Entry> Attempts = new();
public static bool IsLocked(string username, string ip, out TimeSpan remaining)
{
remaining = TimeSpan.Zero;
var byUser = LockedRemaining("u:" + username);
var byIp = LockedRemaining("ip:" + ip);
if (byUser == null && byIp == null) return false;
remaining = Max(byUser, byIp)!.Value;
return true;
}
public static void RecordFailure(string username, string ip)
{
var now = DateTime.UtcNow;
foreach (var key in Keys(username, ip))
{
Attempts.AddOrUpdate(key,
_ => new Entry(1, now, DateTime.MinValue),
(_, existing) =>
{
if (now - existing.FirstFailureAt > LockoutDuration)
return new Entry(1, now, DateTime.MinValue);
var failures = existing.Failures + 1;
var lockedUntil = failures >= MaxFailedAttempts ? now + LockoutDuration : DateTime.MinValue;
return new Entry(failures, existing.FirstFailureAt, lockedUntil);
});
}
Prune(now);
}
public static void Clear(string username, string ip)
{
foreach (var key in Keys(username, ip))
Attempts.TryRemove(key, out _);
}
private static TimeSpan? LockedRemaining(string key)
{
if (string.IsNullOrWhiteSpace(key)) return null;
if (!Attempts.TryGetValue(key, out var entry)) return null;
if (entry.Failures < MaxFailedAttempts) return null;
var now = DateTime.UtcNow;
if (now >= entry.LockedUntil)
{
Attempts.TryRemove(key, out _);
return null;
}
return entry.LockedUntil - now;
}
private static IEnumerable<string> Keys(string username, string ip)
{
if (!string.IsNullOrWhiteSpace(username)) yield return "u:" + username;
if (!string.IsNullOrWhiteSpace(ip)) yield return "ip:" + ip;
}
private static TimeSpan? Max(TimeSpan? a, TimeSpan? b)
{
if (a == null) return b;
if (b == null) return a;
return a > b ? a : b;
}
private static void Prune(DateTime now)
{
foreach (var kv in Attempts)
{
var e = kv.Value;
var lockActive = e.Failures >= MaxFailedAttempts && now < e.LockedUntil;
if (!lockActive && now - e.FirstFailureAt > LockoutDuration)
Attempts.TryRemove(kv.Key, out _);
}
}
}
+78
View File
@@ -0,0 +1,78 @@
using System.Security.Cryptography;
using System.Text;
namespace RadicadeServer.Auth;
public static class AdminSecurity
{
private const int Pbkdf2Iterations = 600_000;
private const int SaltSize = 16;
private const int HashSize = 32;
public static string HashPassword(string password)
{
var salt = RandomNumberGenerator.GetBytes(SaltSize);
var hash = Rfc2898DeriveBytes.Pbkdf2(
Encoding.UTF8.GetBytes(password), salt, Pbkdf2Iterations, HashAlgorithmName.SHA256, HashSize);
return $"pbkdf2${Pbkdf2Iterations}${Convert.ToBase64String(salt)}${Convert.ToBase64String(hash)}";
}
public static bool VerifyPassword(string storedHash, string password)
{
if (string.IsNullOrWhiteSpace(storedHash)) return false;
var parts = storedHash.Split('$');
if (parts.Length != 4 || parts[0] != "pbkdf2") return false;
if (!int.TryParse(parts[1], out var iterations)) return false;
try
{
var salt = Convert.FromBase64String(parts[2]);
var expected = Convert.FromBase64String(parts[3]);
var actual = Rfc2898DeriveBytes.Pbkdf2(
Encoding.UTF8.GetBytes(password), salt, iterations, HashAlgorithmName.SHA256, expected.Length);
return CryptographicOperations.FixedTimeEquals(actual, expected);
}
catch
{
return false;
}
}
public static bool NeedsRehash(string storedHash)
{
if (string.IsNullOrWhiteSpace(storedHash)) return false;
var parts = storedHash.Split('$');
if (parts.Length != 4 || parts[0] != "pbkdf2") return true;
return !int.TryParse(parts[1], out var iterations) || iterations < Pbkdf2Iterations;
}
public static string? ValidatePasswordPolicy(string? password, string? username = null)
{
if (string.IsNullOrWhiteSpace(password) || password.Length < 12)
return "Password must be at least 12 characters long.";
if (!password.Any(char.IsUpper))
return "Password must contain at least one uppercase letter.";
if (!password.Any(char.IsLower))
return "Password must contain at least one lowercase letter.";
if (!password.Any(char.IsDigit))
return "Password must contain at least one number.";
if (!password.Any(ch => !char.IsLetterOrDigit(ch)))
return "Password must contain at least one symbol (e.g. ! @ # $ %).";
if (!string.IsNullOrWhiteSpace(username) && username.Length >= 3 &&
password.Contains(username, StringComparison.OrdinalIgnoreCase))
return "Password must not contain your username.";
return null;
}
}
+162
View File
@@ -0,0 +1,162 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.EntityFrameworkCore;
using RadicadeServer.Admin;
using RadicadeServer.DB;
namespace RadicadeServer.Auth;
public static class AdminSession
{
public const string CookieName = "radicade_admin_session";
public const string StateCookieName = "radicade_oauth_state";
public const string RedirectCookieName = "radicade_oauth_redirect";
public static readonly TimeSpan Lifetime = TimeSpan.FromHours(12);
public static readonly TimeSpan AbsoluteLifetime = TimeSpan.FromDays(7);
private static readonly TimeSpan RotationGrace = TimeSpan.FromMinutes(2);
public static async Task<string> CreateAsync(RecDBContext db, long adminUserId)
{
var token = NewToken();
db.AdminSessions.Add(new AdminSessionRecord
{
AdminUserId = adminUserId,
TokenHash = Hash(token),
CreatedAt = DateTime.UtcNow,
LastSeenAt = DateTime.UtcNow,
ExpiresAt = DateTime.UtcNow.Add(Lifetime)
});
await db.SaveChangesAsync();
return token;
}
public static async Task<(bool Ok, long UserId)> ValidateAsync(RecDBContext db, string? token)
{
if (string.IsNullOrWhiteSpace(token)) return (false, 0);
var now = DateTime.UtcNow;
var hash = Hash(token);
var record = await db.AdminSessions.FirstOrDefaultAsync(s => s.TokenHash == hash);
if (record == null)
{
record = await db.AdminSessions.FirstOrDefaultAsync(s =>
s.PreviousTokenHash == hash && s.LastSeenAt > now - RotationGrace);
if (record == null) return (false, 0);
}
if (record.RevokedAt.HasValue || record.ExpiresAt <= now)
return (false, 0);
var ceiling = record.CreatedAt.Add(AbsoluteLifetime);
if (now >= ceiling) return (false, 0);
record.LastSeenAt = now;
record.ExpiresAt = now.Add(Lifetime) < ceiling ? now.Add(Lifetime) : ceiling;
await db.SaveChangesAsync();
return (true, record.AdminUserId);
}
private static readonly object RotateLock = new();
public static (string Token, bool Rotated) TryRotate(RecDBContext db, string? currentToken)
{
if (string.IsNullOrWhiteSpace(currentToken)) return ("", false);
lock (RotateLock)
{
var record = db.AdminSessions.FirstOrDefault(s => s.TokenHash == Hash(currentToken));
if (record == null)
{
record = db.AdminSessions.FirstOrDefault(s => s.PreviousTokenHash == Hash(currentToken));
if (record != null && record.LastSeenAt > DateTime.UtcNow - RotationGrace)
return (currentToken, false);
return (currentToken, false);
}
var now = DateTime.UtcNow;
var ceiling = record.CreatedAt.Add(AbsoluteLifetime);
if (now >= ceiling) return (currentToken, false);
var next = NewToken();
record.PreviousTokenHash = record.TokenHash;
record.TokenHash = Hash(next);
record.LastSeenAt = now;
record.ExpiresAt = now.Add(Lifetime) < ceiling ? now.Add(Lifetime) : ceiling;
db.SaveChanges();
return (next, true);
}
}
public static async Task RevokeAsync(RecDBContext db, string? token)
{
if (string.IsNullOrWhiteSpace(token)) return;
var record = await db.AdminSessions.FirstOrDefaultAsync(s => s.TokenHash == Hash(token));
if (record == null) return;
record.RevokedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
public static async Task RevokeAllAsync(RecDBContext db, long adminUserId)
{
var sessions = await db.AdminSessions
.Where(s => s.AdminUserId == adminUserId && !s.RevokedAt.HasValue)
.ToListAsync();
foreach (var session in sessions)
session.RevokedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
public static async Task PruneAsync(RecDBContext db)
{
var cutoff = DateTime.UtcNow.AddDays(-2);
var stale = await db.AdminSessions
.Where(s => s.RevokedAt.HasValue || s.ExpiresAt < cutoff)
.ToListAsync();
if (stale.Count == 0) return;
db.AdminSessions.RemoveRange(stale);
await db.SaveChangesAsync();
}
private static string NewToken() => Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
private static string Hash(string token) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
}
+1116
View File
@@ -0,0 +1,1116 @@
using Azure;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet.Accounts;
using RadicadeServer.RecNet.Accounts.Local;
using RadicadeServer.RecNet.Auth;
using RadicadeServer.RecNet.Auth.Cached;
using RadicadeServer.RecNet.Auth.Connection;
using RadicadeServer.RecNet.Enums.Players;
using RadicadeServer.RecNet.Moderation;
using RadicadeServer.RecNet.Progression;
using RadicadeServer.RecNet.Responses;
using RadicadeServer.Radicade.Mail;
using RadicadeServer.Radicade.Responses;
using RadicadeServer.Utils;
using static System.Runtime.InteropServices.JavaScript.JSType;
namespace RadicadeServer.Auth
{
[Route("auth")]
public class AuthAPI : Controller
{
public class WebLoginRequest
{
public string? Username { get; set; }
public string? Password { get; set; }
}
public const int MinPasswordLength = 6;
RecDBContext db;
PasswordHasher<PlayerData> hasher;
public AuthAPI(RecDBContext context) {
this.db = context;
this.hasher = new PasswordHasher<PlayerData>();
}
[HttpGet("account/me/haspassword"), Authorize]
public async Task<bool?> GetHasPassword()
{
var userId = User.GetUserId();
var user = await db.Players.FirstAsync(x => x.AccountId == userId);
return !string.IsNullOrEmpty(user.Hashword);
}
[HttpPost("account/me/changepassword"), Authorize]
public async Task<Success> ChangePassword([FromForm] string oldPassword, [FromForm] string newPassword)
{
if (string.IsNullOrWhiteSpace(newPassword)) return new Success(false, "New password is required.", null);
if (newPassword.Length < MinPasswordLength) return new Success(false, $"Password must be at least {MinPasswordLength} characters.", null);
var userId = User.GetUserId();
var user = await db.Players.FirstOrDefaultAsync(x => x.AccountId == userId);
if (user == null) return new Success(false, "User does not exist.", "damn.");
if (!string.IsNullOrWhiteSpace(user.Hashword))
{
if (string.IsNullOrEmpty(oldPassword))
return new Success(false, "Current password is required.", null);
var check = hasher.VerifyHashedPassword(user, user.Hashword, oldPassword);
if (check != PasswordVerificationResult.Success && check != PasswordVerificationResult.SuccessRehashNeeded)
return new Success(false, "Current password is incorrect.", null);
}
user.Hashword = hasher.HashPassword(user, newPassword);
await db.SaveChangesAsync();
AuthSecurityMonitor.NoteCredentialChange(user.AccountId, "password changed");
return new Success(true, null, null);
}
[HttpPost("account/recoverpassword")]
public async Task<Success> RecoverPassword()
{
var email = "";
if (Request.HasFormContentType)
email = Request.Form["email"].FirstOrDefault() ?? "";
if (string.IsNullOrWhiteSpace(email))
{
using var reader = new System.IO.StreamReader(Request.Body);
var raw = await reader.ReadToEndAsync();
if (!string.IsNullOrWhiteSpace(raw))
{
try
{
using var doc = System.Text.Json.JsonDocument.Parse(raw);
if (doc.RootElement.TryGetProperty("email", out var e) && e.ValueKind == System.Text.Json.JsonValueKind.String)
email = e.GetString() ?? "";
}
catch { }
}
}
email = email.Trim();
if (string.IsNullOrWhiteSpace(email) || !email.Contains('@'))
return new Success(false, "Enter a valid email address.", null);
var plrData = await db.Players.FirstOrDefaultAsync(x => x.Email.ToLower() == email.ToLower());
if (plrData != null)
{
plrData.PasswordResetToken = Guid.NewGuid().ToString("N");
plrData.PasswordResetExpires = DateTime.UtcNow.AddHours(1);
await db.SaveChangesAsync();
SendResetEmail(plrData);
AuthSecurityMonitor.NoteCredentialChange(plrData.AccountId, "password reset requested");
}
return new Success(true, null, null);
}
private void SendResetEmail(PlayerData plrData)
{
if (string.IsNullOrWhiteSpace(plrData.PasswordResetToken)) return;
var link = $"https://radicade.app/reset?code={plrData.PasswordResetToken}";
var html =
"<div style=\"font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:24px;\">" +
"<img src=\"https://radicade.app/assests/RadicadeHorizontal.png\" alt=\"Radicade\" style=\"display:block;max-width:220px;height:auto;margin:0 auto 22px;\" />" +
"<h2 style=\"color:#1a1a2e;margin-top:0;\">Reset your Radicade password</h2>" +
"<p style=\"color:#444;font-size:15px;line-height:1.6;\">Hi there,</p>" +
"<p style=\"color:#444;font-size:15px;line-height:1.6;\">We received a request to reset the password on your Radicade account from the game. If that was you, click the button below to choose a new password:</p>" +
"<p style=\"text-align:center;margin:28px 0;\"><a href=\"" + link + "\" style=\"background:#00a0d0;color:#fff;text-decoration:none;padding:12px 28px;border-radius:6px;display:inline-block;\">Reset my password</a></p>" +
"<p style=\"color:#999;font-size:12px;margin-top:24px;\">This link expires in one hour. If you didn't request this, you can safely ignore this email.</p></div>";
_ = RadicadeMail.SendAsync(plrData.Email, "Reset your password - Radicade", html);
}
[HttpPost("web/login")]
public async Task<IActionResult> WebLogin([FromBody] WebLoginRequest req)
{
if (string.IsNullOrWhiteSpace(req?.Username)) return Ok(new { success = false, message = "No username was given." });
if (string.IsNullOrWhiteSpace(req?.Password)) return Ok(new { success = false, message = "No password was given." });
var loginName = req.Username.Trim().TrimStart('@');
var profile = await db.Profiles.FirstOrDefaultAsync(x => x.username == loginName || x.displayName == loginName);
if (profile == null)
return Ok(new { success = false, message = "Invalid username or password." });
var plrData = await db.Players.FirstOrDefaultAsync(x => x.AccountId == profile.accountId);
if (plrData == null)
return Ok(new { success = false, message = "Invalid username or password." });
if (string.IsNullOrWhiteSpace(plrData.Hashword))
return Ok(new { success = false, message = "Please make a password in-game!" });
var loginIp = Request.GetClientIp();
var result = hasher.VerifyHashedPassword(plrData, plrData.Hashword, req.Password ?? "");
if (result != PasswordVerificationResult.Success)
{
AuthSecurityMonitor.RecordFailedLogin(profile.accountId, loginIp);
return Ok(new { success = false, message = "Invalid username or password." });
}
if (await AccountLock.IsLockedAsync(db, profile.accountId))
{
var lockedUsername = await AccountLock.ResolveUsernameAsync(db, profile.accountId) ?? profile.username;
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Security] refused web sign-in for locked account {profile.accountId}");
return Ok(new { success = false, error = AccountLock.ErrorCode, message = AccountLock.LoginErrorMessage(lockedUsername) });
}
AuthSecurityMonitor.RecordSuccessfulLogin(profile.accountId, loginIp, false, false);
if (!await db.Presences.AnyAsync(x => x.playerId == profile.accountId))
{
db.Presences.Add(new RealPresence
{
playerId = profile.accountId,
deviceClass = DeviceClass.Screen,
statusVisibility = StatusVisibility.Public,
vRMovementMode = VRMovementModes.TELEPORT
});
await db.SaveChangesAsync();
}
var newToken = await GenerateToken(db, profile.accountId, "web", DeviceClass.Screen, Platforms.Steam, "web:" + profile.accountId);
if (!newToken.success || newToken.value == null)
return Ok(new { success = false, message = newToken.error ?? "Failed to sign in." });
return Ok(new
{
success = true,
token = (string)newToken.value,
username = profile.username,
displayName = profile.displayName,
email = plrData.Email,
emailVerified = plrData.EmailVerified
});
}
[HttpPost("connect/token")]
public async Task<ActionResult<AuthResponse?>> ConnectWithToken([FromForm] TokenConnectData data)
{
data.platform_id = data.platform_id?.Trim();
data.device_id = data.device_id?.Trim();
var platformAuth = data.platform_auth?.Trim() ?? string.Empty;
if (data.platform == Platforms.Standalone)
{
if (string.IsNullOrWhiteSpace(data.device_id) ||
data.device_id.Length > 256 || data.client_id != "recroom")
return Unauthorized(new AuthResponse("Failed to sign in.", "Sign in with your Radicade username and password."));
data.platform_id = "radicade-quest:" + data.device_id;
if (data.grant_type == "password" && !string.IsNullOrWhiteSpace(data.username) &&
!string.IsNullOrWhiteSpace(data.password))
data.account_id = 0;
else if (data.grant_type is "cached_login" or "refresh_token")
{
var remembered = await QuestRememberedSession.ValidateAsync(db,
Request.Headers[QuestRememberedSession.Header].ToString(), data.device_id, data.account_id);
if (!remembered.HasValue)
return Unauthorized(new AuthResponse("invalid_grant", "Sign in with your Radicade username and password."));
data.account_id = remembered.Value;
data.grant_type = "refresh_token";
}
else return Unauthorized(new AuthResponse("invalid_grant", "Sign in with your Radicade username and password."));
}
if (data.platform == Platforms.Steam && !string.IsNullOrEmpty(platformAuth) &&
!TryValidateSteamPlatformAuth(platformAuth, out var platformAuthReason))
{
return SteamIdentityRefusal("platform_auth shape", platformAuthReason, data);
}
if (data.platform == Platforms.Steam && (data.grant_type is "cached_login" or "create_account" or "password"))
{
if (string.IsNullOrWhiteSpace(platformAuth))
{
return SteamIdentityRefusal("platform_auth",
"platform_auth was absent or blank", data);
}
if (string.IsNullOrWhiteSpace(DBConfig.SteamApiKey))
{
if (!DBConfig.TrustUnverifiedSteamIdentity)
{
return SteamIdentityRefusal("steam api key",
"no SteamApiKey is configured and TrustUnverifiedSteamIdentity is off", data);
}
}
else
{
if (!TryReadSteamTicket(platformAuth, out var steamTicket))
{
return SteamIdentityRefusal("platform_auth.Ticket",
"platform_auth carried no readable string 'Ticket'", data);
}
string? verifiedSteamId;
try
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(HttpContext.RequestAborted);
timeout.CancelAfter(TimeSpan.FromSeconds(10));
verifiedSteamId = await AuthenticateSteamTicketAsync(steamTicket, GetSteamAppId(platformAuth), timeout.Token);
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException)
{
return SteamIdentityRefusal("steam ticket check",
$"Steam could not be reached ({ex.GetType().Name})", data);
}
if (string.IsNullOrWhiteSpace(verifiedSteamId))
{
return SteamIdentityRefusal("steam ticket check",
"Steam would not confirm the ticket (stale ticket, or one issued for an app it will not check)", data);
}
if (!string.Equals(verifiedSteamId, data.platform_id?.Trim(), StringComparison.Ordinal))
{
return SteamIdentityRefusal("identity match",
$"the ticket is for {verifiedSteamId}, not for the posted identity {data.platform_id?.Trim()}", data);
}
}
}
var isWhitelisted = await AuthShared.IsWhitelisted(data.platform_id ?? string.Empty);
if (!isWhitelisted)
{
return new AuthResponse("Error", "You are not whitelisted.");
}
AuthResponse response = new AuthResponse();
switch (data.grant_type)
{
default:
return Unauthorized();
case "cached_login":
{
if (data.account_id <= 0)
return StatusCode(400, new AuthResponse("invalid_request", "missing account_id"));
if (!await db.Players.AnyAsync(player => player.AccountId == data.account_id) ||
!await db.Profiles.AnyAsync(profile => profile.accountId == data.account_id) ||
!await db.Presences.AnyAsync(presence => presence.playerId == data.account_id))
return StatusCode(400, new AuthResponse("invalid_grant", "unknown or incomplete account_id"));
if (!await db.CachedLogins.AnyAsync(login => login.accountId == data.account_id &&
login.platform == data.platform && login.platformId == data.platform_id))
return StatusCode(400, new AuthResponse("invalid_grant", "no linked account for this platform identity"));
response = await TokenRefresh(data);
return Ok(response);
}
case "create_account":
{
var carriesSignUpPayload = HasSignUpPayload(data);
using var identity = await RegistrationGuard.LockIdentityAsync(
RegistrationGuard.IdentityKey(data.platform.ToString(), data.platform_id, data.device_id));
var existingLink = await db.CachedLogins
.AsNoTracking()
.Where(login => login.platform == data.platform && login.platformId == data.platform_id)
.Where(login => data.account_id <= 0 || login.accountId == data.account_id)
.OrderByDescending(login => login.lastLoginTime)
.FirstOrDefaultAsync();
if (data.account_id > 0 && existingLink == null)
return StatusCode(400, new AuthResponse("invalid_grant", "no linked account for this platform identity"));
if (existingLink != null && carriesSignUpPayload)
{
var linkedSettings = await db.PlayerSettings.AsNoTracking().FirstOrDefaultAsync(x => x.AccountId == existingLink.accountId);
bool finished = linkedSettings?.DeserializeSettings().Any(x =>
string.Equals(x.Key, "Recroom.AccountCreation.HasFinished", StringComparison.OrdinalIgnoreCase) &&
string.Equals(x.Value, "true", StringComparison.OrdinalIgnoreCase)) == true;
if (finished)
existingLink = null;
}
if (existingLink != null)
{
data.account_id = existingLink.accountId;
if (carriesSignUpPayload)
await ApplySignUpPayloadAsync(data, existingLink.accountId);
else if (!await PlayerUtils.HasFinishedAccountCreationAsync(db, existingLink.accountId))
{
await PlayerUtils.RefreshAccountCreationMarkersAsync(db, existingLink.accountId);
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Onboarding] account {existingLink.accountId} resumed " +
"before its creation flow finished; started marker restored so the client runs the flow");
}
}
else
{
var adoptable = await PlayerUtils.FindAdoptableAccountForDeviceAsync(db, data.device_id?.Trim());
if (adoptable != null)
{
data.account_id = adoptable.AccountId;
await PlayerUtils.AdoptDeviceAccountAsync(db, adoptable.AccountId, new AccountCreationData
{
platform = data.platform,
platformId = data.platform_id ?? string.Empty,
deviceId = data.device_id ?? string.Empty
}, markStarted: true);
if (carriesSignUpPayload)
await ApplySignUpPayloadAsync(data, adoptable.AccountId);
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Auth] create_account adopted this device's account " +
$"{adoptable.AccountId} for platform {data.platform} identity {data.platform_id}");
}
else
{
var refusal = await TryProvisionOnboardingAccountAsync(data);
if (refusal != null)
{
return TokenRefusal(refusal);
}
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Auth] create_account made account {data.account_id} " +
$"for platform {data.platform} identity {data.platform_id}");
}
}
response = await TokenRefresh(data);
return Ok(response);
}
case "refresh_token":
response = await TokenRefresh(data);
return Ok(response);
case "password":
if (data.username == null) return Ok(new AuthResponse { error = "Failed to sign in.", error_description = "No username was given." });
if (data.password == null) return Ok(new AuthResponse { error = "Failed to sign in.", error_description = "No password was given." });
var loginIp = Request.GetClientIp();
if (AuthSecurityMonitor.IsSourceThrottled(loginIp))
{
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Security] throttled sign-in source {loginIp} (suspected spraying)");
return StatusCode(429, new AuthResponse("too_many_requests", "Too many sign-in attempts from this connection. Please try again later."));
}
var loginName = data.username.Trim().TrimStart('@');
var existingProfile = await db.Profiles.FirstOrDefaultAsync(x => x.username == loginName || x.displayName == loginName);
if (existingProfile != null)
{
data.account_id = existingProfile.accountId;
}
var playerData = await db.Players.FirstOrDefaultAsync(x => x.AccountId == data.account_id);
if (playerData != null)
{
var result = hasher.VerifyHashedPassword(playerData, playerData.Hashword, data.password);
if (result == PasswordVerificationResult.Success)
{
var alreadyCached = await db.CachedLogins.FirstOrDefaultAsync(x => x.accountId == data.account_id && x.platform == data.platform && x.platformId == data.platform_id);
if (await AccountLock.IsLockedAsync(db, data.account_id) && alreadyCached == null)
{
var lockedUsername = await AccountLock.ResolveUsernameAsync(db, data.account_id);
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Security] refused password sign-in for locked account {data.account_id}");
return Ok(new AuthResponse(AccountLock.ErrorCode, AccountLock.LoginErrorMessage(lockedUsername)));
}
if (alreadyCached == null)
{
await db.AddAsync(new CachedLogin { lastLoginTime = DateTime.UtcNow, accountId = data.account_id, platform = data.platform, platformId = data.platform_id! });
await db.SaveChangesAsync();
}
var newDevice = !string.IsNullOrWhiteSpace(playerData.DeviceId)
&& !string.Equals(playerData.DeviceId, data.device_id, StringComparison.Ordinal);
var newIp = !string.IsNullOrWhiteSpace(playerData.LastIpAddress)
&& !string.Equals(playerData.LastIpAddress, loginIp, StringComparison.Ordinal);
AuthSecurityMonitor.RecordSuccessfulLogin(data.account_id, loginIp, newDevice, newIp);
response = await TokenRefresh(data, verifiedByPassword: true);
return Ok(response);
}
}
var failures = AuthSecurityMonitor.RecordFailedLogin(data.account_id, loginIp);
var failureDelay = AuthSecurityMonitor.ProgressiveDelayMs(failures);
if (failureDelay > 0) await Task.Delay(failureDelay);
return Ok(new AuthResponse { error = "Failed to sign in.", error_description = "Unknown error." });
}
}
[NonAction]
private async Task ApplySignUpPayloadAsync(TokenConnectData data, long accountId)
{
var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == accountId);
var plrData = await db.Players.FirstOrDefaultAsync(x => x.AccountId == accountId);
if (profile == null || plrData == null)
{
return;
}
var applied = new List<string>();
var ignored = new List<string>();
bool isOnboarding = await PlayerUtils.IsChoosingTheirAccountNameAsync(db, accountId) || PlayerUtils.IsNewPlayer(plrData);
bool nameApplied = false;
bool nameChosen = false;
var chosen = UsernameRules.Parse(data.username, out var nameRejection);
if (nameRejection != null)
{
ignored.Add($"name=refused({nameRejection})");
}
else if (chosen == null)
{
ignored.Add("name=absent");
}
else if (string.Equals(chosen, profile.username, StringComparison.Ordinal))
{
ignored.Add($"name={chosen}(already its name)");
nameChosen = true;
}
else if (profile.NameLocked)
{
ignored.Add($"name={chosen}(locked)");
}
else if (!await PlayerUtils.IsChoosingTheirAccountNameAsync(db, accountId) && !PlayerUtils.IsNewPlayer(plrData))
{
ignored.Add($"name={chosen}(flow over for account '{profile.username}')");
}
else if (await PlayerUtils.IsNameTakenAsync(db, chosen))
{
ignored.Add($"name={chosen}(taken)");
}
else
{
var previous = profile.username;
profile.username = chosen;
profile.displayName = chosen;
applied.Add($"name={chosen}(was '{previous}')");
nameApplied = true;
nameChosen = true;
}
var birthday = PlayerUtils.ReadBirthday(data.birthday ?? data.birthdayUTC);
if (birthday == null)
{
ignored.Add("birthday=absent");
}
else if (plrData.Birthday != null && plrData.Birthday.Value.Year > 1900)
{
ignored.Add($"birthday={birthday:yyyy-MM-dd}(account already gave {plrData.Birthday:yyyy-MM-dd})");
}
else
{
plrData.Birthday = birthday;
plrData.IsJunior = PlayerUtils.IsJuniorByBirthday(birthday);
profile.isJunior = plrData.IsJunior;
applied.Add($"birthday={birthday:yyyy-MM-dd}");
}
if (string.IsNullOrWhiteSpace(data.password))
{
ignored.Add("password=absent");
}
else if (!string.IsNullOrWhiteSpace(plrData.Hashword))
{
ignored.Add("password=(account already has one)");
}
else
{
plrData.Hashword = new PasswordHasher<PlayerData>().HashPassword(plrData, data.password);
applied.Add("password=set");
}
if (applied.Count > 0 || isOnboarding) await db.SaveChangesAsync();
if (isOnboarding || nameApplied)
await PlayerUtils.RefreshAccountCreationMarkersAsync(db, accountId, nameChosen ? true : null);
var line =
$"[SignUp] account {accountId} resumed its sign-up on an identity that was already linked: " +
$"applied [{string.Join(", ", applied)}], ignored [{string.Join(", ", ignored)}]; " +
$"name is now '{profile.username}', birthday {(plrData.Birthday == null ? "none" : plrData.Birthday.Value.ToString("yyyy-MM-dd"))}, " +
$"password {(string.IsNullOrWhiteSpace(plrData.Hashword) ? "unset" : "set")}.";
}
[NonAction]
private async Task<AuthResponse?> TryProvisionOnboardingAccountAsync(TokenConnectData data)
{
var platformId = data.platform_id?.Trim() ?? string.Empty;
var deviceId = data.device_id?.Trim() ?? string.Empty;
if (platformId.Length == 0 || deviceId.Length == 0 || platformId.Length > 64 || deviceId.Length > 256)
{
return new AuthResponse("Account creation failed.", "A valid platform ID and device ID are required.");
}
var clientIp = Request.GetClientIp();
if (await db.IsIpBanned(clientIp))
return new AuthResponse("Account creation failed.", "This IP address is banned from creating new accounts.");
if (await db.IsAccountCreationBlocked(clientIp))
return new AuthResponse("Account creation failed.", "This IP address is blocked from creating new accounts.");
if (await db.IsHardwareBanned(deviceId))
return new AuthResponse("Account creation failed.", "This device is banned.");
var throttled = RegistrationGuard.Check(clientIp, deviceId);
if (throttled != null)
{
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Registration] refused a new account for platform " +
$"{data.platform} identity {platformId} ({RegistrationGuard.Describe(clientIp, deviceId)}): {throttled}");
return new AuthResponse("Account creation failed.", throttled);
}
data.platform_id = platformId;
data.device_id = deviceId;
var created = await PlayerUtils.CreateAccount(new AccountCreationData
{
platform = data.platform,
platformId = platformId,
deviceId = deviceId,
deviceClass = data.device_class,
password = data.password,
username = data.username,
birthday = data.birthday,
birthdayUTC = data.birthdayUTC,
email = data.email,
avatar = data.avatar,
avatarJson = data.avatarJson
}, clientIp, accountCreationInProgress: true);
if (!created.success || created.value is not Profile profile)
return new AuthResponse("Account creation failed.", created.error ?? "Could not create the account.");
data.account_id = profile.accountId;
return null;
}
[NonAction]
private static bool HasSignUpPayload(TokenConnectData data) =>
!string.IsNullOrWhiteSpace(data.username) ||
!string.IsNullOrWhiteSpace(data.password) ||
data.birthday != null ||
data.birthdayUTC != null ||
!string.IsNullOrWhiteSpace(data.avatar) ||
!string.IsNullOrWhiteSpace(data.avatarJson) ||
!string.IsNullOrWhiteSpace(data.email);
[NonAction]
private static ObjectResult TokenRefusal(AuthResponse refusal)
{
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Auth] sign-in refused: error={refusal.error} detail=\"{refusal.error_description}\"");
return new(refusal) { StatusCode = 400 };
}
private const string SteamAppId = "471710";
private static readonly string[] SteamAllowedAppIds = ["480", SteamAppId];
[NonAction]
protected virtual async Task<string?> AuthenticateSteamTicketAsync(string ticket, string appId, CancellationToken cancellationToken)
{
foreach (var candidate in CandidateSteamAppIds(appId))
{
var result = await PlatformIdentity.AuthenticateTicketAsync(ticket, candidate, cancellationToken);
if (result.OtherApp) continue;
return result.Ok ? result.SteamId : null;
}
return null;
}
[NonAction]
private static IEnumerable<string> CandidateSteamAppIds(string appId) =>
new[] { appId }.Concat(SteamAllowedAppIds)
.Where(candidate => !string.IsNullOrWhiteSpace(candidate))
.Distinct(StringComparer.Ordinal);
[NonAction]
private static string GetSteamAppId(string platformAuth)
{
using var document = JsonDocument.Parse(platformAuth);
var appId = document.RootElement.GetProperty("AppId");
return appId.ValueKind == JsonValueKind.String ? appId.GetString() ?? SteamAppId : appId.ToString();
}
[NonAction]
private static bool TryReadSteamTicket(string platformAuth, out string ticket)
{
ticket = string.Empty;
try
{
using var document = JsonDocument.Parse(platformAuth);
if (!document.RootElement.TryGetProperty("Ticket", out var ticketElement) ||
ticketElement.ValueKind != JsonValueKind.String)
return false;
ticket = ticketElement.GetString() ?? string.Empty;
return ticket.Length > 0;
}
catch (JsonException)
{
return false;
}
}
[NonAction]
private ActionResult SteamIdentityRefusal(string stage, string detail, TokenConnectData data)
{
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Auth] connect/token refused at {stage} for " +
$"platform {data.platform} identity {OrAbsent(data.platform_id)} " +
$"grant_type {OrAbsent(data.grant_type)}: {detail}");
return Unauthorized();
}
[NonAction]
private static string OrAbsent(string? value) =>
string.IsNullOrWhiteSpace(value) ? "<absent>" : value.Trim();
[NonAction]
private static bool TryValidateSteamPlatformAuth(string platformAuth, out string reason)
{
reason = "unknown";
if (platformAuth[0] != '{' || platformAuth[^1] != '}')
{
reason = $"not a JSON object (len={platformAuth.Length})";
return false;
}
try
{
using var doc = JsonDocument.Parse(platformAuth);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
reason = "platform_auth is valid JSON but not an object";
return false;
} if (!root.TryGetProperty("Ticket", out var ticketElement) || ticketElement.ValueKind != JsonValueKind.String)
{
reason = $"no string 'Ticket' property (properties: {DescribeProperties(root)})";
return false;
}
var ticket = ticketElement.GetString() ?? string.Empty;
if (ticket.Length == 0 || !ticket.StartsWith("140", StringComparison.Ordinal))
{
reason = $"'Ticket' has an unexpected format (len={ticket.Length})";
return false;
}
if (!root.TryGetProperty("AppId", out var appIdElement))
{
reason = $"no 'AppId' property (properties: {DescribeProperties(root)})";
return false;
}
var appId = appIdElement.ValueKind == JsonValueKind.String ? appIdElement.GetString() : appIdElement.ToString();
if (!SteamAllowedAppIds.Contains(appId, StringComparer.Ordinal))
{
reason = "'AppId' is not supported for Steam ticket verification";
return false;
}
reason = "ok";
return true;
}
catch (JsonException ex)
{
reason = $"invalid JSON ({ex.GetType().Name})";
return false;
}
}
[NonAction]
private static string DescribeProperties(JsonElement root)
{
var names = new List<string>();
foreach (var property in root.EnumerateObject())
names.Add(property.Name);
return names.Count == 0 ? "none" : string.Join(",", names);
}
[NonAction]
private async Task<AuthResponse> TokenRefresh(TokenConnectData data, bool verifiedByPassword = false)
{
if (data.account_id <= 0)
{
long resolvedAccountId = 0;
if (!string.IsNullOrWhiteSpace(data.refresh_token) &&
Guid.TryParse(data.refresh_token, out var tokenGuid))
{
var tokenBytes = tokenGuid.ToByteArray();
var embedded = BitConverter.ToInt64(tokenBytes, 0);
if (embedded > 0 && await db.Players.AnyAsync(x => x.AccountId == embedded))
resolvedAccountId = embedded;
}
if (resolvedAccountId <= 0)
{
var headerAuth = base.Request.Headers.Authorization.ToString();
if (!string.IsNullOrWhiteSpace(headerAuth))
{
var headerUserId = headerAuth.GetUserId();
if (headerUserId.HasValue && headerUserId.Value > 0 && await db.Players.AnyAsync(x => x.AccountId == headerUserId.Value))
resolvedAccountId = headerUserId.Value;
}
}
if (resolvedAccountId <= 0)
{
if (!string.IsNullOrWhiteSpace(data.device_id))
{
var devicePlayer = await db.Players
.Where(x => x.DeviceId == data.device_id)
.OrderByDescending(x => x.AccountId)
.FirstOrDefaultAsync();
if (devicePlayer != null) resolvedAccountId = devicePlayer.AccountId;
}
else if (!string.IsNullOrWhiteSpace(data.platform_id))
{
var recentLogin = await db.CachedLogins
.Where(x => x.platformId == data.platform_id && x.platform == data.platform)
.OrderByDescending(x => x.lastLoginTime)
.FirstOrDefaultAsync();
if (recentLogin != null) resolvedAccountId = recentLogin.accountId;
}
}
if (resolvedAccountId > 0) data.account_id = resolvedAccountId; } PlayerData? player = await db.Players.FirstOrDefaultAsync(x => x.AccountId == data.account_id);
if (player == null)
{
var hasIdentity = await db.CachedLogins.AnyAsync(l => l.accountId == data.account_id)
|| await db.Profiles.AnyAsync(p => p.accountId == data.account_id);
if (hasIdentity)
{
player = new PlayerData
{
AccountId = data.account_id,
Phone = "",
AppVersion = data.ver ?? DBConfig.GameVersion,
DeviceClass = data.device_class,
NameChanges = 99,
Email = "",
Hashword = "",
JuniorState = JuniorState.BasedOnBirthday,
Visibility = StatusVisibility.Public,
DeviceId = data.device_id ?? "",
LastIpAddress = Request.GetClientIp() ?? ""
};
await db.AddRangeAsync(
player,
new PlayerAvatar { Avatar = DBConfig.DefaultAvatar, Outfits = "[]", AccountId = data.account_id },
new Reputation { AccountId = data.account_id },
new PlayerSettings { AccountId = data.account_id, Settings = DBConfig.NewAccountSettings2023 },
new ProgressionData { PlayerId = data.account_id, XP = 0, Level = 1, Progress = new ProgressionJson().ToJson() },
new RealPresence { playerId = data.account_id, deviceClass = data.device_class, statusVisibility = StatusVisibility.Public, vRMovementMode = VRMovementModes.TELEPORT },
new Currency { AccountId = data.account_id },
new PlayerUnlocks { AccountId = data.account_id, AvatarItems = DBConfig.DefaultAvatarUnlocks }
);
await db.SaveChangesAsync();
await RoomUtils.GetOrCreateDorm(data.account_id);
}
}
CachedLogin? cachedLogin = await db.CachedLogins.FirstOrDefaultAsync(
l => l.accountId == data.account_id &&
l.platformId == data.platform_id &&
l.platform == data.platform);
if (data.account_id == 1)
{
return new AuthResponse("Failed to login.", "System account cannot log in.");
}
if (player == null)
{
return new AuthResponse("Failed to login.", "Missing player data.");
}
if (await AccountLock.IsLockedAsync(db, data.account_id) && cachedLogin == null)
{
var lockedUsername = await AccountLock.ResolveUsernameAsync(db, data.account_id);
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Security] refused {data.grant_type} sign-in for locked account {data.account_id} (platform={data.platform}, platformId={data.platform_id})");
return new AuthResponse(AccountLock.ErrorCode, AccountLock.LoginErrorMessage(lockedUsername));
}
if (cachedLogin == null)
{
return new AuthResponse("Failed to login.", "Missing cached login.");
}
AuthResponse response = new AuthResponse();
string authorization = data.platform == Platforms.Standalone ? string.Empty : base.Request.Headers.Authorization.ToString();
bool hasToken = !string.IsNullOrEmpty(authorization);
bool isPlatform = false;
if (cachedLogin != null)
{
if (cachedLogin.platformId == data.platform_id &&
cachedLogin.platform == data.platform &&
(player.DeviceId == data.device_id || string.IsNullOrEmpty(player.DeviceId)) &&
data.client_id == "recroom")
{
isPlatform = true;
}
}
if (!hasToken && !isPlatform && !verifiedByPassword)
{
base.Response.StatusCode = 401;
return new AuthResponse("Failed to login.", "Invalid platform session.");
}
else
{
long userId = data.account_id;
if (hasToken)
{
var headerUserId = authorization.GetUserId();
if (headerUserId.HasValue)
{
userId = headerUserId.Value;
}
else
{
hasToken = false;
}
}
Success newToken = await GenerateToken(db, data.account_id, data.ver!, data.device_class, data.platform, data.platform_id);
player.DeviceId = data.device_id!;
if (data.device_class != DeviceClass.Unknown)
player.DeviceClass = data.device_class;
var loginIp = Request.GetClientIp();
if (!string.IsNullOrWhiteSpace(loginIp))
player.LastIpAddress = loginIp;
await db.SaveChangesAsync();
if (!newToken.success || newToken.value == null)
{
response = new AuthResponse
{
error = "Error",
error_description = newToken.error
};
return response;
}
else
{
response.access_token = (string)newToken.value;
if (data.platform == Platforms.Standalone)
Response.Headers[QuestRememberedSession.Header] = response.access_token;
var accountBytes = BitConverter.GetBytes(data.account_id);
var refreshGuidBytes = new byte[16];
Array.Copy(accountBytes, 0, refreshGuidBytes, 0, 8); response.refresh_token = new Guid(refreshGuidBytes).ToString();
response.key = "c3Vja215ZGlja25nYQ==";
return response;
}
}
}
[NonAction]
internal static async Task<Success> GenerateToken(RecDBContext db, long userId, string version, DeviceClass deviceClass, Platforms platform, string platformId)
{
CachedLogin? cachedLogin = await db.CachedLogins.FirstOrDefaultAsync(
login => login.accountId == userId &&
login.platformId == platformId &&
login.platform == platform);
Profile? profile = await db.Profiles.FirstOrDefaultAsync((plr) => plr.accountId == userId);
if (profile == null)
return AuthResponses.MissingProfile;
if (await AccountLock.IsLockedAsync(db, userId) && cachedLogin == null)
return new Success(false, AccountLock.ErrorCode, null);
RealPresence? presence = await db.Presences.FirstOrDefaultAsync(x => x.playerId == userId);
if (presence == null) return AuthResponses.MissingPresence;
presence.deviceClass = deviceClass;
PlayerData? player = await db.Players.FirstOrDefaultAsync((plr) => plr.AccountId == userId);
if (player == null) return AuthResponses.MissingPlayer;
player.AppVersion = version;
if (cachedLogin != null)
{
cachedLogin.lastLoginTime = DateTime.UtcNow;
}
await db.SaveChangesAsync();
MatchmakingUtils.ClearInRoom(userId);
List<Claim> claims = new List<Claim>();
claims.Add(new Claim("iss", DBConfig.TokenIssuer));
claims.Add(new Claim("client_id", "recroom"));
claims.Add(new Claim("role", "gameClient"));
if (RadicadeServer.RecNet.Keepsakes.KeepsakeFeature.Enabled)
{
claims.Add(new Claim("role", "keepsake"));
claims.Add(new Claim("role", "livekeepsakeeventroomsaveoverride"));
}
claims.Add(new Claim("rn.plat", ((int)platform).ToString()));
claims.Add(new Claim("rn.platid", platformId));
claims.Add(new Claim("rn.deviceclass", deviceClass.ToString()));
claims.Add(new Claim("rn.ver", version));
claims.Add(new Claim("idp", "local"));
claims.Add(new Claim("userId", userId.ToString()));
claims.Add(new Claim(ClaimTypes.NameIdentifier, userId.ToString()));
claims.Add(new Claim("tver", player.TokenVersion.ToString()));
if (platform == Platforms.Standalone)
claims.Add(new Claim("q.pwd", QuestRememberedSession.PasswordVersion(player.Hashword)));
if (profile.isJunior || PlayerUtils.IsJuniorByBirthday(player.Birthday))
claims.Add(new Claim("role", "junior"));
if (profile.HasRole("developer")) claims.Add(new Claim("role", "developer"));
if (profile.HasRole("betastudio")) claims.Add(new Claim("role", "betastudio"));
if (profile.HasRole("moderator")) claims.Add(new Claim("role", "moderator"));
if (profile.HasRole("screenshare")) claims.Add(new Claim("role", "screenshare"));
if (profile.HasRole("influencer")) claims.Add(new Claim("role", "influencer"));
var tokenHandler = new JwtSecurityTokenHandler();
var key = Encoding.ASCII.GetBytes(DBConfig.JwtKey);
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(claims.ToArray()),
Expires = DateTime.Now.AddDays(7),
SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature)
};
var token = tokenHandler.CreateToken(tokenDescriptor);
var tokenWritten = tokenHandler.WriteToken(token);
return new Success(true, null, tokenWritten);
}
}
}
+18
View File
@@ -0,0 +1,18 @@
using Microsoft.EntityFrameworkCore;
using RadicadeServer.DB;
namespace RadicadeServer.Auth;
public class AuthShared
{
public static IDbContextFactory<RecDBContext> DbFactory = null!;
public static Task<bool> IsWhitelisted(string platformId)
{
if (!SteamWhitelist.Enabled) return Task.FromResult(true);
return Task.FromResult(SteamWhitelist.Contains(platformId));
}
}
+23
View File
@@ -0,0 +1,23 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using RadicadeServer.RecNet.Enums.Players;
namespace RadicadeServer.RecNet.Auth.Cached
{
public class CachedLogin
{
[Key, DatabaseGenerated(DatabaseGeneratedOption.Identity), JsonIgnore]
public long loginId { get; set; }
public long accountId { get; set; }
public Platforms platform { get; set; }
public string platformId { get; set; } = null!;
public DateTime lastLoginTime { get; set; }
public bool requirePassword { get; set; }
}
}
+55
View File
@@ -0,0 +1,55 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using RadicadeServer.DB;
using RadicadeServer.RecNet.Auth.Cached;
using RadicadeServer.RecNet.Enums.Players;
namespace RadicadeServer.Auth.Cached
{
[Route("auth/cachedlogin")]
public class CachedLoginAPI : Controller
{
RecDBContext db;
public CachedLoginAPI(RecDBContext context)
{
this.db = context;
}
[HttpGet("forplatformid/{platform}/{platformId}")]
public async Task<IActionResult> GetCachedLoginsForPlatformId(Platforms platform, string platformId)
{
if (platform == Platforms.Standalone) return Ok(new List<CachedLogin>());
var cached = await db.CachedLogins.AsNoTracking()
.Where(login => login.platformId == platformId && login.platform == platform)
.Where(login => db.Players.Any(player => player.AccountId == login.accountId))
.Where(login => db.Profiles.Any(profile => profile.accountId == login.accountId))
.Where(login => db.Presences.Any(presence => presence.playerId == login.accountId))
.OrderByDescending(login => login.lastLoginTime)
.ToListAsync();
return Ok(cached);
}
[HttpPost("forplatformids")]
public async Task<List<CachedLogin>> GetCachedLoginsForPlatformIds([FromForm] string[] id)
{
if (id == null || id.Length <= 0) return [];
var requestedIds = id.Where(platformId => !string.IsNullOrWhiteSpace(platformId))
.Distinct(StringComparer.Ordinal).ToArray();
var cachedLogins = new List<CachedLogin>();
foreach (var platformId in requestedIds)
{
var cached = await db.CachedLogins.AsNoTracking()
.Where(login => login.platformId == platformId && login.platform != Platforms.Standalone)
.Where(login => db.Players.Any(player => player.AccountId == login.accountId))
.Where(login => db.Profiles.Any(profile => profile.accountId == login.accountId))
.Where(login => db.Presences.Any(presence => presence.playerId == login.accountId))
.OrderByDescending(login => login.lastLoginTime)
.FirstOrDefaultAsync();
if (cached != null) cachedLogins.Add(cached);
}
return cachedLogins;
}
}
}
+55
View File
@@ -0,0 +1,55 @@
using Microsoft.AspNetCore.Mvc;
using RadicadeServer.DB;
namespace RadicadeServer.Auth
{
[Route("/")]
public class DeviceApprovalAPI : Controller
{
public DeviceApprovalAPI() {
}
[HttpGet("device")]
public IActionResult Device([FromQuery(Name = "user_code")] string userCode)
{
var (found, approved, accountId, expiresUtc) = StudioAuthAPI.DescribeUserCode(userCode);
return Content(DevicePage(userCode, found, approved, accountId, expiresUtc), "text/html");
}
private static string DevicePage(string userCode, bool found, bool approved, long accountId, DateTime expiresUtc)
{
var glyph = found && approved ? "✅" : found ? "⏳" : "⚠️";
var color = found && approved ? "#16a34a" : found ? "#d97706" : "#dc2626";
var title = found && approved ? "Approved" : found ? "Waiting for approval" : "Code not recognised";
var body = found && approved
? "This server signs Rec Room Studio in automatically, so this code is already approved as account <b>" + accountId + "</b>. Close this tab and go back to Unity."
: found
? "The code is valid, but it has not been approved yet. Return to Unity and try again."
: "This server has no pending sign-in for that code - it may have expired, or it may already have been used (device codes are single use). Start the sign-in again from Unity.";
var codeBlock = string.IsNullOrWhiteSpace(userCode)
? "<p style=\"color:#475569;font-size:15px;\">No <b>user_code</b> was supplied. Unity sends one when it opens this page.</p>"
: "<p style=\"color:#475569;font-size:15px;\">Your code</p>" +
"<p style=\"font-family:Consolas,Menlo,monospace;font-size:26px;letter-spacing:3px;color:#0f172a;background:#f1f5f9;border-radius:8px;padding:12px 0;margin:0;\">" +
System.Net.WebUtility.HtmlEncode(userCode) + "</p>";
var expiry = found && expiresUtc > DateTime.UtcNow
? "<p style=\"color:#94a3b8;font-size:13px;margin-top:18px;\">Code expires " + expiresUtc.ToString("u") + " UTC.</p>"
: "";
return "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>" +
"Radicade - Rec Room Studio sign-in</title></head>" +
"<body style=\"margin:0;background:#0f172a;font-family:Arial,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;\">" +
"<div style=\"background:#fff;border-radius:12px;padding:40px;max-width:440px;text-align:center;box-shadow:0 10px 40px rgba(0,0,0,0.3);\">" +
"<div style=\"font-size:44px;\">" + glyph + "</div>" +
"<h1 style=\"color:" + color + ";font-size:22px;margin:12px 0;\">" + title + "</h1>" +
codeBlock +
"<p style=\"color:#475569;font-size:15px;line-height:1.6;margin-top:18px;\">" + body + "</p>" +
expiry +
"<p style=\"color:#94a3b8;font-size:12px;margin-top:24px;\">" + DBConfig.ServerUrl + " - local Radicade server</p>" +
"</div></body></html>";
}
}
}
+14
View File
@@ -0,0 +1,14 @@
using Microsoft.AspNetCore.Mvc;
namespace RadicadeServer.Auth
{
[Route("auth/eac")]
public class EACApi : Controller
{
[HttpGet("challenge")]
public IActionResult GetFakeEACChallenge()
{
return new JsonResult("AQAAAOmBNw8TMlSZs9NsuyGxsU7WMoKVcieTW8IyoSMEGcbTIPg7DZ4JGZTCfg4b1h58FOOkM7r0LUhRVwlq8wo528o3LR9g/kHzlrVJA6H0+HaYTQzjKEe+zl5/VupqSXk8PXQ0BlNiRED3x5YMCGiM+F0fYmd0NU4K0d5hY+A=");
}
}
}
+18
View File
@@ -0,0 +1,18 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using RadicadeServer.RecNet.Auth.Photon;
namespace RadicadeServer.Auth.Photon
{
[ApiController, Route("auth/photon")]
public class PhotonAuthAPI : Controller
{
[AllowAnonymous]
[HttpGet("authenticate")]
[HttpPost("authenticate")]
public ActionResult<PhotonAuthResult> AuthenticatePhoton()
{
return Ok(new PhotonAuthResult { ResultCode = 1 });
}
}
}
+18
View File
@@ -0,0 +1,18 @@
using System.Text.Json.Serialization;
namespace RadicadeServer.RecNet.Auth.Photon
{
public class PhotonAuthResult
{
public int ResultCode { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? UserId { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Message { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Nickname { get; set; }
}
}
+242
View File
@@ -0,0 +1,242 @@
using System.Text.Json;
using RadicadeServer.DB;
using RadicadeServer.RecNet.Enums.Players;
namespace RadicadeServer.Auth;
public static class PlatformIdentity
{
public static readonly string[] AllowedSteamAppIds = ["480", "471710"];
public static HttpClient Client = new() { Timeout = TimeSpan.FromSeconds(10) };
public sealed record Proof(bool Verified, string? SteamId, string Detail)
{
public static Proof Ok(string? steamId) => new(true, steamId, "proved");
public static Proof No(string detail) => new(false, null, detail);
}
public static async Task<Proof> ProveAsync(
Platforms platform,
string? platformId,
string? steamTicketHeader,
string? platformAuth,
CancellationToken cancellationToken = default)
{
var id = platformId?.Trim() ?? string.Empty;
if (id.Length == 0) return Proof.No("A platform id is required.");
return platform switch
{
Platforms.Steam => await ProveSteamAsync(id, steamTicketHeader, platformAuth, cancellationToken),
Platforms.Oculus => ProveOculus(id, platformAuth),
_ => Proof.No("Account creation is only available from Steam or Oculus on this server.")
};
}
private static async Task<Proof> ProveSteamAsync(
string platformId,
string? steamTicketHeader,
string? platformAuth,
CancellationToken cancellationToken)
{
var ticket = string.IsNullOrWhiteSpace(steamTicketHeader)
? ReadString(platformAuth, "Ticket")
: steamTicketHeader.Trim();
if (string.IsNullOrWhiteSpace(ticket))
return Proof.No("No Steam ticket was sent (x-steam-ticket header or platform_auth.Ticket).");
if (string.IsNullOrWhiteSpace(DBConfig.SteamApiKey))
{
if (!DBConfig.TrustUnverifiedSteamIdentity)
return Proof.No("Steam identity cannot be proved: no SteamApiKey is configured.");
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Identity] accepted Steam identity {platformId} " +
"UNPROVED - no SteamApiKey is configured and TrustUnverifiedSteamIdentity is on.");
return Proof.Ok(null);
}
var attempted = new List<string>();
foreach (var appId in CandidateAppIds(ReadString(platformAuth, "AppId")))
{
attempted.Add(appId);
var result = await AuthenticateTicketAsync(ticket, appId, cancellationToken);
if (result.OtherApp) continue;
if (!result.Ok)
return Proof.No(result.Detail);
if (!string.Equals(result.SteamId, platformId, StringComparison.Ordinal))
return Proof.No($"The Steam ticket is for {result.SteamId}, not for the identity it was sent with.");
return Proof.Ok(result.SteamId);
}
return Proof.No("The Steam ticket is not for an app this server accepts (tried " +
string.Join(", ", attempted) + ").");
}
private static Proof ProveOculus(string platformId, string? platformAuth)
{
var nonce = ReadString(platformAuth, "Nonce");
if (string.IsNullOrWhiteSpace(nonce))
return Proof.No("No Oculus nonce was sent (platform_auth.Nonce).");
return Proof.No("Oculus identity cannot be proved yet: no Oculus app secret is configured, " +
"so the nonce cannot be validated.");
}
public static async Task<string?> CheckSteamStandingAsync(string steamId, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(DBConfig.SteamApiKey)) return null;
if (string.IsNullOrWhiteSpace(steamId)) return null;
var key = Uri.EscapeDataString(DBConfig.SteamApiKey);
var id = Uri.EscapeDataString(steamId);
try
{
using var summaryDoc = await GetJsonAsync(
$"https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/?key={key}&steamids={id}",
cancellationToken);
if (summaryDoc == null) return null;
if (!summaryDoc.RootElement.TryGetProperty("response", out var summary) ||
!summary.TryGetProperty("players", out var players) ||
players.ValueKind != JsonValueKind.Array ||
players.GetArrayLength() == 0)
return "Your Steam profile was not found, so an account cannot be created for it.";
var player = players[0];
if (player.TryGetProperty("profilestate", out var profileState) &&
profileState.ValueKind == JsonValueKind.Number &&
profileState.TryGetInt32(out var state) && state != 1)
return "Your Steam profile has to be set up before you can create an account.";
using var bansDoc = await GetJsonAsync(
$"https://api.steampowered.com/ISteamUser/GetPlayerBans/v1/?key={key}&steamids={id}",
cancellationToken);
if (bansDoc == null) return null;
if (!bansDoc.RootElement.TryGetProperty("players", out var bans) ||
bans.ValueKind != JsonValueKind.Array || bans.GetArrayLength() == 0)
return "Steam would not report this account's ban status, so an account cannot be created for it.";
var entry = bans[0];
if (entry.TryGetProperty("CommunityBanned", out var banned) && banned.ValueKind == JsonValueKind.True)
return "Steam accounts with an active community ban are not allowed to create an account.";
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException)
{
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Identity] Steam standing check could not be " +
$"completed for {steamId}: {ex.GetType().Name}: {ex.Message}");
return null;
}
return null;
}
public sealed record TicketResult(bool Ok, bool OtherApp, string? SteamId, string Detail);
public static async Task<TicketResult> AuthenticateTicketAsync(string ticket, string appId, CancellationToken cancellationToken)
{
try
{
using var doc = await GetJsonAsync(
"https://api.steampowered.com/ISteamUserAuth/AuthenticateUserTicket/v1/?key=" +
$"{Uri.EscapeDataString(DBConfig.SteamApiKey)}&appid={Uri.EscapeDataString(appId)}" +
$"&ticket={Uri.EscapeDataString(ticket)}",
cancellationToken);
if (doc == null) return new TicketResult(false, false, null, "Steam did not answer the ticket check.");
if (!doc.RootElement.TryGetProperty("response", out var response))
return new TicketResult(false, false, null, "Steam did not answer the ticket check.");
if (response.TryGetProperty("error", out var error))
{
var code = error.TryGetProperty("errorcode", out var errorCode) && errorCode.TryGetInt32(out var value)
? value
: 0;
var description = error.TryGetProperty("errordesc", out var desc) ? desc.GetString() : null;
if (code == 102)
return new TicketResult(false, true, null, $"ticket for app {appId}");
return new TicketResult(false, false, null,
$"Steam refused the ticket ({(description ?? "no description")}).");
}
if (!response.TryGetProperty("params", out var parameters) ||
parameters.ValueKind != JsonValueKind.Object ||
!parameters.TryGetProperty("result", out var result) ||
result.GetString() != "OK" ||
!parameters.TryGetProperty("steamid", out var steamId) ||
steamId.ValueKind != JsonValueKind.String)
return new TicketResult(false, false, null, "Steam did not accept the ticket.");
return new TicketResult(true, false, steamId.GetString(), "ok");
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException)
{
return new TicketResult(false, false, null,
$"Steam could not be reached to check the ticket ({ex.GetType().Name}).");
}
}
private static async Task<JsonDocument?> GetJsonAsync(string url, CancellationToken cancellationToken)
{
using var response = await Client.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Identity] Steam answered {(int)response.StatusCode} for " +
$"{new Uri(url).AbsolutePath}.");
return null;
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
return await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
}
private static IEnumerable<string> CandidateAppIds(string? claimed)
{
var seen = new HashSet<string>(StringComparer.Ordinal);
var ordered = new List<string>();
void Add(string? candidate)
{
var value = candidate?.Trim();
if (string.IsNullOrEmpty(value) || !seen.Add(value)) return;
ordered.Add(value);
}
Add(claimed);
foreach (var id in AllowedSteamAppIds) Add(id);
return ordered;
}
private static string? ReadString(string? json, string property)
{
if (string.IsNullOrWhiteSpace(json)) return null;
try
{
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
if (!doc.RootElement.TryGetProperty(property, out var value)) return null;
return value.ValueKind switch
{
JsonValueKind.String => value.GetString(),
JsonValueKind.Number => value.ToString(),
_ => null
};
}
catch (JsonException)
{
return null;
}
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace RadicadeServer.RecNet.Auth.Cached
{
public class PlatformIds
{
public long Platform { get; set; }
public long PlatformId { get; set; }
}
}
+90
View File
@@ -0,0 +1,90 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using RadicadeServer.RecNet.Enums.Players;
namespace RadicadeServer.RecNet.Auth
{
public class PlayerData
{
[Key, DatabaseGenerated(DatabaseGeneratedOption.None)]
public long AccountId { get; set; }
public string Email { get; set; } = "";
public bool EmailVerified { get; set; }
[JsonIgnore]
public string? EmailVerifyToken { get; set; }
[JsonIgnore]
public string? PendingEmail { get; set; }
public string Phone { get; set; } = "";
public string AppVersion { get; set; } = "";
public long? Parent { get; set; }
public int NameChanges { get; set; } = 3;
public JuniorState JuniorState { get; set; }
public bool IsJunior { get; set; }
[JsonIgnore]
public string Hashword { get; set; } = "";
[JsonIgnore]
public int TokenVersion { get; set; }
[JsonIgnore]
public string? PasswordResetToken { get; set; }
[JsonIgnore]
public DateTime? PasswordResetExpires { get; set; }
public DateTime? Birthday { get; set; }
public bool CodeOfConductAccepted { get; set; }
public StatusVisibility Visibility { get; set; }
public DeviceClass DeviceClass { get; set; }
public bool Developer { get; set; }
[JsonIgnore]
public string DeviceId { get; set; } = "";
[JsonIgnore]
public string LastIpAddress { get; set; } = "";
}
}
+10
View File
@@ -0,0 +1,10 @@
namespace RadicadeServer.RecNet.Auth.Enums;
public enum PlayerRoleTypes
{
none,
influencer,
screenshare,
moderator,
developer
}
+69
View File
@@ -0,0 +1,69 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using RadicadeServer.RecNet.Accounts;
using RadicadeServer.RecNet.Auth.Enums;
namespace RadicadeServer.RecNet.Auth
{
public class Profile
{
[Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public long accountId { get; set; }
public string profileImage { get; set; } = null!;
public string? bannerImage { get; set; }
public bool isRecentHistoryVisible { get; set; } = true;
public bool isJunior { get; set; }
public int platforms { get; set; }
public string username { get; set; } = null!;
public string displayName { get; set; } = null!;
public string? bio { get; set; }
public int personalPronouns { get; set; }
public int identityFlags { get; set; }
public string? displayEmoji { get; set; }
public bool NameLocked { get; set; }
public bool BioLocked { get; set; }
public DateTime createdAt { get; set; }
public string? role { get; set; }
public bool developer => HasRole("developer");
public bool moderator => HasRole("moderator");
public bool screenshare => HasRole("screenshare");
public bool influencer => HasRole("influencer");
public bool HasRole(string expectedRoleName)
{
if (string.IsNullOrWhiteSpace(role)) return false;
var normalized = expectedRoleName switch
{
"screenshare" => "sharescreen",
"influencer" => "imfluencer",
_ => expectedRoleName
};
return role.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Any(r => r.Equals(expectedRoleName, StringComparison.OrdinalIgnoreCase) || r.Equals(normalized, StringComparison.OrdinalIgnoreCase));
}
}
}
+51
View File
@@ -0,0 +1,51 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using System.Security.Cryptography;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using RadicadeServer.DB;
namespace RadicadeServer.Auth;
internal static class QuestRememberedSession
{
internal const string Header = "X-Radicade-Quest-Session";
internal static async Task<long?> ValidateAsync(RecDBContext db, string token, string deviceId, long requestedAccount)
{
if (string.IsNullOrWhiteSpace(token) || token.Length > 8192) return null;
try
{
var handler = new JwtSecurityTokenHandler { MapInboundClaims = false };
var principal = handler.ValidateToken(token, new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(DBConfig.JwtKey)),
ValidAlgorithms = new[] { SecurityAlgorithms.HmacSha256 },
ValidateIssuer = true,
ValidIssuer = DBConfig.TokenIssuer,
ValidateAudience = false,
ValidateLifetime = true,
RequireExpirationTime = true,
ClockSkew = TimeSpan.Zero
}, out _);
string? Claim(string name) => principal.FindFirst(name)?.Value;
if (Claim("client_id") != "recroom" || Claim("rn.plat") != "7" ||
Claim("rn.platid") != "radicade-quest:" + deviceId ||
!long.TryParse(Claim("userId"), out var account) || account <= 0 ||
(requestedAccount > 0 && requestedAccount != account) ||
!int.TryParse(Claim("tver"), out var version)) return null;
var player = await db.Players.AsNoTracking().FirstOrDefaultAsync(x => x.AccountId == account);
return player != null && player.TokenVersion == version &&
Claim("q.pwd") == PasswordVersion(player.Hashword) ? account : null;
}
catch (Exception ex) when (ex is SecurityTokenException or ArgumentException)
{
return null;
}
}
internal static string PasswordVersion(string? hash) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(hash ?? "")));
}
+31
View File
@@ -0,0 +1,31 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using System.Reflection;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet.Auth;
namespace RadicadeServer.Utils.Attributes
{
}
+22
View File
@@ -0,0 +1,22 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using RadicadeServer.DB;
namespace RadicadeServer.API
{
[Route("role")]
[Route("auth/role")]
public class RolesAPI(RecDBContext db) : Controller
{
[HttpGet("{roleType}/{id}")]
public async Task<bool> GetDeveloperRole(string roleType, long id)
{
var player = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == id);
if (player != null)
{
return player.HasRole(roleType);
}
return false;
}
}
}
+192
View File
@@ -0,0 +1,192 @@
using System.Text.Json;
namespace RadicadeServer.Auth;
public static class SteamWhitelist
{
public sealed class WhitelistFile
{
public bool Enabled { get; set; }
public List<string> SteamIds { get; set; } = new();
}
private static readonly JsonSerializerOptions CaseInsensitive = new()
{
PropertyNameCaseInsensitive = true
};
private static readonly object Lock = new();
private static WhitelistFile? _data;
private static string _filePath = "";
private static bool _loaded;
private static DateTime _lastWriteUtc = DateTime.MinValue;
public static string FilePath => _filePath;
public static bool CreatedEmptyConfig { get; private set; }
public static void Load()
{
lock (Lock)
{
if (_loaded) return;
_filePath = Path.GetFullPath(Path.Join(DBConfig.PrivateData, "steamwhitelist.json"));
_loaded = true;
CreatedEmptyConfig = !File.Exists(_filePath);
try
{
if (CreatedEmptyConfig)
{
_data = new WhitelistFile { Enabled = false, SteamIds = new List<string>() };
}
else
{
var raw = File.ReadAllText(_filePath);
_data = JsonSerializer.Deserialize<WhitelistFile>(raw, CaseInsensitive) ?? new WhitelistFile();
_data.SteamIds ??= new List<string>();
_lastWriteUtc = File.GetLastWriteTimeUtc(_filePath);
}
}
catch (Exception)
{
_data = new WhitelistFile { Enabled = false, SteamIds = new List<string>() };
}
}
}
public static bool Enabled
{
get { EnsureLoaded(); lock (Lock) return _data!.Enabled; }
set { EnsureLoaded(); lock (Lock) { _data!.Enabled = value; SaveLocked(); } }
}
public static IReadOnlyCollection<string> SteamIds
{
get { EnsureLoaded(); lock (Lock) return _data!.SteamIds.ToList(); }
}
public static void SetSteamIds(IEnumerable<string> steamIds)
{
EnsureLoaded();
lock (Lock)
{
_data!.SteamIds = steamIds
.Where(id => !string.IsNullOrWhiteSpace(id))
.Select(id => id.Trim())
.Distinct()
.ToList();
SaveLocked();
}
}
public static bool Contains(string steamId)
{
if (string.IsNullOrWhiteSpace(steamId)) return false;
EnsureLoaded();
lock (Lock) return _data!.SteamIds.Contains(steamId.Trim());
}
public static bool Add(string steamId)
{
if (string.IsNullOrWhiteSpace(steamId)) return false;
steamId = steamId.Trim();
EnsureLoaded();
lock (Lock)
{
if (_data!.SteamIds.Contains(steamId)) return false;
_data.SteamIds.Add(steamId);
SaveLocked();
return true;
}
}
public static bool Remove(string steamId)
{
if (string.IsNullOrWhiteSpace(steamId)) return false;
steamId = steamId.Trim();
EnsureLoaded();
lock (Lock)
{
var removed = _data!.SteamIds.Remove(steamId);
if (removed) SaveLocked();
return removed;
}
}
private static void EnsureLoaded()
{
if (!_loaded) { Load(); return; }
lock (Lock)
{
DateTime lastWriteUtc;
try { lastWriteUtc = File.GetLastWriteTimeUtc(_filePath); }
catch { return; }
if (lastWriteUtc > _lastWriteUtc)
{
try
{
var raw = File.ReadAllText(_filePath);
var reloaded = JsonSerializer.Deserialize<WhitelistFile>(raw, CaseInsensitive);
if (reloaded != null)
{
reloaded.SteamIds ??= new List<string>();
_data = reloaded;
_lastWriteUtc = lastWriteUtc;
}
}
catch (Exception)
{
}
}
}
}
private static void SaveLocked()
{
try
{
var dir = Path.GetDirectoryName(_filePath);
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
var options = new JsonSerializerOptions { WriteIndented = true };
File.WriteAllText(_filePath, JsonSerializer.Serialize(_data, options));
_lastWriteUtc = File.GetLastWriteTimeUtc(_filePath);
}
catch (Exception)
{
}
}
}
+317
View File
@@ -0,0 +1,317 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
using RadicadeServer.Utils;
using RadicadeServer.RecNet.Accounts;
using RadicadeServer.RecNet.Enums.Players;
using RadicadeServer.RecNet.Enums.Rooms;
using System.Collections.Concurrent;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace RadicadeServer.Auth
{
[Route("auth")]
public class StudioAuthAPI : Controller
{
private const int DeviceCodeLifetimeSeconds = 900;
private const int PollIntervalSeconds = 2;
private const int TokenLifetimeSeconds = 604800;
private static readonly ConcurrentDictionary<string, PendingDeviceAuthorization> Pending = new(StringComparer.Ordinal);
private const string UserCodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
private sealed class PendingDeviceAuthorization
{
public string UserCode = string.Empty;
public string Scope = "studio";
public DateTime ExpiresUtc = DateTime.UtcNow;
public long AccountId;
public bool Approved;
}
RecDBContext db;
private readonly IConfiguration config;
public StudioAuthAPI(RecDBContext context,IConfiguration configuration) {
this.db = context;
this.config = configuration;
}
[HttpGet("connect/deviceauthorization")]
[HttpPost("connect/deviceauthorization")]
public async Task<IActionResult> DeviceAuthorization()
{
var fields = await ReadRequestFields();
if (!IsDeviceFlowAllowed())
{
return BadRequest(new { error = "access_denied", error_description = "Rec Room Studio sign-in is only available from the machine running the server." });
}
var accountId = await ResolveStudioAccountId(fields);
if (accountId <= 0)
{
return BadRequest(new { error = "access_denied", error_description = "No account is configured for Rec Room Studio on this server." });
}
PruneExpired();
var deviceCode = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant();
var pending = new PendingDeviceAuthorization
{
UserCode = GenerateUserCode(),
Scope = fields.TryGetValue("scope", out var scope) && !string.IsNullOrWhiteSpace(scope) ? scope : "studio",
ExpiresUtc = DateTime.UtcNow.AddSeconds(DeviceCodeLifetimeSeconds),
AccountId = accountId,
Approved = true
};
Pending[deviceCode] = pending;
return Ok(new
{
device_code = deviceCode,
user_code = pending.UserCode,
verification_uri = $"{DBConfig.ServerUrl}/device",
verification_uri_complete = $"{DBConfig.ServerUrl}/device?user_code={pending.UserCode}",
expires_in = DeviceCodeLifetimeSeconds,
interval = PollIntervalSeconds,
scope = pending.Scope
});
}
[HttpGet("connect/studiotoken")]
[HttpPost("connect/studiotoken")]
public async Task<IActionResult> StudioToken()
{
var fields = await ReadRequestFields();
var grantType = fields.GetValueOrDefault("grant_type") ?? string.Empty;
if (IsDeviceCodeGrant(grantType))
return await CompleteDeviceAuthorization(fields);
if (IsRefreshGrant(grantType))
return await RefreshStudioToken(fields);
if (grantType.Length == 0)
{
return BadRequest(new { error = "invalid_request", error_description = "grant_type is required." });
}
return BadRequest(new { error = "unsupported_grant_type", error_description = $"grant_type '{grantType}' is not supported." });
}
private static bool IsDeviceCodeGrant(string grantType) =>
grantType.Replace("-", "_").Contains("device_code", StringComparison.OrdinalIgnoreCase);
private static bool IsRefreshGrant(string grantType) =>
grantType.Replace("-", "_").Contains("refresh_token", StringComparison.OrdinalIgnoreCase);
internal static (bool Found, bool Approved, long AccountId, DateTime ExpiresUtc) DescribeUserCode(string userCode)
{
if (string.IsNullOrWhiteSpace(userCode)) return (false, false, 0, default);
var wanted = NormalizeUserCode(userCode);
var now = DateTime.UtcNow;
foreach (var entry in Pending)
{
if (entry.Value.ExpiresUtc <= now) continue;
if (NormalizeUserCode(entry.Value.UserCode) != wanted) continue;
return (true, entry.Value.Approved, entry.Value.AccountId, entry.Value.ExpiresUtc);
}
return (false, false, 0, default);
}
private static string NormalizeUserCode(string userCode) =>
new string(userCode.Where(char.IsLetterOrDigit).ToArray()).ToUpperInvariant();
private async Task<IActionResult> CompleteDeviceAuthorization(Dictionary<string, string> fields)
{
var deviceCode = fields.GetValueOrDefault("device_code") ?? string.Empty;
if (!Pending.TryGetValue(deviceCode, out var pending))
return BadRequest(new { error = "invalid_grant", error_description = "Unknown device code." });
if (pending.ExpiresUtc <= DateTime.UtcNow)
{
Pending.TryRemove(deviceCode, out _);
return BadRequest(new { error = "expired_token", error_description = "The device code has expired." });
}
if (!pending.Approved)
return BadRequest(new { error = "authorization_pending", error_description = "The user has not approved this sign-in yet." });
Pending.TryRemove(deviceCode, out _);
return await IssueStudioTokens(pending.AccountId);
}
private async Task<IActionResult> RefreshStudioToken(Dictionary<string, string> fields)
{
var refreshToken = fields.GetValueOrDefault("refresh_token") ?? string.Empty;
var accountId = AccountIdFromRefreshToken(refreshToken);
if (accountId > 0 && !await db.Players.AnyAsync(x => x.AccountId == accountId))
accountId = 0;
if (accountId <= 0)
{
return BadRequest(new { error = "invalid_grant", error_description = "Unknown refresh token." });
}
return await IssueStudioTokens(accountId);
}
private async Task<IActionResult> IssueStudioTokens(long accountId)
{
await EnsurePresence(accountId);
var token = await AuthAPI.GenerateToken(db, accountId, DBConfig.GameVersion, DeviceClass.Screen, Platforms.Steam, $"studio:{accountId}");
if (!token.success || token.value is not string accessToken)
{
return BadRequest(new { error = "invalid_grant", error_description = token.error ?? "Could not create a studio token." });
}
return Ok(new
{
access_token = accessToken,
refresh_token = RefreshTokenFor(accountId),
token_type = "Bearer",
expires_in = TokenLifetimeSeconds,
scope = "studio"
});
}
private static string RefreshTokenFor(long accountId)
{
var bytes = new byte[16];
Array.Copy(BitConverter.GetBytes(accountId), 0, bytes, 0, 8);
return new Guid(bytes).ToString();
}
private static long AccountIdFromRefreshToken(string refreshToken)
{
if (!Guid.TryParse(refreshToken, out var guid)) return 0;
var embedded = BitConverter.ToInt64(guid.ToByteArray(), 0);
return embedded > 0 ? embedded : 0;
}
private async Task EnsurePresence(long accountId)
{
if (await db.Presences.AnyAsync(x => x.playerId == accountId)) return;
db.Presences.Add(new RealPresence
{
playerId = accountId,
deviceClass = DeviceClass.Screen,
statusVisibility = StatusVisibility.Public,
vRMovementMode = VRMovementModes.TELEPORT
});
await db.SaveChangesAsync();
}
private async Task<long> ResolveStudioAccountId(Dictionary<string, string> fields)
{
var configured = config["Radicade:Studio:AutoApproveAccountId"];
if (long.TryParse(configured, out var configuredId) && configuredId > 0 && await db.Players.AnyAsync(x => x.AccountId == configuredId))
return configuredId;
if (long.TryParse(fields.GetValueOrDefault("account_id"), out var requestedId) && requestedId > 0 && await db.Players.AnyAsync(x => x.AccountId == requestedId))
return requestedId;
var developer = await db.Profiles.Where(p => p.role != null && p.role.Contains("developer"))
.OrderBy(p => p.accountId)
.FirstOrDefaultAsync();
if (developer != null) return developer.accountId;
return 0;
}
private bool IsDeviceFlowAllowed()
{
if (bool.TryParse(config["Radicade:Studio:AllowRemoteDeviceAuth"], out var allowRemote) && allowRemote) return true;
var ip = Request.GetClientIp();
if (string.IsNullOrWhiteSpace(ip)) return false;
if (IPAddress.TryParse(ip, out var parsed)) return IPAddress.IsLoopback(parsed);
return ip.Equals("localhost", StringComparison.OrdinalIgnoreCase);
}
private static string GenerateUserCode()
{
var buffer = new char[9];
var bytes = RandomNumberGenerator.GetBytes(8);
for (var i = 0; i < 8; i++)
{
buffer[i < 4 ? i : i + 1] = UserCodeAlphabet[bytes[i] % UserCodeAlphabet.Length];
}
buffer[4] = '-';
return new string(buffer);
}
private static void PruneExpired()
{
var now = DateTime.UtcNow;
foreach (var entry in Pending)
{
if (entry.Value.ExpiresUtc <= now) Pending.TryRemove(entry.Key, out _);
}
}
private async Task<Dictionary<string, string>> ReadRequestFields()
{
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var query in Request.Query)
fields[query.Key] = query.Value.ToString();
if (Request.HasFormContentType)
{
foreach (var form in Request.Form)
fields[form.Key] = form.Value.ToString();
}
else
{
Request.EnableBuffering();
Request.Body.Position = 0;
using var reader = new StreamReader(Request.Body, Encoding.UTF8, leaveOpen: true);
var raw = await reader.ReadToEndAsync();
Request.Body.Position = 0;
if (!string.IsNullOrWhiteSpace(raw))
{
try
{
using var document = JsonDocument.Parse(raw);
if (document.RootElement.ValueKind == JsonValueKind.Object)
{
foreach (var property in document.RootElement.EnumerateObject())
{
fields[property.Name] = property.Value.ValueKind == JsonValueKind.String
? property.Value.GetString() ?? string.Empty
: property.Value.ToString();
}
}
}
catch (JsonException)
{
fields["raw"] = raw;
}
}
}
return fields;
}
private static string Describe(Dictionary<string, string> fields)
{
var parts = fields.Select(field => field.Key.Contains("secret", StringComparison.OrdinalIgnoreCase)
|| field.Key.Contains("password", StringComparison.OrdinalIgnoreCase)
? $"{field.Key}=<redacted>"
: $"{field.Key}={field.Value}");
return string.Join(", ", parts);
}
}
}
+36
View File
@@ -0,0 +1,36 @@
using RadicadeServer.RecNet.Enums.Players;
namespace RadicadeServer.RecNet.Auth.Connection
{
public class TokenConnectData
{
public string grant_type { get; set; } = null!;
public long account_id { get; set; }
public string ver { get; set; } = null!;
public DeviceClass device_class { get; set; }
public string device_id { get; set; } = null!;
public Platforms platform { get; set; }
public string platform_id { get; set; } = null!;
public string client_id { get; set; } = null!;
public string? username { get; set; }
public string? password { get; set; }
public DateTime? birthday { get; set; }
public DateTime? birthdayUTC { get; set; }
public string? email { get; set; }
public string? avatar { get; set; }
public string? avatarJson { get; set; }
public string? platform_auth { get; set; }
public string? refresh_token { get; set; }
}
}