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 hasher; public AuthAPI(RecDBContext context) { this.db = context; this.hasher = new PasswordHasher(); } [HttpGet("account/me/haspassword"), Authorize] public async Task 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 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 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 = "
" + "\"Radicade\"" + "

Reset your Radicade password

" + "

Hi there,

" + "

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:

" + "

Reset my password

" + "

This link expires in one hour. If you didn't request this, you can safely ignore this email.

"; _ = RadicadeMail.SendAsync(plrData.Email, "Reset your password - Radicade", html); } [HttpPost("web/login")] public async Task 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> 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(); var ignored = new List(); 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().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 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 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 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) ? "" : 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(); foreach (var property in root.EnumerateObject()) names.Add(property.Name); return names.Count == 0 ? "none" : string.Join(",", names); } [NonAction] private async Task 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 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 claims = new List(); 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); } } }