Files
Radicade/Server/Auth/PlatformIdentity.cs
T
2026-10-16 23:33:13 -07:00

243 lines
10 KiB
C#

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