🔊 Logging auth flow
This commit is contained in:
@@ -1,13 +1,8 @@
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Encodings.Web;
|
||||
using DysonNetwork.Pass.Account;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using DysonNetwork.Pass.Auth.OidcProvider.Services;
|
||||
using DysonNetwork.Pass.Handlers;
|
||||
using DysonNetwork.Pass.Wallet;
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using SystemClock = NodaTime.SystemClock;
|
||||
|
||||
@@ -38,19 +33,13 @@ public class DysonTokenAuthOptions : AuthenticationSchemeOptions;
|
||||
|
||||
public class DysonTokenAuthHandler(
|
||||
IOptionsMonitor<DysonTokenAuthOptions> options,
|
||||
IConfiguration configuration,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
AppDatabase database,
|
||||
OidcProviderService oidc,
|
||||
SubscriptionService subscriptions,
|
||||
ICacheService cache,
|
||||
AuthService authService,
|
||||
FlushBufferService fbs
|
||||
)
|
||||
: AuthenticationHandler<DysonTokenAuthOptions>(options, logger, encoder)
|
||||
{
|
||||
public const string AuthCachePrefix = "auth:";
|
||||
|
||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
var tokenInfo = _ExtractToken(Request);
|
||||
@@ -60,48 +49,9 @@ public class DysonTokenAuthHandler(
|
||||
|
||||
try
|
||||
{
|
||||
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<AuthSession>($"{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)
|
||||
.ThenInclude(e => e.Client)
|
||||
.Include(e => e.Account)
|
||||
.ThenInclude(e => e.Profile)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (session is not null)
|
||||
{
|
||||
var perk = await subscriptions.GetPerkSubscriptionAsync(session.AccountId);
|
||||
session.Account.PerkSubscription = perk?.ToReference();
|
||||
|
||||
// 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.");
|
||||
var (valid, session, message) = await authService.AuthenticateTokenAsync(tokenInfo.Token);
|
||||
if (!valid || session is null)
|
||||
return AuthenticateResult.Fail(message ?? "Authentication failed.");
|
||||
|
||||
// Store user and session in the HttpContext.Items for easy access in controllers
|
||||
Context.Items["CurrentUser"] = session.Account;
|
||||
@@ -145,77 +95,6 @@ public class DysonTokenAuthHandler(
|
||||
}
|
||||
}
|
||||
|
||||
private bool ValidateToken(string token, out Guid sessionId)
|
||||
{
|
||||
sessionId = Guid.Empty;
|
||||
|
||||
try
|
||||
{
|
||||
var parts = token.Split('.');
|
||||
|
||||
switch (parts.Length)
|
||||
{
|
||||
// Handle JWT tokens (3 parts)
|
||||
case 3:
|
||||
{
|
||||
var (isValid, jwtResult) = oidc.ValidateToken(token);
|
||||
if (!isValid) return false;
|
||||
var jti = jwtResult?.Claims.FirstOrDefault(c => c.Type == "jti")?.Value;
|
||||
if (jti is null) return false;
|
||||
|
||||
return Guid.TryParse(jti, out sessionId);
|
||||
}
|
||||
// Handle compact tokens (2 parts)
|
||||
case 2:
|
||||
// Original compact token validation logic
|
||||
try
|
||||
{
|
||||
// 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["AuthToken: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;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Token validation failed");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] Base64UrlDecode(string base64Url)
|
||||
{
|
||||
var padded = base64Url
|
||||
.Replace('-', '+')
|
||||
.Replace('_', '/');
|
||||
|
||||
switch (padded.Length % 4)
|
||||
{
|
||||
case 2: padded += "=="; break;
|
||||
case 3: padded += "="; break;
|
||||
}
|
||||
|
||||
return Convert.FromBase64String(padded);
|
||||
}
|
||||
|
||||
private TokenInfo? _ExtractToken(HttpRequest request)
|
||||
{
|
||||
// Check for token in query parameters
|
||||
|
@@ -1,6 +1,9 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using DysonNetwork.Pass.Account;
|
||||
using DysonNetwork.Pass.Auth.OidcProvider.Services;
|
||||
using DysonNetwork.Pass.Wallet;
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
@@ -13,10 +16,13 @@ public class AuthService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
ICacheService cache,
|
||||
ILogger<AuthService> logger
|
||||
ILogger<AuthService> logger,
|
||||
OidcProviderService oidc,
|
||||
SubscriptionService subscriptions
|
||||
)
|
||||
{
|
||||
private HttpContext HttpContext => httpContextAccessor.HttpContext!;
|
||||
public const string AuthCachePrefix = "auth:";
|
||||
|
||||
/// <summary>
|
||||
/// Detect the risk of the current request to login
|
||||
@@ -74,6 +80,137 @@ public class AuthService(
|
||||
return totalRequiredSteps;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Universal authenticate method: validate token (JWT or compact),
|
||||
/// load session from cache/DB, check expiry, enrich with subscription,
|
||||
/// then cache and return.
|
||||
/// </summary>
|
||||
/// <param name="token">Incoming token string</param>
|
||||
/// <returns>(Valid, Session, Message)</returns>
|
||||
public async Task<(bool Valid, AuthSession? Session, string? Message)> AuthenticateTokenAsync(string token)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
logger.LogWarning("AuthenticateTokenAsync: no token provided");
|
||||
return (false, null, "No token provided.");
|
||||
}
|
||||
|
||||
// token fingerprint for correlation
|
||||
var tokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
|
||||
var tokenFp = tokenHash[..8];
|
||||
|
||||
var partsLen = token.Split('.').Length;
|
||||
var format = partsLen switch
|
||||
{
|
||||
3 => "JWT",
|
||||
2 => "Compact",
|
||||
_ => "Unknown"
|
||||
};
|
||||
logger.LogDebug("AuthenticateTokenAsync: token format detected: {Format} (fp={TokenFp})", format, tokenFp);
|
||||
|
||||
if (!ValidateToken(token, out var sessionId))
|
||||
{
|
||||
logger.LogWarning("AuthenticateTokenAsync: token validation failed (format={Format}, fp={TokenFp})", format, tokenFp);
|
||||
return (false, null, "Invalid token.");
|
||||
}
|
||||
|
||||
logger.LogDebug("AuthenticateTokenAsync: token validated, sessionId={SessionId} (fp={TokenFp})", sessionId, tokenFp);
|
||||
|
||||
// Try cache first
|
||||
var cacheKey = $"{AuthCachePrefix}{sessionId}";
|
||||
var session = await cache.GetAsync<AuthSession>(cacheKey);
|
||||
if (session is not null)
|
||||
{
|
||||
logger.LogDebug("AuthenticateTokenAsync: cache hit for {CacheKey}", cacheKey);
|
||||
var nowHit = SystemClock.Instance.GetCurrentInstant();
|
||||
if (session.ExpiredAt.HasValue && session.ExpiredAt < nowHit)
|
||||
{
|
||||
logger.LogWarning("AuthenticateTokenAsync: cached session expired (sessionId={SessionId})", sessionId);
|
||||
return (false, null, "Session has been expired.");
|
||||
}
|
||||
logger.LogInformation(
|
||||
"AuthenticateTokenAsync: success via cache (sessionId={SessionId}, accountId={AccountId}, scopes={ScopeCount}, expiresAt={ExpiresAt})",
|
||||
sessionId,
|
||||
session.AccountId,
|
||||
session.Challenge.Scopes.Count,
|
||||
session.ExpiredAt
|
||||
);
|
||||
return (true, session, null);
|
||||
}
|
||||
|
||||
logger.LogDebug("AuthenticateTokenAsync: cache miss for {CacheKey}, loading from DB", cacheKey);
|
||||
|
||||
session = await db.AuthSessions
|
||||
.AsNoTracking()
|
||||
.Include(e => e.Challenge)
|
||||
.ThenInclude(e => e.Client)
|
||||
.Include(e => e.Account)
|
||||
.ThenInclude(e => e.Profile)
|
||||
.FirstOrDefaultAsync(s => s.Id == sessionId);
|
||||
|
||||
if (session is null)
|
||||
{
|
||||
logger.LogWarning("AuthenticateTokenAsync: session not found (sessionId={SessionId})", sessionId);
|
||||
return (false, null, "Session was not found.");
|
||||
}
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
if (session.ExpiredAt.HasValue && session.ExpiredAt < now)
|
||||
{
|
||||
logger.LogWarning("AuthenticateTokenAsync: session expired (sessionId={SessionId}, expiredAt={ExpiredAt}, now={Now})", sessionId, session.ExpiredAt, now);
|
||||
return (false, null, "Session has been expired.");
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"AuthenticateTokenAsync: DB session loaded (sessionId={SessionId}, accountId={AccountId}, clientId={ClientId}, appId={AppId}, scopes={ScopeCount}, ip={Ip}, uaLen={UaLen})",
|
||||
sessionId,
|
||||
session.AccountId,
|
||||
session.Challenge.ClientId,
|
||||
session.AppId,
|
||||
session.Challenge.Scopes.Count,
|
||||
session.Challenge.IpAddress,
|
||||
(session.Challenge.UserAgent ?? string.Empty).Length
|
||||
);
|
||||
|
||||
logger.LogDebug("AuthenticateTokenAsync: enriching account with subscription (accountId={AccountId})", session.AccountId);
|
||||
var perk = await subscriptions.GetPerkSubscriptionAsync(session.AccountId);
|
||||
session.Account.PerkSubscription = perk?.ToReference();
|
||||
logger.LogInformation(
|
||||
"AuthenticateTokenAsync: subscription attached (accountId={AccountId}, hasPerk={HasPerk}, identifier={Identifier}, status={Status}, available={Available})",
|
||||
session.AccountId,
|
||||
perk is not null,
|
||||
perk?.Identifier,
|
||||
perk?.Status,
|
||||
perk?.IsAvailable
|
||||
);
|
||||
|
||||
await cache.SetWithGroupsAsync(
|
||||
cacheKey,
|
||||
session,
|
||||
[$"{AccountService.AccountCachePrefix}{session.Account.Id}"],
|
||||
TimeSpan.FromHours(1)
|
||||
);
|
||||
logger.LogDebug("AuthenticateTokenAsync: cached session with key {CacheKey} (groups=[{GroupKey}])",
|
||||
cacheKey,
|
||||
$"{AccountService.AccountCachePrefix}{session.Account.Id}");
|
||||
|
||||
logger.LogInformation(
|
||||
"AuthenticateTokenAsync: success via DB (sessionId={SessionId}, accountId={AccountId}, clientId={ClientId})",
|
||||
sessionId,
|
||||
session.AccountId,
|
||||
session.Challenge.ClientId
|
||||
);
|
||||
return (true, session, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "AuthenticateTokenAsync: unexpected error");
|
||||
return (false, null, "Authentication error.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AuthSession> CreateSessionForOidcAsync(Account.Account account, Instant time,
|
||||
Guid? customAppId = null)
|
||||
{
|
||||
@@ -323,25 +460,35 @@ public class AuthService(
|
||||
|
||||
try
|
||||
{
|
||||
// Split the token
|
||||
var parts = token.Split('.');
|
||||
if (parts.Length != 2)
|
||||
return false;
|
||||
|
||||
// Decode the payload
|
||||
var payloadBytes = Base64UrlDecode(parts[0]);
|
||||
switch (parts.Length)
|
||||
{
|
||||
case 3:
|
||||
{
|
||||
// JWT via OIDC
|
||||
var (isValid, jwtResult) = oidc.ValidateToken(token);
|
||||
if (!isValid) return false;
|
||||
var jti = jwtResult?.Claims.FirstOrDefault(c => c.Type == "jti")?.Value;
|
||||
if (jti is null) return false;
|
||||
return Guid.TryParse(jti, out sessionId);
|
||||
}
|
||||
case 2:
|
||||
{
|
||||
// Compact token
|
||||
var payloadBytes = Base64UrlDecode(parts[0]);
|
||||
sessionId = new Guid(payloadBytes);
|
||||
|
||||
// Extract session ID
|
||||
sessionId = new Guid(payloadBytes);
|
||||
var publicKeyPem = File.ReadAllText(config["AuthToken:PublicKeyPath"]!);
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(publicKeyPem);
|
||||
|
||||
// Load public key for verification
|
||||
var publicKeyPem = File.ReadAllText(config["AuthToken: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);
|
||||
var signature = Base64UrlDecode(parts[1]);
|
||||
return rsa.VerifyData(payloadBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
@@ -20,35 +20,9 @@ public class AuthServiceGrpc(
|
||||
ServerCallContext context
|
||||
)
|
||||
{
|
||||
if (!authService.ValidateToken(request.Token, out var sessionId))
|
||||
return new AuthenticateResponse { Valid = false, Message = "Invalid token." };
|
||||
|
||||
var session = await cache.GetAsync<AuthSession>($"{DysonTokenAuthHandler.AuthCachePrefix}{sessionId}");
|
||||
if (session is not null)
|
||||
return new AuthenticateResponse { Valid = true, Session = session.ToProtoValue() };
|
||||
|
||||
session = await db.AuthSessions
|
||||
.AsNoTracking()
|
||||
.Include(e => e.Challenge)
|
||||
.ThenInclude(e => e.Client)
|
||||
.Include(e => e.Account)
|
||||
.ThenInclude(e => e.Profile)
|
||||
.FirstOrDefaultAsync(s => s.Id == sessionId);
|
||||
if (session == null)
|
||||
return new AuthenticateResponse { Valid = false, Message = "Session was not found." };
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
if (session.ExpiredAt.HasValue && session.ExpiredAt < now)
|
||||
return new AuthenticateResponse { Valid = false, Message = "Session has been expired." };
|
||||
|
||||
var perk = await subscriptions.GetPerkSubscriptionAsync(session.AccountId);
|
||||
session.Account.PerkSubscription = perk?.ToReference();
|
||||
|
||||
await cache.SetWithGroupsAsync(
|
||||
$"auth:{sessionId}",
|
||||
session,
|
||||
[$"{Account.AccountService.AccountCachePrefix}{session.Account.Id}"],
|
||||
TimeSpan.FromHours(1)
|
||||
);
|
||||
var (valid, session, message) = await authService.AuthenticateTokenAsync(request.Token);
|
||||
if (!valid || session is null)
|
||||
return new AuthenticateResponse { Valid = false, Message = message ?? "Authentication failed." };
|
||||
|
||||
return new AuthenticateResponse { Valid = true, Session = session.ToProtoValue() };
|
||||
}
|
||||
|
Reference in New Issue
Block a user