618 lines
21 KiB
C#
618 lines
21 KiB
C#
using System.Security.Cryptography;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using DysonNetwork.Shared.Cache;
|
|
using DysonNetwork.Shared.Models;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using NodaTime;
|
|
|
|
namespace DysonNetwork.Pass.Auth;
|
|
|
|
public class AuthService(
|
|
AppDatabase db,
|
|
IConfiguration config,
|
|
IHttpClientFactory httpClientFactory,
|
|
IHttpContextAccessor httpContextAccessor,
|
|
ICacheService cache
|
|
)
|
|
{
|
|
private HttpContext HttpContext => httpContextAccessor.HttpContext!;
|
|
public const string AuthCachePrefix = "auth:";
|
|
|
|
/// <summary>
|
|
/// Detect the risk of the current request to login
|
|
/// and returns the required steps to login.
|
|
/// </summary>
|
|
/// <param name="request">The request context</param>
|
|
/// <param name="account">The account to login</param>
|
|
/// <returns>The required steps to login</returns>
|
|
public async Task<int> DetectChallengeRisk(HttpRequest request, SnAccount account)
|
|
{
|
|
// 1) Find out how many authentication factors the account has enabled.
|
|
var enabledFactors = await db.AccountAuthFactors
|
|
.Where(f => f.AccountId == account.Id)
|
|
.Where(f => f.EnabledAt != null)
|
|
.ToListAsync();
|
|
var maxSteps = enabledFactors.Count;
|
|
|
|
// 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.0;
|
|
|
|
// 2) Get login context from recent sessions
|
|
var recentSessions = await db.AuthSessions
|
|
.Include(s => s.Challenge)
|
|
.Where(s => s.AccountId == account.Id)
|
|
.Where(s => s.LastGrantedAt != null)
|
|
.OrderByDescending(s => s.LastGrantedAt)
|
|
.Take(10)
|
|
.ToListAsync();
|
|
|
|
var ipAddress = request.HttpContext.Connection.RemoteIpAddress?.ToString();
|
|
var userAgent = request.Headers.UserAgent.ToString();
|
|
|
|
// 3) IP Address Risk Assessment
|
|
if (string.IsNullOrWhiteSpace(ipAddress))
|
|
{
|
|
riskScore += 10;
|
|
}
|
|
else
|
|
{
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
// 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));
|
|
|
|
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;
|
|
}
|
|
|
|
public async Task<SnAuthSession> CreateSessionForOidcAsync(SnAccount account, Instant time,
|
|
Guid? customAppId = null)
|
|
{
|
|
var challenge = new SnAuthChallenge
|
|
{
|
|
AccountId = account.Id,
|
|
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString(),
|
|
UserAgent = HttpContext.Request.Headers.UserAgent,
|
|
StepRemain = 1,
|
|
StepTotal = 1,
|
|
Type = customAppId is not null ? ChallengeType.OAuth : ChallengeType.Oidc
|
|
};
|
|
|
|
var session = new SnAuthSession
|
|
{
|
|
AccountId = account.Id,
|
|
CreatedAt = time,
|
|
LastGrantedAt = time,
|
|
Challenge = challenge,
|
|
AppId = customAppId
|
|
};
|
|
|
|
db.AuthChallenges.Add(challenge);
|
|
db.AuthSessions.Add(session);
|
|
await db.SaveChangesAsync();
|
|
|
|
return session;
|
|
}
|
|
|
|
public async Task<SnAuthClient> GetOrCreateDeviceAsync(
|
|
Guid accountId,
|
|
string deviceId,
|
|
string? deviceName = null,
|
|
ClientPlatform platform = ClientPlatform.Unidentified
|
|
)
|
|
{
|
|
var device = await db.AuthClients.FirstOrDefaultAsync(d => d.DeviceId == deviceId && d.AccountId == accountId);
|
|
if (device is not null) return device;
|
|
device = new SnAuthClient
|
|
{
|
|
Platform = platform,
|
|
DeviceId = deviceId,
|
|
AccountId = accountId
|
|
};
|
|
if (deviceName is not null) device.DeviceName = deviceName;
|
|
db.AuthClients.Add(device);
|
|
await db.SaveChangesAsync();
|
|
|
|
return device;
|
|
}
|
|
|
|
public async Task<bool> ValidateCaptcha(string token)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(token)) return false;
|
|
|
|
var provider = config.GetSection("Captcha")["Provider"]?.ToLower();
|
|
var apiSecret = config.GetSection("Captcha")["ApiSecret"];
|
|
|
|
var client = httpClientFactory.CreateClient();
|
|
|
|
var jsonOpts = new JsonSerializerOptions
|
|
{
|
|
NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals,
|
|
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
|
DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower
|
|
};
|
|
|
|
switch (provider)
|
|
{
|
|
case "cloudflare":
|
|
var content = new StringContent($"secret={apiSecret}&response={token}", System.Text.Encoding.UTF8,
|
|
"application/x-www-form-urlencoded");
|
|
var response = await client.PostAsync("https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
|
content);
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
var json = await response.Content.ReadAsStringAsync();
|
|
var result = JsonSerializer.Deserialize<CaptchaVerificationResponse>(json, options: jsonOpts);
|
|
|
|
return result?.Success == true;
|
|
case "google":
|
|
content = new StringContent($"secret={apiSecret}&response={token}", System.Text.Encoding.UTF8,
|
|
"application/x-www-form-urlencoded");
|
|
response = await client.PostAsync("https://www.google.com/recaptcha/siteverify", content);
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
json = await response.Content.ReadAsStringAsync();
|
|
result = JsonSerializer.Deserialize<CaptchaVerificationResponse>(json, options: jsonOpts);
|
|
|
|
return result?.Success == true;
|
|
case "hcaptcha":
|
|
content = new StringContent($"secret={apiSecret}&response={token}", System.Text.Encoding.UTF8,
|
|
"application/x-www-form-urlencoded");
|
|
response = await client.PostAsync("https://hcaptcha.com/siteverify", content);
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
json = await response.Content.ReadAsStringAsync();
|
|
result = JsonSerializer.Deserialize<CaptchaVerificationResponse>(json, options: jsonOpts);
|
|
|
|
return result?.Success == true;
|
|
default:
|
|
throw new ArgumentException("The server misconfigured for the captcha.");
|
|
}
|
|
}
|
|
|
|
/// <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
|
|
var privateKeyPem = File.ReadAllText(config["AuthToken:PrivateKeyPath"]!);
|
|
using var rsa = RSA.Create();
|
|
rsa.ImportFromPem(privateKeyPem);
|
|
|
|
// Create and return a single token
|
|
return CreateCompactToken(session.Id, rsa);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Create a session for a completed challenge, persist it, issue a token, and set the auth cookie.
|
|
/// Keeps behavior identical to previous controller implementation.
|
|
/// </summary>
|
|
/// <param name="challenge">Completed challenge</param>
|
|
/// <returns>Signed compact token</returns>
|
|
/// <exception cref="ArgumentException">If challenge not completed or session already exists</exception>
|
|
public async Task<string> CreateSessionAndIssueToken(SnAuthChallenge challenge)
|
|
{
|
|
if (challenge.StepRemain != 0)
|
|
throw new ArgumentException("Challenge not yet completed.");
|
|
|
|
var hasSession = await db.AuthSessions
|
|
.AnyAsync(e => e.ChallengeId == challenge.Id);
|
|
if (hasSession)
|
|
throw new ArgumentException("Session already exists for this challenge.");
|
|
|
|
var now = SystemClock.Instance.GetCurrentInstant();
|
|
var session = new SnAuthSession
|
|
{
|
|
LastGrantedAt = now,
|
|
ExpiredAt = now.Plus(Duration.FromDays(7)),
|
|
AccountId = challenge.AccountId,
|
|
ChallengeId = challenge.Id
|
|
};
|
|
|
|
db.AuthSessions.Add(session);
|
|
await db.SaveChangesAsync();
|
|
|
|
var tk = CreateToken(session);
|
|
|
|
// Set cookie using HttpContext
|
|
var cookieDomain = config["AuthToken:CookieDomain"]!;
|
|
HttpContext.Response.Cookies.Append(AuthConstants.CookieTokenName, tk, new CookieOptions
|
|
{
|
|
HttpOnly = true,
|
|
Secure = true,
|
|
SameSite = SameSiteMode.Lax,
|
|
Domain = cookieDomain,
|
|
// Effectively never expire client-side (20 years)
|
|
Expires = DateTime.UtcNow.AddYears(20)
|
|
});
|
|
|
|
return tk;
|
|
}
|
|
|
|
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 async Task<bool> ValidateSudoMode(SnAuthSession session, string? pinCode)
|
|
{
|
|
// Check if the session is already in sudo mode (cached)
|
|
var sudoModeKey = $"accounts:{session.Id}:sudo";
|
|
var (found, _) = await cache.GetAsyncWithStatus<bool>(sudoModeKey);
|
|
|
|
if (found)
|
|
{
|
|
// Session is already in sudo mode
|
|
return true;
|
|
}
|
|
|
|
// Check if the user has a pin code
|
|
var hasPinCode = await db.AccountAuthFactors
|
|
.Where(f => f.AccountId == session.AccountId)
|
|
.Where(f => f.EnabledAt != null)
|
|
.Where(f => f.Type == AccountAuthFactorType.PinCode)
|
|
.AnyAsync();
|
|
|
|
if (!hasPinCode)
|
|
{
|
|
// User doesn't have a pin code, no validation needed
|
|
return true;
|
|
}
|
|
|
|
// If pin code is not provided, we can't validate
|
|
if (string.IsNullOrEmpty(pinCode))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
try
|
|
{
|
|
// Validate the pin code
|
|
var isValid = await ValidatePinCode(session.AccountId, pinCode);
|
|
|
|
if (isValid)
|
|
{
|
|
// Set session in sudo mode for 5 minutes
|
|
await cache.SetAsync(sudoModeKey, true, TimeSpan.FromMinutes(5));
|
|
}
|
|
|
|
return isValid;
|
|
}
|
|
catch (InvalidOperationException)
|
|
{
|
|
// No pin code enabled for this account, so validation is successful
|
|
return true;
|
|
}
|
|
}
|
|
|
|
public async Task<bool> ValidatePinCode(Guid accountId, string pinCode)
|
|
{
|
|
var factor = await db.AccountAuthFactors
|
|
.Where(f => f.AccountId == accountId)
|
|
.Where(f => f.EnabledAt != null)
|
|
.Where(f => f.Type == AccountAuthFactorType.PinCode)
|
|
.FirstOrDefaultAsync();
|
|
if (factor is null) throw new InvalidOperationException("No pin code enabled for this account.");
|
|
|
|
return factor.VerifyPassword(pinCode);
|
|
}
|
|
|
|
public async Task<SnApiKey?> GetApiKey(Guid id, Guid? accountId = null)
|
|
{
|
|
var key = await db.ApiKeys
|
|
.Include(e => e.Session)
|
|
.Where(e => e.Id == id)
|
|
.If(accountId.HasValue, q => q.Where(e => e.AccountId == accountId!.Value))
|
|
.FirstOrDefaultAsync();
|
|
return key;
|
|
}
|
|
|
|
public async Task<SnApiKey> CreateApiKey(Guid accountId, string label, Instant? expiredAt = null)
|
|
{
|
|
var key = new SnApiKey
|
|
{
|
|
AccountId = accountId,
|
|
Label = label,
|
|
Session = new SnAuthSession
|
|
{
|
|
AccountId = accountId,
|
|
ExpiredAt = expiredAt
|
|
},
|
|
};
|
|
|
|
db.ApiKeys.Add(key);
|
|
await db.SaveChangesAsync();
|
|
|
|
return key;
|
|
}
|
|
|
|
public async Task<string> IssueApiKeyToken(SnApiKey key)
|
|
{
|
|
key.Session.LastGrantedAt = SystemClock.Instance.GetCurrentInstant();
|
|
db.Update(key.Session);
|
|
await db.SaveChangesAsync();
|
|
var tk = CreateToken(key.Session);
|
|
return tk;
|
|
}
|
|
|
|
public async Task RevokeApiKeyToken(SnApiKey key)
|
|
{
|
|
db.Remove(key);
|
|
db.Remove(key.Session);
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
public async Task<SnApiKey> RotateApiKeyToken(SnApiKey key)
|
|
{
|
|
await using var transaction = await db.Database.BeginTransactionAsync();
|
|
try
|
|
{
|
|
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
|
|
{
|
|
AccountId = key.AccountId,
|
|
ExpiredAt = key.Session?.ExpiredAt
|
|
};
|
|
|
|
db.AuthSessions.Add(newSession);
|
|
await db.SaveChangesAsync();
|
|
|
|
// Update ApiKey to point to new session
|
|
key.SessionId = newSession.Id;
|
|
key.Session = newSession;
|
|
db.ApiKeys.Update(key);
|
|
await db.SaveChangesAsync();
|
|
|
|
// 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;
|
|
}
|
|
catch
|
|
{
|
|
await transaction.RollbackAsync();
|
|
throw;
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
{
|
|
var padded = base64Url
|
|
.Replace('-', '+')
|
|
.Replace('_', '/');
|
|
|
|
switch (padded.Length % 4)
|
|
{
|
|
case 2: padded += "=="; break;
|
|
case 3: padded += "="; break;
|
|
}
|
|
|
|
return Convert.FromBase64String(padded);
|
|
}
|
|
}
|