💥 The newly crafted Dyson Token

This commit is contained in:
LittleSheep 2025-05-28 23:21:32 +08:00
parent 7e309bb5c7
commit 6a426efde9
8 changed files with 363 additions and 145 deletions

View File

@ -11,7 +11,7 @@ public class AccountService(
ICacheService cache
)
{
public const string AccountCachePrefix = "Account_";
public const string AccountCachePrefix = "account:";
public async Task PurgeAccountCache(Account account)
{

View File

@ -1,9 +1,12 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text.Encodings.Web;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Storage;
using Microsoft.AspNetCore.Authentication;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using SystemClock = NodaTime.SystemClock;
namespace DysonNetwork.Sphere.Auth;
@ -13,44 +16,104 @@ public static class AuthConstants
public const string TokenQueryParamName = "tk";
}
public enum TokenType
{
AuthKey,
ApiKey,
Unknown
}
public class TokenInfo
{
public string Token { get; set; } = string.Empty;
public TokenType Type { get; set; } = TokenType.Unknown;
}
public class DysonTokenAuthOptions : AuthenticationSchemeOptions;
public class DysonTokenAuthHandler : AuthenticationHandler<DysonTokenAuthOptions>
public class DysonTokenAuthHandler(
IOptionsMonitor<DysonTokenAuthOptions> options,
IConfiguration configuration,
ILoggerFactory logger,
UrlEncoder encoder,
AppDatabase database,
ICacheService cache
)
: AuthenticationHandler<DysonTokenAuthOptions>(options, logger, encoder)
{
private TokenValidationParameters _tokenValidationParameters;
private const string AuthCachePrefix = "auth:";
public DysonTokenAuthHandler(
IOptionsMonitor<DysonTokenAuthOptions> options,
IConfiguration configuration,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder)
{
var publicKey = File.ReadAllText(configuration["Jwt:PublicKeyPath"]!);
var rsa = RSA.Create();
rsa.ImportFromPem(publicKey);
_tokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = false,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = "solar-network",
IssuerSigningKey = new RsaSecurityKey(rsa)
};
}
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
var token = _ExtractToken(Request);
var tokenInfo = _ExtractToken(Request);
if (string.IsNullOrEmpty(token))
if (tokenInfo == null || string.IsNullOrEmpty(tokenInfo.Token))
return AuthenticateResult.Fail("No token was provided.");
try
{
var tokenHandler = new JwtSecurityTokenHandler();
var principal = tokenHandler.ValidateToken(token, _tokenValidationParameters, out var validatedToken);
var now = SystemClock.Instance.GetCurrentInstant();
// Validate token and extract session ID
if (!ValidateToken(tokenInfo.Token, out var sessionId))
return AuthenticateResult.Fail("Invalid token.");
// Try to get session from cache first
var session = await cache.GetAsync<Session>($"{AuthCachePrefix}{sessionId}");
// If not in cache, load from database
if (session is null)
{
session = await database.AuthSessions
.Where(e => e.Id == sessionId)
.Include(e => e.Challenge)
.Include(e => e.Account)
.ThenInclude(e => e.Profile)
.FirstOrDefaultAsync();
if (session is not null)
{
// Store in cache for future requests
await cache.SetWithGroupsAsync(
$"Auth_{sessionId}",
session,
[$"{AccountService.AccountCachePrefix}{session.Account.Id}"],
TimeSpan.FromHours(1)
);
}
}
// Check if the session exists
if (session == null)
return AuthenticateResult.Fail("Session not found.");
// Check if the session is expired
if (session.ExpiredAt.HasValue && session.ExpiredAt.Value < now)
return AuthenticateResult.Fail("Session expired.");
// Store user and session in the HttpContext.Items for easy access in controllers
Context.Items["CurrentUser"] = session.Account;
Context.Items["CurrentSession"] = session;
Context.Items["CurrentTokenType"] = tokenInfo.Type.ToString();
// Create claims from the session
var claims = new List<Claim>
{
new("user_id", session.Account.Id.ToString()),
new("session_id", session.Id.ToString()),
new("token_type", tokenInfo.Type.ToString())
};
// Add scopes as claims
session.Challenge.Scopes.ForEach(scope => claims.Add(new Claim("scope", scope)));
// Add superuser claim if applicable
if (session.Account.IsSuperuser)
claims.Add(new Claim("is_superuser", "1"));
// Create the identity and principal
var identity = new ClaimsIdentity(claims, AuthConstants.SchemeName);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, AuthConstants.SchemeName);
return AuthenticateResult.Success(ticket);
@ -61,17 +124,97 @@ public class DysonTokenAuthHandler : AuthenticationHandler<DysonTokenAuthOptions
}
}
private static string? _ExtractToken(HttpRequest request)
private bool ValidateToken(string token, out Guid sessionId)
{
sessionId = Guid.Empty;
try
{
// Split the token
var parts = token.Split('.');
if (parts.Length != 2)
return false;
// Decode the payload
var payloadBytes = Base64UrlDecode(parts[0]);
// Extract session ID
sessionId = new Guid(payloadBytes);
// Load public key for verification
var publicKeyPem = File.ReadAllText(configuration["Jwt:PublicKeyPath"]!);
using var rsa = RSA.Create();
rsa.ImportFromPem(publicKeyPem);
// Verify signature
var signature = Base64UrlDecode(parts[1]);
return rsa.VerifyData(payloadBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
}
catch
{
return false;
}
}
private static byte[] Base64UrlDecode(string base64Url)
{
string padded = base64Url
.Replace('-', '+')
.Replace('_', '/');
switch (padded.Length % 4)
{
case 2: padded += "=="; break;
case 3: padded += "="; break;
}
return Convert.FromBase64String(padded);
}
private static TokenInfo? _ExtractToken(HttpRequest request)
{
// Check for token in query parameters
if (request.Query.TryGetValue(AuthConstants.TokenQueryParamName, out var queryToken))
return queryToken;
{
return new TokenInfo
{
Token = queryToken,
Type = TokenType.AuthKey
};
}
// Check for token in Authorization header
var authHeader = request.Headers.Authorization.ToString();
if (!string.IsNullOrEmpty(authHeader) && authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return authHeader["Bearer ".Length..].Trim();
if (!string.IsNullOrEmpty(authHeader))
{
if (authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
return new TokenInfo
{
Token = authHeader["Bearer ".Length..].Trim(),
Type = TokenType.AuthKey
};
}
else if (authHeader.StartsWith("ApiKey ", StringComparison.OrdinalIgnoreCase))
{
return new TokenInfo
{
Token = authHeader["ApiKey ".Length..].Trim(),
Type = TokenType.ApiKey
};
}
}
return request.Cookies.TryGetValue(AuthConstants.TokenQueryParamName, out var cookieToken)
? cookieToken
: null;
// Check for token in cookies
if (request.Cookies.TryGetValue(AuthConstants.TokenQueryParamName, out var cookieToken))
{
return new TokenInfo
{
Token = cookieToken,
Type = TokenType.AuthKey
};
}
return null;
}
}

View File

@ -182,8 +182,13 @@ public class AuthController(
public string? Code { get; set; }
}
public class TokenExchangeResponse
{
public string Token { get; set; } = string.Empty;
}
[HttpPost("token")]
public async Task<ActionResult<SignedTokenPair>> ExchangeToken([FromBody] TokenExchangeRequest request)
public async Task<ActionResult<TokenExchangeResponse>> ExchangeToken([FromBody] TokenExchangeRequest request)
{
Session? session;
switch (request.GrantType)
@ -218,26 +223,11 @@ public class AuthController(
db.AuthSessions.Add(session);
await db.SaveChangesAsync();
return auth.CreateToken(session);
var tk = auth.CreateToken(session);
return Ok(new TokenExchangeResponse { Token = tk });
case "refresh_token":
var handler = new JwtSecurityTokenHandler();
var token = handler.ReadJwtToken(request.RefreshToken);
var sessionIdClaim = token.Claims.FirstOrDefault(c => c.Type == "session_id")?.Value;
if (!Guid.TryParse(sessionIdClaim, out var sessionId))
return Unauthorized("Invalid or missing session_id claim in refresh token.");
session = await db.AuthSessions
.Include(e => e.Account)
.Include(e => e.Challenge)
.FirstOrDefaultAsync(s => s.Id == sessionId);
if (session is null)
return NotFound("Session not found or expired.");
session.LastGrantedAt = Instant.FromDateTimeUtc(DateTime.UtcNow);
await db.SaveChangesAsync();
return auth.CreateToken(session);
// Since we no longer need the refresh token
// This case is blank for now, thinking to mock it if the OIDC standard requires it
default:
return BadRequest("Unsupported grant type.");
}

View File

@ -1,19 +1,8 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.IdentityModel.Tokens;
using NodaTime;
namespace DysonNetwork.Sphere.Auth;
public class SignedTokenPair
{
public string AccessToken { get; set; } = null!;
public string RefreshToken { get; set; } = null!;
public Instant ExpiredAt { get; set; }
}
public class AuthService(IConfiguration config, IHttpClientFactory httpClientFactory)
{
public async Task<bool> ValidateCaptcha(string token)
@ -69,47 +58,88 @@ public class AuthService(IConfiguration config, IHttpClientFactory httpClientFac
}
}
public SignedTokenPair CreateToken(Session session)
public string CreateToken(Session session)
{
// Load the private key for signing
var privateKeyPem = File.ReadAllText(config["Jwt:PrivateKeyPath"]!);
var rsa = RSA.Create();
using var rsa = RSA.Create();
rsa.ImportFromPem(privateKeyPem);
var key = new RsaSecurityKey(rsa);
// Create and return a single token
return CreateCompactToken(session.Id, rsa);
}
var creds = new SigningCredentials(key, SecurityAlgorithms.RsaSha256);
var claims = new List<Claim>
private string CreateCompactToken(Guid sessionId, RSA rsa)
{
// Create the payload: just the session ID
var payloadBytes = sessionId.ToByteArray();
// Base64Url encode the payload
var payloadBase64 = Base64UrlEncode(payloadBytes);
// Sign the payload with RSA-SHA256
var signature = rsa.SignData(payloadBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
// Base64Url encode the signature
var signatureBase64 = Base64UrlEncode(signature);
// Combine payload and signature with a dot
return $"{payloadBase64}.{signatureBase64}";
}
public bool ValidateToken(string token, out Guid sessionId)
{
sessionId = Guid.Empty;
try
{
new("user_id", session.Account.Id.ToString()),
new("session_id", session.Id.ToString())
};
var refreshTokenClaims = new JwtSecurityToken(
issuer: "solar-network",
audience: string.Join(',', session.Challenge.Audiences),
claims: claims,
expires: DateTime.Now.AddDays(30),
signingCredentials: creds
);
session.Challenge.Scopes.ForEach(c => claims.Add(new Claim("scope", c)));
if (session.Account.IsSuperuser) claims.Add(new Claim("is_superuser", "1"));
var accessTokenClaims = new JwtSecurityToken(
issuer: "solar-network",
audience: string.Join(',', session.Challenge.Audiences),
claims: claims,
expires: DateTime.Now.AddMinutes(30),
signingCredentials: creds
);
var handler = new JwtSecurityTokenHandler();
var accessToken = handler.WriteToken(accessTokenClaims);
var refreshToken = handler.WriteToken(refreshTokenClaims);
return new SignedTokenPair
// Split the token
var parts = token.Split('.');
if (parts.Length != 2)
return false;
// Decode the payload
var payloadBytes = Base64UrlDecode(parts[0]);
// Extract session ID
sessionId = new Guid(payloadBytes);
// Load public key for verification
var publicKeyPem = File.ReadAllText(config["Jwt:PublicKeyPath"]!);
using var rsa = RSA.Create();
rsa.ImportFromPem(publicKeyPem);
// Verify signature
var signature = Base64UrlDecode(parts[1]);
return rsa.VerifyData(payloadBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
}
catch
{
AccessToken = accessToken,
RefreshToken = refreshToken,
ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddMinutes(30))
};
return false;
}
}
// Helper methods for Base64Url encoding/decoding
private static string Base64UrlEncode(byte[] data)
{
return Convert.ToBase64String(data)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
private static byte[] Base64UrlDecode(string base64Url)
{
string padded = base64Url
.Replace('-', '+')
.Replace('_', '/');
switch (padded.Length % 4)
{
case 2: padded += "=="; break;
case 3: padded += "="; break;
}
return Convert.FromBase64String(padded);
}
}

View File

@ -0,0 +1,94 @@
using System.Security.Cryptography;
namespace DysonNetwork.Sphere.Auth;
public class CompactTokenService(IConfiguration config)
{
private readonly string _privateKeyPath = config["Jwt:PrivateKeyPath"]
?? throw new InvalidOperationException("Jwt:PrivateKeyPath configuration is missing");
public string CreateToken(Session session)
{
// Load the private key for signing
var privateKeyPem = File.ReadAllText(_privateKeyPath);
using var rsa = RSA.Create();
rsa.ImportFromPem(privateKeyPem);
// Create and return a single token
return CreateCompactToken(session.Id, rsa);
}
private string CreateCompactToken(Guid sessionId, RSA rsa)
{
// Create the payload: just the session ID
var payloadBytes = sessionId.ToByteArray();
// Base64Url encode the payload
var payloadBase64 = Base64UrlEncode(payloadBytes);
// Sign the payload with RSA-SHA256
var signature = rsa.SignData(payloadBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
// Base64Url encode the signature
var signatureBase64 = Base64UrlEncode(signature);
// Combine payload and signature with a dot
return $"{payloadBase64}.{signatureBase64}";
}
public bool ValidateToken(string token, out Guid sessionId)
{
sessionId = Guid.Empty;
try
{
// Split the token
var parts = token.Split('.');
if (parts.Length != 2)
return false;
// Decode the payload
var payloadBytes = Base64UrlDecode(parts[0]);
// Extract session ID
sessionId = new Guid(payloadBytes);
// Load public key for verification
var publicKeyPem = File.ReadAllText(config["Jwt:PublicKeyPath"]!);
using var rsa = RSA.Create();
rsa.ImportFromPem(publicKeyPem);
// Verify signature
var signature = Base64UrlDecode(parts[1]);
return rsa.VerifyData(payloadBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
}
catch
{
return false;
}
}
// Helper methods for Base64Url encoding/decoding
private static string Base64UrlEncode(byte[] data)
{
return Convert.ToBase64String(data)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
private static byte[] Base64UrlDecode(string base64Url)
{
string padded = base64Url
.Replace('-', '+')
.Replace('_', '/');
switch (padded.Length % 4)
{
case 2: padded += "=="; break;
case 3: padded += "="; break;
}
return Convert.FromBase64String(padded);
}
}

View File

@ -1,40 +0,0 @@
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Auth;
public class UserInfoMiddleware(RequestDelegate next, ICacheService cache)
{
public async Task InvokeAsync(HttpContext context, AppDatabase db)
{
var sessionIdClaim = context.User.FindFirst("session_id")?.Value;
if (sessionIdClaim is not null && Guid.TryParse(sessionIdClaim, out var sessionId))
{
var session = await cache.GetAsync<Session>($"Auth_{sessionId}");
if (session is null)
{
session = await db.AuthSessions
.Where(e => e.Id == sessionId)
.Include(e => e.Challenge)
.Include(e => e.Account)
.ThenInclude(e => e.Profile)
.FirstOrDefaultAsync();
if (session is not null)
{
await cache.SetWithGroupsAsync($"Auth_{sessionId}", session,
[$"{AccountService.AccountCachePrefix}{session.Account.Id}"], TimeSpan.FromHours(1));
}
}
if (session is not null)
{
context.Items["CurrentUser"] = session.Account;
context.Items["CurrentSession"] = session;
}
}
await next(context);
}
}

View File

@ -171,6 +171,7 @@ builder.Services.AddScoped<IWebSocketPacketHandler, MessageReadHandler>();
builder.Services.AddScoped<IWebSocketPacketHandler, MessageTypingHandler>();
// Services
builder.Services.AddScoped<CompactTokenService>();
builder.Services.AddScoped<RazorViewRenderer>();
builder.Services.Configure<GeoIpOptions>(builder.Configuration.GetSection("GeoIP"));
builder.Services.AddScoped<GeoIpService>();
@ -274,7 +275,6 @@ app.UseWebSockets();
app.UseRateLimiter();
app.UseHttpsRedirection();
app.UseAuthorization();
app.UseMiddleware<UserInfoMiddleware>();
app.UseMiddleware<PermissionMiddleware>();
app.MapControllers().RequireRateLimiting("fixed");

View File

@ -62,6 +62,7 @@
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AQueryable_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F42d8f09d6a294d00a6f49efc989927492fe00_003F4e_003F26d1ee34_003FQueryable_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AResizeOptions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fef3339e864a448e2b1ec6fa7bbf4c6661fee00_003F48_003F0209e410_003FResizeOptions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AResourceManagerStringLocalizerFactory_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fb62f365d06c44ad695ff75960cdf97a2a800_003Fe4_003Ff6ba93b7_003FResourceManagerStringLocalizerFactory_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARSA_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fee4f989f6b8042b59b2654fdc188e287243600_003F8b_003F44e5f855_003FRSA_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASafeHandle_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fb6f0571a6bc744b0b551fd4578292582e54c00_003F66_003Fde27c365_003FSafeHandle_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASecuritySchemeType_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003F29898ce74e3763a786ac1bd9a6db2152e1af75769440b1e53b9cbdf1dda1bd99_003FSecuritySchemeType_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AServiceCollectionContainerBuilderExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fc0e30e11d8f5456cb7a11b21ebee6c5a35c00_003F60_003F78b485f5_003FServiceCollectionContainerBuilderExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>