Files
mochaisvibecoded 42f7f57b72 :3
2026-08-24 17:00:26 -04:00

229 lines
7.5 KiB
C#

using System;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using JsonSerializer = System.Text.Json.JsonSerializer;
namespace Mocha2023.Classes
{
public static class PhotonTicketService
{
private const string TicketVersion = "v1";
private static readonly TimeSpan TicketLifetime = TimeSpan.FromHours(2);
private static readonly TimeSpan AllowedClockSkew = TimeSpan.FromMinutes(5);
public static string Issue(
long playerId,
long? roomInstanceId,
long? roomId,
string? displayName)
{
DateTimeOffset now = DateTimeOffset.UtcNow;
var payload = new PhotonTicketPayload
{
PlayerId = playerId,
RoomInstanceId = roomInstanceId,
RoomId = roomId,
DisplayName = displayName ?? string.Empty,
IssuedAtUnix = now.ToUnixTimeSeconds(),
ExpiresAtUnix = now.Add(TicketLifetime).ToUnixTimeSeconds(),
Nonce = Base64UrlEncode(RandomNumberGenerator.GetBytes(16))
};
byte[] payloadBytes = JsonSerializer.SerializeToUtf8Bytes(payload);
string encodedPayload = Base64UrlEncode(payloadBytes);
string signingInput = $"{TicketVersion}.{encodedPayload}";
string signature = Base64UrlEncode(Sign(signingInput));
string token = $"{signingInput}.{signature}";
Console.WriteLine(
$"[PHOTON TICKET] Issued player={playerId} " +
$"room={roomId?.ToString() ?? "none"} " +
$"instance={roomInstanceId?.ToString() ?? "none"} " +
$"fingerprint={GetFingerprint(token)}");
return token;
}
public static bool TryValidate(string? token, out PhotonTicket ticket)
{
return TryValidate(token, out ticket, out _);
}
public static bool TryValidate(
string? token,
out PhotonTicket ticket,
out string failureReason)
{
ticket = null!;
failureReason = string.Empty;
token = NormalizeToken(token);
if (string.IsNullOrWhiteSpace(token))
{
failureReason = "missing";
return false;
}
string[] parts = token.Split('.', StringSplitOptions.None);
if (parts.Length != 3 ||
!string.Equals(parts[0], TicketVersion, StringComparison.Ordinal))
{
failureReason = "wrong-format";
return false;
}
string signingInput = $"{parts[0]}.{parts[1]}";
byte[] expectedSignature = Sign(signingInput);
if (!TryBase64UrlDecode(parts[2], out byte[] suppliedSignature) ||
suppliedSignature.Length != expectedSignature.Length ||
!CryptographicOperations.FixedTimeEquals(
suppliedSignature,
expectedSignature))
{
failureReason = "bad-signature";
return false;
}
if (!TryBase64UrlDecode(parts[1], out byte[] payloadBytes))
{
failureReason = "bad-payload-encoding";
return false;
}
PhotonTicketPayload? payload;
try
{
payload = JsonSerializer.Deserialize<PhotonTicketPayload>(payloadBytes);
}
catch (JsonException)
{
failureReason = "bad-payload-json";
return false;
}
if (payload == null || payload.PlayerId <= 0)
{
failureReason = "bad-player";
return false;
}
DateTimeOffset now = DateTimeOffset.UtcNow;
long nowUnix = now.ToUnixTimeSeconds();
if (payload.ExpiresAtUnix <= nowUnix)
{
failureReason = "expired";
return false;
}
if (payload.IssuedAtUnix > now.Add(AllowedClockSkew).ToUnixTimeSeconds())
{
failureReason = "issued-in-future";
return false;
}
ticket = new PhotonTicket
{
PlayerId = payload.PlayerId,
RoomInstanceId = payload.RoomInstanceId,
RoomId = payload.RoomId,
DisplayName = payload.DisplayName ?? string.Empty,
IssuedAt = DateTimeOffset.FromUnixTimeSeconds(payload.IssuedAtUnix),
ExpiresAt = DateTimeOffset.FromUnixTimeSeconds(payload.ExpiresAtUnix)
};
return true;
}
public static string GetFingerprint(string? token)
{
token = NormalizeToken(token);
if (string.IsNullOrWhiteSpace(token))
return "none";
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(token));
return Convert.ToHexString(hash.AsSpan(0, 6));
}
private static byte[] Sign(string signingInput)
{
byte[] key = Encoding.UTF8.GetBytes(ServerConfig.PhotonCustomAuthSecret);
using var hmac = new HMACSHA256(key);
return hmac.ComputeHash(Encoding.UTF8.GetBytes(signingInput));
}
private static string? NormalizeToken(string? token)
{
if (string.IsNullOrWhiteSpace(token))
return null;
string normalized = token.Trim().Trim('"');
const string bearerPrefix = "Bearer ";
if (normalized.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase))
normalized = normalized[bearerPrefix.Length..].Trim();
return normalized;
}
private static string Base64UrlEncode(byte[] bytes)
{
return Convert.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
private static bool TryBase64UrlDecode(string value, out byte[] bytes)
{
bytes = Array.Empty<byte>();
try
{
string base64 = value
.Replace('-', '+')
.Replace('_', '/');
int padding = base64.Length % 4;
if (padding == 2)
base64 += "==";
else if (padding == 3)
base64 += "=";
else if (padding == 1)
return false;
bytes = Convert.FromBase64String(base64);
return true;
}
catch (FormatException)
{
return false;
}
}
private sealed class PhotonTicketPayload
{
public long PlayerId { get; set; }
public long? RoomInstanceId { get; set; }
public long? RoomId { get; set; }
public string? DisplayName { get; set; }
public long IssuedAtUnix { get; set; }
public long ExpiresAtUnix { get; set; }
public string? Nonce { get; set; }
}
}
public sealed class PhotonTicket
{
public long PlayerId { get; init; }
public long? RoomInstanceId { get; init; }
public long? RoomId { get; init; }
public string DisplayName { get; init; } = string.Empty;
public DateTimeOffset IssuedAt { get; init; }
public DateTimeOffset ExpiresAt { get; init; }
}
}