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

318 lines
10 KiB
C#

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