🐛 Fix circular dependecy
This commit is contained in:
@@ -35,7 +35,7 @@ public class DysonTokenAuthHandler(
|
|||||||
IOptionsMonitor<DysonTokenAuthOptions> options,
|
IOptionsMonitor<DysonTokenAuthOptions> options,
|
||||||
ILoggerFactory logger,
|
ILoggerFactory logger,
|
||||||
UrlEncoder encoder,
|
UrlEncoder encoder,
|
||||||
AuthService authService,
|
TokenAuthService token,
|
||||||
FlushBufferService fbs
|
FlushBufferService fbs
|
||||||
)
|
)
|
||||||
: AuthenticationHandler<DysonTokenAuthOptions>(options, logger, encoder)
|
: AuthenticationHandler<DysonTokenAuthOptions>(options, logger, encoder)
|
||||||
@@ -49,7 +49,7 @@ public class DysonTokenAuthHandler(
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var (valid, session, message) = await authService.AuthenticateTokenAsync(tokenInfo.Token);
|
var (valid, session, message) = await token.AuthenticateTokenAsync(tokenInfo.Token);
|
||||||
if (!valid || session is null)
|
if (!valid || session is null)
|
||||||
return AuthenticateResult.Fail(message ?? "Authentication failed.");
|
return AuthenticateResult.Fail(message ?? "Authentication failed.");
|
||||||
|
|
||||||
|
8
DysonNetwork.Pass/Auth/AuthCacheConstants.cs
Normal file
8
DysonNetwork.Pass/Auth/AuthCacheConstants.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Pass.Auth;
|
||||||
|
|
||||||
|
public static class AuthCacheConstants
|
||||||
|
{
|
||||||
|
public const string Prefix = "auth:";
|
||||||
|
}
|
@@ -1,9 +1,6 @@
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using DysonNetwork.Pass.Account;
|
using DysonNetwork.Pass.Account;
|
||||||
using DysonNetwork.Pass.Auth.OidcProvider.Services;
|
|
||||||
using DysonNetwork.Pass.Wallet;
|
|
||||||
using DysonNetwork.Shared.Cache;
|
using DysonNetwork.Shared.Cache;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
@@ -16,9 +13,7 @@ public class AuthService(
|
|||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
IHttpContextAccessor httpContextAccessor,
|
IHttpContextAccessor httpContextAccessor,
|
||||||
ICacheService cache,
|
ICacheService cache,
|
||||||
ILogger<AuthService> logger,
|
ILogger<AuthService> logger
|
||||||
OidcProviderService oidc,
|
|
||||||
SubscriptionService subscriptions
|
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
private HttpContext HttpContext => httpContextAccessor.HttpContext!;
|
private HttpContext HttpContext => httpContextAccessor.HttpContext!;
|
||||||
@@ -80,137 +75,6 @@ public class AuthService(
|
|||||||
return totalRequiredSteps;
|
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,
|
public async Task<AuthSession> CreateSessionForOidcAsync(Account.Account account, Instant time,
|
||||||
Guid? customAppId = null)
|
Guid? customAppId = null)
|
||||||
{
|
{
|
||||||
@@ -454,91 +318,6 @@ public class AuthService(
|
|||||||
return factor.VerifyPassword(pinCode);
|
return factor.VerifyPassword(pinCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool ValidateToken(string token, out Guid sessionId)
|
|
||||||
{
|
|
||||||
sessionId = Guid.Empty;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var parts = token.Split('.');
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
var publicKeyPem = File.ReadAllText(config["AuthToken:PublicKeyPath"]!);
|
|
||||||
using var rsa = RSA.Create();
|
|
||||||
rsa.ImportFromPem(publicKeyPem);
|
|
||||||
|
|
||||||
var signature = Base64UrlDecode(parts[1]);
|
|
||||||
return rsa.VerifyData(payloadBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task MigrateDeviceIdToClient()
|
|
||||||
{
|
|
||||||
logger.LogInformation("Migrating device IDs to clients...");
|
|
||||||
|
|
||||||
await using var transaction = await db.Database.BeginTransactionAsync();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var challenges = await db.AuthChallenges
|
|
||||||
.Where(c => c.DeviceId != null && c.ClientId == null)
|
|
||||||
.ToListAsync();
|
|
||||||
var clients = challenges.GroupBy(c => c.DeviceId)
|
|
||||||
.Select(c => new AuthClient
|
|
||||||
{
|
|
||||||
DeviceId = c.Key!,
|
|
||||||
AccountId = c.First().AccountId,
|
|
||||||
DeviceName = c.First().UserAgent ?? string.Empty,
|
|
||||||
Platform = ClientPlatform.Unidentified
|
|
||||||
})
|
|
||||||
.ToList();
|
|
||||||
await db.AuthClients.AddRangeAsync(clients);
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
|
|
||||||
var clientsMap = clients.ToDictionary(c => c.DeviceId, c => c.Id);
|
|
||||||
foreach (var challenge in challenges.Where(challenge => challenge.ClientId == null && challenge.DeviceId != null))
|
|
||||||
{
|
|
||||||
if (clientsMap.TryGetValue(challenge.DeviceId!, out var clientId))
|
|
||||||
challenge.ClientId = clientId;
|
|
||||||
db.AuthChallenges.Update(challenge);
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
await transaction.CommitAsync();
|
|
||||||
logger.LogInformation("Migrated {Count} device IDs to clients", challenges.Count);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
logger.LogError("Failed to migrate device IDs to clients");
|
|
||||||
await transaction.RollbackAsync();
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper methods for Base64Url encoding/decoding
|
// Helper methods for Base64Url encoding/decoding
|
||||||
private static string Base64UrlEncode(byte[] data)
|
private static string Base64UrlEncode(byte[] data)
|
||||||
{
|
{
|
||||||
|
@@ -8,10 +8,8 @@ using NodaTime;
|
|||||||
namespace DysonNetwork.Pass.Auth;
|
namespace DysonNetwork.Pass.Auth;
|
||||||
|
|
||||||
public class AuthServiceGrpc(
|
public class AuthServiceGrpc(
|
||||||
AuthService authService,
|
TokenAuthService token,
|
||||||
SubscriptionService subscriptions,
|
AuthService auth
|
||||||
ICacheService cache,
|
|
||||||
AppDatabase db
|
|
||||||
)
|
)
|
||||||
: Shared.Proto.AuthService.AuthServiceBase
|
: Shared.Proto.AuthService.AuthServiceBase
|
||||||
{
|
{
|
||||||
@@ -20,7 +18,7 @@ public class AuthServiceGrpc(
|
|||||||
ServerCallContext context
|
ServerCallContext context
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var (valid, session, message) = await authService.AuthenticateTokenAsync(request.Token);
|
var (valid, session, message) = await token.AuthenticateTokenAsync(request.Token);
|
||||||
if (!valid || session is null)
|
if (!valid || session is null)
|
||||||
return new AuthenticateResponse { Valid = false, Message = message ?? "Authentication failed." };
|
return new AuthenticateResponse { Valid = false, Message = message ?? "Authentication failed." };
|
||||||
|
|
||||||
@@ -30,13 +28,13 @@ public class AuthServiceGrpc(
|
|||||||
public override async Task<ValidateResponse> ValidatePin(ValidatePinRequest request, ServerCallContext context)
|
public override async Task<ValidateResponse> ValidatePin(ValidatePinRequest request, ServerCallContext context)
|
||||||
{
|
{
|
||||||
var accountId = Guid.Parse(request.AccountId);
|
var accountId = Guid.Parse(request.AccountId);
|
||||||
var valid = await authService.ValidatePinCode(accountId, request.Pin);
|
var valid = await auth.ValidatePinCode(accountId, request.Pin);
|
||||||
return new ValidateResponse { Valid = valid };
|
return new ValidateResponse { Valid = valid };
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task<ValidateResponse> ValidateCaptcha(ValidateCaptchaRequest request, ServerCallContext context)
|
public override async Task<ValidateResponse> ValidateCaptcha(ValidateCaptchaRequest request, ServerCallContext context)
|
||||||
{
|
{
|
||||||
var valid = await authService.ValidateCaptcha(request.Token);
|
var valid = await auth.ValidateCaptcha(request.Token);
|
||||||
return new ValidateResponse { Valid = valid };
|
return new ValidateResponse { Valid = valid };
|
||||||
}
|
}
|
||||||
}
|
}
|
206
DysonNetwork.Pass/Auth/TokenAuthService.cs
Normal file
206
DysonNetwork.Pass/Auth/TokenAuthService.cs
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using DysonNetwork.Pass.Wallet;
|
||||||
|
using DysonNetwork.Shared.Cache;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Pass.Auth;
|
||||||
|
|
||||||
|
public class TokenAuthService(
|
||||||
|
AppDatabase db,
|
||||||
|
IConfiguration config,
|
||||||
|
ICacheService cache,
|
||||||
|
ILogger<TokenAuthService> logger,
|
||||||
|
OidcProvider.Services.OidcProviderService oidc,
|
||||||
|
SubscriptionService subscriptions
|
||||||
|
)
|
||||||
|
{
|
||||||
|
/// <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 = $"{AuthCacheConstants.Prefix}{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,
|
||||||
|
[$"{AuthCacheConstants.Prefix}{session.Account.Id}"],
|
||||||
|
TimeSpan.FromHours(1)
|
||||||
|
);
|
||||||
|
logger.LogDebug("AuthenticateTokenAsync: cached session with key {CacheKey} (groups=[{GroupKey}])",
|
||||||
|
cacheKey,
|
||||||
|
$"{AuthCacheConstants.Prefix}{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 bool ValidateToken(string token, out Guid sessionId)
|
||||||
|
{
|
||||||
|
sessionId = Guid.Empty;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var parts = token.Split('.');
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
var publicKeyPem = File.ReadAllText(config["AuthToken:PublicKeyPath"]!);
|
||||||
|
using var rsa = RSA.Create();
|
||||||
|
rsa.ImportFromPem(publicKeyPem);
|
||||||
|
|
||||||
|
var signature = Base64UrlDecode(parts[1]);
|
||||||
|
return rsa.VerifyData(payloadBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
@@ -196,6 +196,7 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddScoped<RelationshipService>();
|
services.AddScoped<RelationshipService>();
|
||||||
services.AddScoped<MagicSpellService>();
|
services.AddScoped<MagicSpellService>();
|
||||||
services.AddScoped<AuthService>();
|
services.AddScoped<AuthService>();
|
||||||
|
services.AddScoped<TokenAuthService>();
|
||||||
services.AddScoped<AccountUsernameService>();
|
services.AddScoped<AccountUsernameService>();
|
||||||
services.AddScoped<WalletService>();
|
services.AddScoped<WalletService>();
|
||||||
services.AddScoped<SubscriptionService>();
|
services.AddScoped<SubscriptionService>();
|
||||||
|
Reference in New Issue
Block a user