From 6a426efde9c266ecf067b589739b2faf3a43ac62 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Wed, 28 May 2025 23:21:32 +0800 Subject: [PATCH] :boom: The newly crafted Dyson Token --- DysonNetwork.Sphere/Account/AccountService.cs | 2 +- DysonNetwork.Sphere/Auth/Auth.cs | 215 +++++++++++++++--- DysonNetwork.Sphere/Auth/AuthController.cs | 30 +-- DysonNetwork.Sphere/Auth/AuthService.cs | 124 ++++++---- .../Auth/CompactTokenService.cs | 94 ++++++++ .../Auth/UserInfoMiddleware.cs | 40 ---- DysonNetwork.Sphere/Program.cs | 2 +- DysonNetwork.sln.DotSettings.user | 1 + 8 files changed, 363 insertions(+), 145 deletions(-) create mode 100644 DysonNetwork.Sphere/Auth/CompactTokenService.cs delete mode 100644 DysonNetwork.Sphere/Auth/UserInfoMiddleware.cs diff --git a/DysonNetwork.Sphere/Account/AccountService.cs b/DysonNetwork.Sphere/Account/AccountService.cs index cc76571..4b75b8b 100644 --- a/DysonNetwork.Sphere/Account/AccountService.cs +++ b/DysonNetwork.Sphere/Account/AccountService.cs @@ -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) { diff --git a/DysonNetwork.Sphere/Auth/Auth.cs b/DysonNetwork.Sphere/Auth/Auth.cs index e853f8e..3e285e0 100644 --- a/DysonNetwork.Sphere/Auth/Auth.cs +++ b/DysonNetwork.Sphere/Auth/Auth.cs @@ -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 +public class DysonTokenAuthHandler( + IOptionsMonitor options, + IConfiguration configuration, + ILoggerFactory logger, + UrlEncoder encoder, + AppDatabase database, + ICacheService cache +) + : AuthenticationHandler(options, logger, encoder) { - private TokenValidationParameters _tokenValidationParameters; + private const string AuthCachePrefix = "auth:"; - public DysonTokenAuthHandler( - IOptionsMonitor 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 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($"{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 + { + 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> ExchangeToken([FromBody] TokenExchangeRequest request) + public async Task> 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."); } diff --git a/DysonNetwork.Sphere/Auth/AuthService.cs b/DysonNetwork.Sphere/Auth/AuthService.cs index 76601c5..3d5eb0d 100644 --- a/DysonNetwork.Sphere/Auth/AuthService.cs +++ b/DysonNetwork.Sphere/Auth/AuthService.cs @@ -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 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 + 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); } } \ No newline at end of file diff --git a/DysonNetwork.Sphere/Auth/CompactTokenService.cs b/DysonNetwork.Sphere/Auth/CompactTokenService.cs new file mode 100644 index 0000000..8e3d735 --- /dev/null +++ b/DysonNetwork.Sphere/Auth/CompactTokenService.cs @@ -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); + } +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Auth/UserInfoMiddleware.cs b/DysonNetwork.Sphere/Auth/UserInfoMiddleware.cs deleted file mode 100644 index ea6a552..0000000 --- a/DysonNetwork.Sphere/Auth/UserInfoMiddleware.cs +++ /dev/null @@ -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($"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); - } -} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Program.cs b/DysonNetwork.Sphere/Program.cs index 9854f8f..596fbac 100644 --- a/DysonNetwork.Sphere/Program.cs +++ b/DysonNetwork.Sphere/Program.cs @@ -171,6 +171,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); // Services +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.Configure(builder.Configuration.GetSection("GeoIP")); builder.Services.AddScoped(); @@ -274,7 +275,6 @@ app.UseWebSockets(); app.UseRateLimiter(); app.UseHttpsRedirection(); app.UseAuthorization(); -app.UseMiddleware(); app.UseMiddleware(); app.MapControllers().RequireRateLimiting("fixed"); diff --git a/DysonNetwork.sln.DotSettings.user b/DysonNetwork.sln.DotSettings.user index e07bd88..dcf78d8 100644 --- a/DysonNetwork.sln.DotSettings.user +++ b/DysonNetwork.sln.DotSettings.user @@ -62,6 +62,7 @@ ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded