mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-25 15:09:26 -07:00
Radicade Public Code
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user