♻️ Optimized auth service
This commit is contained in:
		@@ -22,7 +22,8 @@ public class AuthController(
 | 
			
		||||
    ActionLogService als,
 | 
			
		||||
    RingService.RingServiceClient pusher,
 | 
			
		||||
    IConfiguration configuration,
 | 
			
		||||
    IStringLocalizer<NotificationResource> localizer
 | 
			
		||||
    IStringLocalizer<NotificationResource> localizer,
 | 
			
		||||
    ILogger<AuthController> logger
 | 
			
		||||
) : ControllerBase
 | 
			
		||||
{
 | 
			
		||||
    private readonly string _cookieDomain = configuration["AuthToken:CookieDomain"]!;
 | 
			
		||||
@@ -111,9 +112,14 @@ public class AuthController(
 | 
			
		||||
            .ThenInclude(e => e.Profile)
 | 
			
		||||
            .FirstOrDefaultAsync(e => e.Id == id);
 | 
			
		||||
 | 
			
		||||
        return challenge is null
 | 
			
		||||
            ? NotFound("Auth challenge was not found.")
 | 
			
		||||
            : challenge;
 | 
			
		||||
        if (challenge is null)
 | 
			
		||||
        {
 | 
			
		||||
            logger.LogWarning("GetChallenge: challenge not found (challengeId={ChallengeId}, ip={IpAddress})",
 | 
			
		||||
                id, HttpContext.Connection.RemoteIpAddress?.ToString());
 | 
			
		||||
            return NotFound("Auth challenge was not found.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return challenge;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    [HttpGet("challenge/{id:guid}/factors")]
 | 
			
		||||
@@ -212,7 +218,7 @@ public class AuthController(
 | 
			
		||||
                throw new ArgumentException("Invalid password.");
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        catch
 | 
			
		||||
        catch (Exception ex)
 | 
			
		||||
        {
 | 
			
		||||
            challenge.FailedAttempts++;
 | 
			
		||||
            db.Update(challenge);
 | 
			
		||||
@@ -224,6 +230,10 @@ public class AuthController(
 | 
			
		||||
                }, Request, challenge.Account
 | 
			
		||||
            );
 | 
			
		||||
            await db.SaveChangesAsync();
 | 
			
		||||
 | 
			
		||||
            logger.LogWarning("DoChallenge: authentication failure (challengeId={ChallengeId}, factorId={FactorId}, accountId={AccountId}, failedAttempts={FailedAttempts}, factorType={FactorType}, ip={IpAddress}, uaLength={UaLength})",
 | 
			
		||||
                challenge.Id, factor.Id, challenge.AccountId, challenge.FailedAttempts, factor.Type, HttpContext.Connection.RemoteIpAddress?.ToString(), (HttpContext.Request.Headers.UserAgent.ToString() ?? "").Length);
 | 
			
		||||
 | 
			
		||||
            return BadRequest("Invalid password.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
@@ -317,4 +327,4 @@ public class AuthController(
 | 
			
		||||
        });
 | 
			
		||||
        return Ok();
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -29,47 +29,152 @@ public class AuthService(
 | 
			
		||||
    public async Task<int> DetectChallengeRisk(HttpRequest request, SnAccount account)
 | 
			
		||||
    {
 | 
			
		||||
        // 1) Find out how many authentication factors the account has enabled.
 | 
			
		||||
        var maxSteps = await db.AccountAuthFactors
 | 
			
		||||
        var enabledFactors = await db.AccountAuthFactors
 | 
			
		||||
            .Where(f => f.AccountId == account.Id)
 | 
			
		||||
            .Where(f => f.EnabledAt != null)
 | 
			
		||||
            .CountAsync();
 | 
			
		||||
            .ToListAsync();
 | 
			
		||||
        var maxSteps = enabledFactors.Count;
 | 
			
		||||
 | 
			
		||||
        // We’ll accumulate a “risk score” based on various factors.
 | 
			
		||||
        // We'll accumulate a "risk score" based on various factors.
 | 
			
		||||
        // Then we can decide how many total steps are required for the challenge.
 | 
			
		||||
        var riskScore = 0;
 | 
			
		||||
        var riskScore = 0.0;
 | 
			
		||||
 | 
			
		||||
        // 2) Get the remote IP address from the request (if any).
 | 
			
		||||
        var ipAddress = request.HttpContext.Connection.RemoteIpAddress?.ToString();
 | 
			
		||||
        var lastActiveInfo = await db.AuthSessions
 | 
			
		||||
            .OrderByDescending(s => s.LastGrantedAt)
 | 
			
		||||
        // 2) Get login context from recent sessions
 | 
			
		||||
        var recentSessions = await db.AuthSessions
 | 
			
		||||
            .Include(s => s.Challenge)
 | 
			
		||||
            .Where(s => s.AccountId == account.Id)
 | 
			
		||||
            .FirstOrDefaultAsync();
 | 
			
		||||
            .Where(s => s.LastGrantedAt != null)
 | 
			
		||||
            .OrderByDescending(s => s.LastGrantedAt)
 | 
			
		||||
            .Take(10)
 | 
			
		||||
            .ToListAsync();
 | 
			
		||||
 | 
			
		||||
        // Example check: if IP is missing or in an unusual range, increase the risk.
 | 
			
		||||
        // (This is just a placeholder; in reality, you’d integrate with GeoIpService or a custom check.)
 | 
			
		||||
        var ipAddress = request.HttpContext.Connection.RemoteIpAddress?.ToString();
 | 
			
		||||
        var userAgent = request.Headers.UserAgent.ToString();
 | 
			
		||||
 | 
			
		||||
        // 3) IP Address Risk Assessment
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(ipAddress))
 | 
			
		||||
            riskScore += 1;
 | 
			
		||||
        {
 | 
			
		||||
            riskScore += 10;
 | 
			
		||||
        }
 | 
			
		||||
        else
 | 
			
		||||
        {
 | 
			
		||||
            if (!string.IsNullOrEmpty(lastActiveInfo?.Challenge?.IpAddress) &&
 | 
			
		||||
                !lastActiveInfo.Challenge.IpAddress.Equals(ipAddress, StringComparison.OrdinalIgnoreCase))
 | 
			
		||||
                riskScore += 1;
 | 
			
		||||
            // Check if IP has been used before
 | 
			
		||||
            var ipPreviouslyUsed = recentSessions.Any(s => s.Challenge?.IpAddress == ipAddress);
 | 
			
		||||
            if (!ipPreviouslyUsed)
 | 
			
		||||
            {
 | 
			
		||||
                riskScore += 8;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Check geographical distance for last known location
 | 
			
		||||
            var lastKnownIp = recentSessions.FirstOrDefault(s => !string.IsNullOrWhiteSpace(s.Challenge?.IpAddress))?.Challenge?.IpAddress;
 | 
			
		||||
            if (!string.IsNullOrWhiteSpace(lastKnownIp) && lastKnownIp != ipAddress)
 | 
			
		||||
            {
 | 
			
		||||
                riskScore += 6;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // 3) (Optional) Check how recent the last login was.
 | 
			
		||||
        // If it was a long time ago, the risk might be higher.
 | 
			
		||||
        var now = SystemClock.Instance.GetCurrentInstant();
 | 
			
		||||
        var daysSinceLastActive = lastActiveInfo?.LastGrantedAt is not null
 | 
			
		||||
            ? (now - lastActiveInfo.LastGrantedAt.Value).TotalDays
 | 
			
		||||
            : double.MaxValue;
 | 
			
		||||
        if (daysSinceLastActive > 30)
 | 
			
		||||
            riskScore += 1;
 | 
			
		||||
        // 4) User Agent Analysis
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(userAgent))
 | 
			
		||||
        {
 | 
			
		||||
            riskScore += 5;
 | 
			
		||||
        }
 | 
			
		||||
        else
 | 
			
		||||
        {
 | 
			
		||||
            var uaPreviouslyUsed = recentSessions.Any(s =>
 | 
			
		||||
                !string.IsNullOrWhiteSpace(s.Challenge?.UserAgent) &&
 | 
			
		||||
                string.Equals(s.Challenge.UserAgent, userAgent, StringComparison.OrdinalIgnoreCase));
 | 
			
		||||
 | 
			
		||||
        // 4) Combine base “maxSteps” (the number of enabled factors) with any accumulated risk score.
 | 
			
		||||
        const int totalRiskScore = 3;
 | 
			
		||||
        var totalRequiredSteps = (int)Math.Round((float)maxSteps * riskScore / totalRiskScore);
 | 
			
		||||
        // Clamp the steps
 | 
			
		||||
            if (!uaPreviouslyUsed)
 | 
			
		||||
            {
 | 
			
		||||
                riskScore += 4;
 | 
			
		||||
 | 
			
		||||
                // Check for suspicious user agent patterns
 | 
			
		||||
                if (userAgent.Contains("bot", StringComparison.OrdinalIgnoreCase) ||
 | 
			
		||||
                    userAgent.Contains("crawler", StringComparison.OrdinalIgnoreCase) ||
 | 
			
		||||
                    userAgent.Contains("spider", StringComparison.OrdinalIgnoreCase))
 | 
			
		||||
                {
 | 
			
		||||
                    riskScore += 8;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // 5) Time-based Risk Assessment
 | 
			
		||||
        var now = SystemClock.Instance.GetCurrentInstant();
 | 
			
		||||
        var lastLogin = recentSessions.FirstOrDefault()?.LastGrantedAt;
 | 
			
		||||
 | 
			
		||||
        if (lastLogin.HasValue)
 | 
			
		||||
        {
 | 
			
		||||
            var hoursSinceLastLogin = (now - lastLogin.Value).TotalHours;
 | 
			
		||||
 | 
			
		||||
            // Very high risk: long time since last activity
 | 
			
		||||
            if (hoursSinceLastLogin > 720) // 30 days
 | 
			
		||||
            {
 | 
			
		||||
                riskScore += 9;
 | 
			
		||||
            }
 | 
			
		||||
            else if (hoursSinceLastLogin > 168) // 7 days
 | 
			
		||||
            {
 | 
			
		||||
                riskScore += 6;
 | 
			
		||||
            }
 | 
			
		||||
            else if (hoursSinceLastLogin > 24) // 24 hours
 | 
			
		||||
            {
 | 
			
		||||
                riskScore += 3;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        else
 | 
			
		||||
        {
 | 
			
		||||
            // First login ever for this account
 | 
			
		||||
            riskScore += 7;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // 6) Failed Login Attempts Assessment
 | 
			
		||||
        var recentFailedChallenges = await db.AuthChallenges
 | 
			
		||||
            .Where(c => c.AccountId == account.Id)
 | 
			
		||||
            .Where(c => c.CreatedAt > now.Minus(Duration.FromHours(1))) // Last hour
 | 
			
		||||
            .Where(c => c.FailedAttempts > 0)
 | 
			
		||||
            .SumAsync(c => c.FailedAttempts);
 | 
			
		||||
 | 
			
		||||
        if (recentFailedChallenges > 0)
 | 
			
		||||
        {
 | 
			
		||||
            riskScore += Math.Min(recentFailedChallenges * 2, 10);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // 7) Account Security Status
 | 
			
		||||
        var totalAuthFactors = enabledFactors.Count;
 | 
			
		||||
        var timedCodeEnabled = enabledFactors.Any(f => f.Type == AccountAuthFactorType.TimedCode); // TOTP-like
 | 
			
		||||
        var pinCodeEnabled = enabledFactors.Any(f => f.Type == AccountAuthFactorType.PinCode);
 | 
			
		||||
 | 
			
		||||
        // Bonus for strong security
 | 
			
		||||
        if (totalAuthFactors >= 2)
 | 
			
		||||
            riskScore -= 3;
 | 
			
		||||
        else if (totalAuthFactors == 1)
 | 
			
		||||
            riskScore -= 1;
 | 
			
		||||
 | 
			
		||||
        // Additional bonuses for specific factors
 | 
			
		||||
        if (timedCodeEnabled) riskScore -= 2; // TOTP-like security
 | 
			
		||||
        if (pinCodeEnabled) riskScore -= 1;
 | 
			
		||||
 | 
			
		||||
        // 8) Device Trust Assessment
 | 
			
		||||
        var trustedDeviceIds = recentSessions
 | 
			
		||||
            .Where(s => s.CreatedAt > now.Minus(Duration.FromDays(30))) // Trust devices from last 30 days
 | 
			
		||||
            .Select(s => s.Challenge?.ClientId)
 | 
			
		||||
            .Where(id => id.HasValue)
 | 
			
		||||
            .Distinct()
 | 
			
		||||
            .ToList();
 | 
			
		||||
 | 
			
		||||
        // If using a trusted device, reduce risk (this will be validated later)
 | 
			
		||||
        if (trustedDeviceIds.Any())
 | 
			
		||||
        {
 | 
			
		||||
            riskScore -= 1;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Clamp risk score between 0 and 20
 | 
			
		||||
        riskScore = Math.Max(0, Math.Min(riskScore, 20));
 | 
			
		||||
 | 
			
		||||
        // 9) Calculate required steps based on risk
 | 
			
		||||
        var riskWeight = maxSteps > 0 ? riskScore / 20.0 : 0.5; // Default 50% if no factors
 | 
			
		||||
        var totalRequiredSteps = (int)Math.Round(maxSteps * riskWeight);
 | 
			
		||||
 | 
			
		||||
        // Minimum 1 step, maximum all enabled factors
 | 
			
		||||
        totalRequiredSteps = Math.Max(Math.Min(totalRequiredSteps, maxSteps), 1);
 | 
			
		||||
 | 
			
		||||
        return totalRequiredSteps;
 | 
			
		||||
@@ -180,6 +285,72 @@ public class AuthService(
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// Immediately revoke a session by setting expiry to now and clearing from cache
 | 
			
		||||
    /// This provides immediate invalidation of tokens and sessions
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    /// <param name="sessionId">Session ID to revoke</param>
 | 
			
		||||
    /// <returns>True if session was found and revoked, false otherwise</returns>
 | 
			
		||||
    public async Task<bool> RevokeSessionAsync(Guid sessionId)
 | 
			
		||||
    {
 | 
			
		||||
        var session = await db.AuthSessions.FirstOrDefaultAsync(s => s.Id == sessionId);
 | 
			
		||||
        if (session == null)
 | 
			
		||||
        {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Set expiry to now (immediate invalidation)
 | 
			
		||||
        var now = SystemClock.Instance.GetCurrentInstant();
 | 
			
		||||
        session.ExpiredAt = now;
 | 
			
		||||
        db.AuthSessions.Update(session);
 | 
			
		||||
 | 
			
		||||
        // Clear from cache immediately
 | 
			
		||||
        var cacheKey = $"{AuthCachePrefix}{session.Id}";
 | 
			
		||||
        await cache.RemoveAsync(cacheKey);
 | 
			
		||||
 | 
			
		||||
        // Clear account-level cache groups that include this session
 | 
			
		||||
        await cache.RemoveAsync($"{AuthCachePrefix}{session.AccountId}");
 | 
			
		||||
 | 
			
		||||
        await db.SaveChangesAsync();
 | 
			
		||||
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// Revoke all sessions for an account (logout everywhere)
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    /// <param name="accountId">Account ID to revoke all sessions for</param>
 | 
			
		||||
    /// <returns>Number of sessions revoked</returns>
 | 
			
		||||
    public async Task<int> RevokeAllSessionsForAccountAsync(Guid accountId)
 | 
			
		||||
    {
 | 
			
		||||
        var sessions = await db.AuthSessions
 | 
			
		||||
            .Where(s => s.AccountId == accountId && !s.ExpiredAt.HasValue)
 | 
			
		||||
            .ToListAsync();
 | 
			
		||||
 | 
			
		||||
        if (!sessions.Any())
 | 
			
		||||
        {
 | 
			
		||||
            return 0;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var now = SystemClock.Instance.GetCurrentInstant();
 | 
			
		||||
        foreach (var session in sessions)
 | 
			
		||||
        {
 | 
			
		||||
            session.ExpiredAt = now;
 | 
			
		||||
 | 
			
		||||
            // Clear individual session cache
 | 
			
		||||
            var cacheKey = $"{AuthCachePrefix}{session.Id}";
 | 
			
		||||
            await cache.RemoveAsync(cacheKey);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Clear account-level cache
 | 
			
		||||
        await cache.RemoveAsync($"{AuthCachePrefix}{accountId}");
 | 
			
		||||
 | 
			
		||||
        db.AuthSessions.UpdateRange(sessions);
 | 
			
		||||
        await db.SaveChangesAsync();
 | 
			
		||||
 | 
			
		||||
        return sessions.Count;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public string CreateToken(SnAuthSession session)
 | 
			
		||||
    {
 | 
			
		||||
        // Load the private key for signing
 | 
			
		||||
@@ -370,6 +541,21 @@ public class AuthService(
 | 
			
		||||
        {
 | 
			
		||||
            var oldSessionId = key.SessionId;
 | 
			
		||||
 | 
			
		||||
            // Immediately expire old session and clear from cache
 | 
			
		||||
            if (oldSessionId != Guid.Empty)
 | 
			
		||||
            {
 | 
			
		||||
                var oldSession = await db.AuthSessions.FirstOrDefaultAsync(s => s.Id == oldSessionId);
 | 
			
		||||
                if (oldSession != null)
 | 
			
		||||
                {
 | 
			
		||||
                    oldSession.ExpiredAt = SystemClock.Instance.GetCurrentInstant();
 | 
			
		||||
                    db.AuthSessions.Update(oldSession);
 | 
			
		||||
 | 
			
		||||
                    // Clear old session from cache
 | 
			
		||||
                    var oldCacheKey = $"{AuthCachePrefix}{oldSessionId}";
 | 
			
		||||
                    await cache.RemoveAsync(oldCacheKey);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Create new session
 | 
			
		||||
            var newSession = new SnAuthSession
 | 
			
		||||
            {
 | 
			
		||||
@@ -386,8 +572,14 @@ public class AuthService(
 | 
			
		||||
            db.ApiKeys.Update(key);
 | 
			
		||||
            await db.SaveChangesAsync();
 | 
			
		||||
 | 
			
		||||
            // Delete old session
 | 
			
		||||
            await db.AuthSessions.Where(s => s.Id == oldSessionId).ExecuteDeleteAsync();
 | 
			
		||||
            // Delete expired old session
 | 
			
		||||
            if (oldSessionId != Guid.Empty)
 | 
			
		||||
            {
 | 
			
		||||
                await db.AuthSessions.Where(s => s.Id == oldSessionId).ExecuteDeleteAsync<SnAuthSession>();
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Clear account-level cache to ensure new API key is picked up
 | 
			
		||||
            await cache.RemoveAsync($"{AuthCachePrefix}{key.AccountId}");
 | 
			
		||||
 | 
			
		||||
            await transaction.CommitAsync();
 | 
			
		||||
            return key;
 | 
			
		||||
@@ -422,4 +614,4 @@ public class AuthService(
 | 
			
		||||
 | 
			
		||||
        return Convert.FromBase64String(padded);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,10 +1,15 @@
 | 
			
		||||
using Microsoft.AspNetCore.Mvc;
 | 
			
		||||
using Microsoft.AspNetCore.RateLimiting;
 | 
			
		||||
 | 
			
		||||
namespace DysonNetwork.Pass.Auth;
 | 
			
		||||
 | 
			
		||||
[ApiController]
 | 
			
		||||
[Route("/api/captcha")]
 | 
			
		||||
public class CaptchaController(IConfiguration configuration) : ControllerBase
 | 
			
		||||
public class CaptchaController(
 | 
			
		||||
    IConfiguration configuration,
 | 
			
		||||
    AuthService authService,
 | 
			
		||||
    ILogger<CaptchaController> logger
 | 
			
		||||
) : ControllerBase
 | 
			
		||||
{
 | 
			
		||||
    [HttpGet]
 | 
			
		||||
    public IActionResult GetConfiguration()
 | 
			
		||||
@@ -15,4 +20,43 @@ public class CaptchaController(IConfiguration configuration) : ControllerBase
 | 
			
		||||
            apiKey = configuration["Captcha:ApiKey"],
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
    [HttpPost("verify")]
 | 
			
		||||
    [EnableRateLimiting("captcha")]
 | 
			
		||||
    public async Task<IActionResult> Verify([FromBody] CaptchaVerifyRequest request)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(request.Token))
 | 
			
		||||
        {
 | 
			
		||||
            logger.LogWarning("Captcha verification failed: empty token from {IpAddress}",
 | 
			
		||||
                HttpContext.Connection.RemoteIpAddress?.ToString());
 | 
			
		||||
            return BadRequest("Token is required");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            var isValid = await authService.ValidateCaptcha(request.Token);
 | 
			
		||||
 | 
			
		||||
            if (!isValid)
 | 
			
		||||
            {
 | 
			
		||||
                logger.LogWarning("Captcha verification failed: invalid token from {IpAddress}",
 | 
			
		||||
                    HttpContext.Connection.RemoteIpAddress?.ToString());
 | 
			
		||||
                return BadRequest("Invalid captcha token");
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            logger.LogInformation("Captcha verification successful from {IpAddress}",
 | 
			
		||||
                HttpContext.Connection.RemoteIpAddress?.ToString());
 | 
			
		||||
            return Ok();
 | 
			
		||||
        }
 | 
			
		||||
        catch (Exception ex)
 | 
			
		||||
        {
 | 
			
		||||
            logger.LogError(ex, "Error during captcha verification from {IpAddress}",
 | 
			
		||||
                HttpContext.Connection.RemoteIpAddress?.ToString());
 | 
			
		||||
            return StatusCode(500, "Internal server error");
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public class CaptchaVerifyRequest
 | 
			
		||||
    {
 | 
			
		||||
        public string Token { get; set; } = string.Empty;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,95 +0,0 @@
 | 
			
		||||
using System.Security.Cryptography;
 | 
			
		||||
using DysonNetwork.Shared.Models;
 | 
			
		||||
 | 
			
		||||
namespace DysonNetwork.Pass.Auth;
 | 
			
		||||
 | 
			
		||||
public class CompactTokenService(IConfiguration config)
 | 
			
		||||
{
 | 
			
		||||
    private readonly string _privateKeyPath = config["AuthToken:PrivateKeyPath"] 
 | 
			
		||||
        ?? throw new InvalidOperationException("AuthToken:PrivateKeyPath configuration is missing");
 | 
			
		||||
    
 | 
			
		||||
    public string CreateToken(SnAuthSession 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["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;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // 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);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -78,6 +78,18 @@ public static class ServiceCollectionExtensions
 | 
			
		||||
        });
 | 
			
		||||
        services.AddRazorPages();
 | 
			
		||||
 | 
			
		||||
        // Configure rate limiting
 | 
			
		||||
        services.AddRateLimiter(options =>
 | 
			
		||||
        {
 | 
			
		||||
            options.AddFixedWindowLimiter("captcha", opt =>
 | 
			
		||||
            {
 | 
			
		||||
                opt.Window = TimeSpan.FromMinutes(1);
 | 
			
		||||
                opt.PermitLimit = 5; // 5 attempts per minute
 | 
			
		||||
                opt.QueueProcessingOrder = System.Threading.RateLimiting.QueueProcessingOrder.OldestFirst;
 | 
			
		||||
                opt.QueueLimit = 2;
 | 
			
		||||
            });
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        services.Configure<RequestLocalizationOptions>(options =>
 | 
			
		||||
        {
 | 
			
		||||
            var supportedCultures = new[]
 | 
			
		||||
@@ -118,7 +130,6 @@ public static class ServiceCollectionExtensions
 | 
			
		||||
    public static IServiceCollection AddAppBusinessServices(this IServiceCollection services,
 | 
			
		||||
        IConfiguration configuration)
 | 
			
		||||
    {
 | 
			
		||||
        services.AddScoped<CompactTokenService>();
 | 
			
		||||
        services.AddScoped<RazorViewRenderer>();
 | 
			
		||||
        services.Configure<GeoIpOptions>(configuration.GetSection("GeoIP"));
 | 
			
		||||
        services.AddScoped<GeoIpService>();
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user