Files
2026-10-16 23:33:13 -07:00

1117 lines
38 KiB
C#

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);
}
}
}