488 lines
14 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|