♻️ Optimized auth service

This commit is contained in:
2025-11-02 14:26:07 +08:00
parent a377ca2072
commit 5445df3b61
5 changed files with 296 additions and 134 deletions

View File

@@ -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();
}
}
}

View File

@@ -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;
// Well 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, youd 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);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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>();