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