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

488 lines
14 KiB
C#

using Microsoft.AspNetCore.Http;
using Microsoft.IdentityModel.Tokens;
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.IO;
using System.Linq;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Mocha2023.Classes.DBs;
using static Mocha2023.Classes.DBs.DBClasses.PlayerDBClasses;
using JsonSerializer = System.Text.Json.JsonSerializer;
namespace Mocha2023.Auth
{
public static class AuthStuff
{
private const string Issuer = "mocha";
private static readonly string Secret = LoadSecret();
private static readonly string[] ExtraTokenHeaders =
{
"X-Access-Token",
"X-Auth-Token",
"X-RecNet-Token",
"X-Authorization"
};
private static readonly string[] TokenCookies =
{
"recnet_session",
"access_token",
"auth_token"
};
private static readonly string[] TokenQueryParameters =
{
"access_token",
"token"
};
private static string LoadSecret()
{
string? configured =
Program.LoadLocalSetting("JWT_SECRET");
if (!string.IsNullOrWhiteSpace(configured) &&
Encoding.UTF8.GetByteCount(configured) >= 32)
{
return configured;
}
string authDirectory =
Path.Combine(Program.dataDir, "Auth");
string secretPath =
Path.Combine(authDirectory, "jwt-secret");
Directory.CreateDirectory(authDirectory);
if (File.Exists(secretPath))
{
string persisted =
File.ReadAllText(secretPath).Trim();
if (Encoding.UTF8.GetByteCount(persisted) >= 32)
{
RestrictSecretPermissions(secretPath);
return persisted;
}
}
string generated = Convert.ToBase64String(
RandomNumberGenerator.GetBytes(64));
File.WriteAllText(secretPath, generated);
RestrictSecretPermissions(secretPath);
return generated;
}
private static void RestrictSecretPermissions(string secretPath)
{
if (OperatingSystem.IsWindows())
return;
try
{
File.SetUnixFileMode(
secretPath,
UnixFileMode.UserRead | UnixFileMode.UserWrite);
}
catch (Exception exception)
{
Console.WriteLine(
$"[AUTH SECURITY] Could not restrict JWT secret permissions: " +
exception.Message);
}
}
public static string Encode(long accountId)
{
if (accountId <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(accountId),
"The account ID must be positive.");
}
var key = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(Secret));
var credentials = new SigningCredentials(
key,
SecurityAlgorithms.HmacSha256);
var player =
PlayerDB.Players.FindById(accountId);
List<string> roles =
player?.PlayerRoles?
.Select(role =>
role.ToString().ToLowerInvariant())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList()
?? new List<string>();
DateTime now = DateTime.UtcNow;
var claims = new List<Claim>
{
new(
JwtRegisteredClaimNames.Sub,
accountId.ToString()),
new(
"account_id",
accountId.ToString(),
ClaimValueTypes.Integer64),
new(
"role",
JsonSerializer.Serialize(roles),
JsonClaimValueTypes.JsonArray),
new(
JwtRegisteredClaimNames.Jti,
Guid.NewGuid().ToString("N")),
new(
JwtRegisteredClaimNames.Iat,
new DateTimeOffset(now)
.ToUnixTimeSeconds()
.ToString(),
ClaimValueTypes.Integer64)
};
var token = new JwtSecurityToken(
issuer: Issuer,
audience: null,
claims: claims,
notBefore: now.AddMinutes(-2),
expires: now.AddDays(30),
signingCredentials: credentials);
return new JwtSecurityTokenHandler()
.WriteToken(token);
}
public static long? GetPlayerId(HttpRequest request)
{
if (!TryGetAccessToken(
request,
out string token,
out string tokenSource))
{
return null;
}
try
{
var handler = new JwtSecurityTokenHandler
{
MapInboundClaims = false
};
var key = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(Secret));
ClaimsPrincipal principal =
handler.ValidateToken(
token,
new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = key,
ValidateIssuer = true,
ValidIssuer = Issuer,
ValidateAudience = false,
ValidateLifetime = true,
RequireExpirationTime = true,
RequireSignedTokens = true,
ValidAlgorithms = new[]
{
SecurityAlgorithms.HmacSha256
},
ClockSkew = TimeSpan.FromMinutes(2),
NameClaimType =
JwtRegisteredClaimNames.Sub,
RoleClaimType = "role"
},
out SecurityToken validatedToken);
if (validatedToken is not JwtSecurityToken jwt ||
!string.Equals(
jwt.Header.Alg,
SecurityAlgorithms.HmacSha256,
StringComparison.Ordinal))
{
return null;
}
string? accountIdText =
principal.FindFirst(
JwtRegisteredClaimNames.Sub)?.Value
?? principal.FindFirst(
ClaimTypes.NameIdentifier)?.Value
?? principal.FindFirst(
"account_id")?.Value
?? jwt.Claims.FirstOrDefault(
claim =>
claim.Type ==
JwtRegisteredClaimNames.Sub)?.Value;
if (!long.TryParse(
accountIdText,
out long accountId) ||
accountId <= 0)
{
Console.WriteLine(
$"[AUTH JWT REJECTED] " +
$"source={tokenSource} missing_account_id");
return null;
}
if (PlayerDB.Players.FindById(accountId)?.Player == null)
{
Console.WriteLine(
$"[AUTH JWT REJECTED] source={tokenSource} unknown_account");
return null;
}
if (RecNetDB.ModerationLocks.Exists(
moderationLock =>
moderationLock.AccountId == accountId))
{
Console.WriteLine(
$"[AUTH JWT REJECTED] source={tokenSource} account_locked");
return null;
}
return accountId;
}
catch (SecurityTokenExpiredException)
{
Console.WriteLine(
$"[AUTH JWT EXPIRED] source={tokenSource}");
return null;
}
catch (SecurityTokenException exception)
{
Console.WriteLine(
$"[AUTH JWT REJECTED] " +
$"source={tokenSource} " +
$"reason={exception.GetType().Name}");
return null;
}
catch (Exception exception)
{
Console.WriteLine(
$"[AUTH JWT ERROR] " +
$"source={tokenSource} " +
$"reason={exception.GetType().Name}");
return null;
}
}
private static bool TryGetAccessToken(
HttpRequest request,
out string token,
out string source)
{
token = string.Empty;
source = string.Empty;
foreach (string? value in
request.Headers.Authorization)
{
if (TryNormalizeToken(value, out token))
{
source = "Authorization";
return true;
}
}
foreach (string headerName in ExtraTokenHeaders)
{
if (!request.Headers.TryGetValue(
headerName,
out var values))
{
continue;
}
foreach (string? value in values)
{
if (TryNormalizeToken(value, out token))
{
source = headerName;
return true;
}
}
}
if (request.Path.StartsWithSegments(
"/recnet",
StringComparison.OrdinalIgnoreCase))
{
foreach (string cookieName in TokenCookies)
{
if (request.Cookies.TryGetValue(
cookieName,
out string? value) &&
TryNormalizeToken(value, out token))
{
source = $"cookie:{cookieName}";
return true;
}
}
}
if (request.Path.StartsWithSegments(
"/noti/hub/v1",
StringComparison.OrdinalIgnoreCase))
{
foreach (string parameter in TokenQueryParameters)
{
if (!request.Query.TryGetValue(
parameter,
out var values))
{
continue;
}
foreach (string? value in values)
{
if (TryNormalizeToken(value, out token))
{
source = $"query:{parameter}";
return true;
}
}
}
}
return false;
}
private static bool TryNormalizeToken(
string? input,
out string token)
{
token = string.Empty;
if (string.IsNullOrWhiteSpace(input))
{
return false;
}
string value = Unquote(input.Trim());
if (value.Length is < 16 or > 8192)
{
return false;
}
int whitespaceIndex =
value.IndexOfAny(new[] { ' ', '\t' });
if (whitespaceIndex > 0)
{
string scheme =
value[..whitespaceIndex].Trim();
if (scheme.Equals(
"Bearer",
StringComparison.OrdinalIgnoreCase) ||
scheme.Equals(
"Token",
StringComparison.OrdinalIgnoreCase) ||
scheme.Equals(
"JWT",
StringComparison.OrdinalIgnoreCase))
{
value =
value[(whitespaceIndex + 1)..]
.Trim();
}
}
value = Unquote(value.Trim());
if (value.Length is < 16 or > 8192)
{
return false;
}
string[] parts = value.Split('.');
if (parts.Length != 3 ||
parts.Any(string.IsNullOrWhiteSpace))
{
return false;
}
token = value;
return true;
}
private static string Unquote(string value)
{
if (value.Length < 2 ||
value[0] != '"' ||
value[^1] != '"')
{
return value;
}
try
{
return JsonSerializer.Deserialize<string>(value)?
.Trim()
?? string.Empty;
}
catch
{
return value[1..^1].Trim();
}
}
public static FullPlayer? GetCurrentPlayer(
HttpRequest request)
{
long? id = GetPlayerId(request);
if (!id.HasValue)
{
return null;
}
if (RecNetDB.ModerationLocks.Exists(
moderationLock =>
moderationLock.AccountId == id.Value))
{
return null;
}
return PlayerDB.Players.FindById(id.Value);
}
}
}