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