mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-25 15:09:26 -07:00
1117 lines
38 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|