💥 The newly crafted Dyson Token
This commit is contained in:
parent
7e309bb5c7
commit
6a426efde9
@ -11,7 +11,7 @@ public class AccountService(
|
|||||||
ICacheService cache
|
ICacheService cache
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
public const string AccountCachePrefix = "Account_";
|
public const string AccountCachePrefix = "account:";
|
||||||
|
|
||||||
public async Task PurgeAccountCache(Account account)
|
public async Task PurgeAccountCache(Account account)
|
||||||
{
|
{
|
||||||
|
@ -1,9 +1,12 @@
|
|||||||
using System.IdentityModel.Tokens.Jwt;
|
using System.Security.Claims;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text.Encodings.Web;
|
using System.Text.Encodings.Web;
|
||||||
|
using DysonNetwork.Sphere.Account;
|
||||||
|
using DysonNetwork.Sphere.Storage;
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using SystemClock = NodaTime.SystemClock;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Auth;
|
namespace DysonNetwork.Sphere.Auth;
|
||||||
|
|
||||||
@ -13,44 +16,104 @@ public static class AuthConstants
|
|||||||
public const string TokenQueryParamName = "tk";
|
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 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()
|
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.");
|
return AuthenticateResult.Fail("No token was provided.");
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var tokenHandler = new JwtSecurityTokenHandler();
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
var principal = tokenHandler.ValidateToken(token, _tokenValidationParameters, out var validatedToken);
|
|
||||||
|
// 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);
|
var ticket = new AuthenticationTicket(principal, AuthConstants.SchemeName);
|
||||||
return AuthenticateResult.Success(ticket);
|
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))
|
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();
|
var authHeader = request.Headers.Authorization.ToString();
|
||||||
if (!string.IsNullOrEmpty(authHeader) && authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
if (!string.IsNullOrEmpty(authHeader))
|
||||||
return authHeader["Bearer ".Length..].Trim();
|
{
|
||||||
|
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)
|
// Check for token in cookies
|
||||||
? cookieToken
|
if (request.Cookies.TryGetValue(AuthConstants.TokenQueryParamName, out var cookieToken))
|
||||||
: null;
|
{
|
||||||
|
return new TokenInfo
|
||||||
|
{
|
||||||
|
Token = cookieToken,
|
||||||
|
Type = TokenType.AuthKey
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -182,8 +182,13 @@ public class AuthController(
|
|||||||
public string? Code { get; set; }
|
public string? Code { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class TokenExchangeResponse
|
||||||
|
{
|
||||||
|
public string Token { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("token")]
|
[HttpPost("token")]
|
||||||
public async Task<ActionResult<SignedTokenPair>> ExchangeToken([FromBody] TokenExchangeRequest request)
|
public async Task<ActionResult<TokenExchangeResponse>> ExchangeToken([FromBody] TokenExchangeRequest request)
|
||||||
{
|
{
|
||||||
Session? session;
|
Session? session;
|
||||||
switch (request.GrantType)
|
switch (request.GrantType)
|
||||||
@ -218,26 +223,11 @@ public class AuthController(
|
|||||||
db.AuthSessions.Add(session);
|
db.AuthSessions.Add(session);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
return auth.CreateToken(session);
|
var tk = auth.CreateToken(session);
|
||||||
|
return Ok(new TokenExchangeResponse { Token = tk });
|
||||||
case "refresh_token":
|
case "refresh_token":
|
||||||
var handler = new JwtSecurityTokenHandler();
|
// Since we no longer need the refresh token
|
||||||
var token = handler.ReadJwtToken(request.RefreshToken);
|
// This case is blank for now, thinking to mock it if the OIDC standard requires it
|
||||||
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);
|
|
||||||
default:
|
default:
|
||||||
return BadRequest("Unsupported grant type.");
|
return BadRequest("Unsupported grant type.");
|
||||||
}
|
}
|
||||||
|
@ -1,19 +1,8 @@
|
|||||||
using System.IdentityModel.Tokens.Jwt;
|
|
||||||
using System.Security.Claims;
|
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
|
||||||
using NodaTime;
|
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Auth;
|
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 class AuthService(IConfiguration config, IHttpClientFactory httpClientFactory)
|
||||||
{
|
{
|
||||||
public async Task<bool> ValidateCaptcha(string token)
|
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 privateKeyPem = File.ReadAllText(config["Jwt:PrivateKeyPath"]!);
|
||||||
var rsa = RSA.Create();
|
using var rsa = RSA.Create();
|
||||||
rsa.ImportFromPem(privateKeyPem);
|
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);
|
private string CreateCompactToken(Guid sessionId, RSA rsa)
|
||||||
var claims = new List<Claim>
|
{
|
||||||
|
// 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()),
|
// Split the token
|
||||||
new("session_id", session.Id.ToString())
|
var parts = token.Split('.');
|
||||||
};
|
if (parts.Length != 2)
|
||||||
|
return false;
|
||||||
var refreshTokenClaims = new JwtSecurityToken(
|
|
||||||
issuer: "solar-network",
|
// Decode the payload
|
||||||
audience: string.Join(',', session.Challenge.Audiences),
|
var payloadBytes = Base64UrlDecode(parts[0]);
|
||||||
claims: claims,
|
|
||||||
expires: DateTime.Now.AddDays(30),
|
// Extract session ID
|
||||||
signingCredentials: creds
|
sessionId = new Guid(payloadBytes);
|
||||||
);
|
|
||||||
|
// Load public key for verification
|
||||||
session.Challenge.Scopes.ForEach(c => claims.Add(new Claim("scope", c)));
|
var publicKeyPem = File.ReadAllText(config["Jwt:PublicKeyPath"]!);
|
||||||
if (session.Account.IsSuperuser) claims.Add(new Claim("is_superuser", "1"));
|
using var rsa = RSA.Create();
|
||||||
var accessTokenClaims = new JwtSecurityToken(
|
rsa.ImportFromPem(publicKeyPem);
|
||||||
issuer: "solar-network",
|
|
||||||
audience: string.Join(',', session.Challenge.Audiences),
|
// Verify signature
|
||||||
claims: claims,
|
var signature = Base64UrlDecode(parts[1]);
|
||||||
expires: DateTime.Now.AddMinutes(30),
|
return rsa.VerifyData(payloadBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||||
signingCredentials: creds
|
}
|
||||||
);
|
catch
|
||||||
|
|
||||||
var handler = new JwtSecurityTokenHandler();
|
|
||||||
var accessToken = handler.WriteToken(accessTokenClaims);
|
|
||||||
var refreshToken = handler.WriteToken(refreshTokenClaims);
|
|
||||||
|
|
||||||
return new SignedTokenPair
|
|
||||||
{
|
{
|
||||||
AccessToken = accessToken,
|
return false;
|
||||||
RefreshToken = refreshToken,
|
}
|
||||||
ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddMinutes(30))
|
}
|
||||||
};
|
|
||||||
|
// 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
94
DysonNetwork.Sphere/Auth/CompactTokenService.cs
Normal file
94
DysonNetwork.Sphere/Auth/CompactTokenService.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
@ -171,6 +171,7 @@ builder.Services.AddScoped<IWebSocketPacketHandler, MessageReadHandler>();
|
|||||||
builder.Services.AddScoped<IWebSocketPacketHandler, MessageTypingHandler>();
|
builder.Services.AddScoped<IWebSocketPacketHandler, MessageTypingHandler>();
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
|
builder.Services.AddScoped<CompactTokenService>();
|
||||||
builder.Services.AddScoped<RazorViewRenderer>();
|
builder.Services.AddScoped<RazorViewRenderer>();
|
||||||
builder.Services.Configure<GeoIpOptions>(builder.Configuration.GetSection("GeoIP"));
|
builder.Services.Configure<GeoIpOptions>(builder.Configuration.GetSection("GeoIP"));
|
||||||
builder.Services.AddScoped<GeoIpService>();
|
builder.Services.AddScoped<GeoIpService>();
|
||||||
@ -274,7 +275,6 @@ app.UseWebSockets();
|
|||||||
app.UseRateLimiter();
|
app.UseRateLimiter();
|
||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
app.UseMiddleware<UserInfoMiddleware>();
|
|
||||||
app.UseMiddleware<PermissionMiddleware>();
|
app.UseMiddleware<PermissionMiddleware>();
|
||||||
|
|
||||||
app.MapControllers().RequireRateLimiting("fixed");
|
app.MapControllers().RequireRateLimiting("fixed");
|
||||||
|
@ -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_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_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_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_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_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>
|
<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>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user