565 lines
21 KiB
C#
565 lines
21 KiB
C#
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;
|
||
|
||
namespace DysonNetwork.Pass.Auth;
|
||
|
||
public class AuthService(
|
||
AppDatabase db,
|
||
IConfiguration config,
|
||
IHttpClientFactory httpClientFactory,
|
||
IHttpContextAccessor httpContextAccessor,
|
||
ICacheService cache,
|
||
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
|
||
/// 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, Account.Account account)
|
||
{
|
||
// 1) Find out how many authentication factors the account has enabled.
|
||
var maxSteps = await db.AccountAuthFactors
|
||
.Where(f => f.AccountId == account.Id)
|
||
.Where(f => f.EnabledAt != null)
|
||
.CountAsync();
|
||
|
||
// 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;
|
||
|
||
// 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)
|
||
.Include(s => s.Challenge)
|
||
.Where(s => s.AccountId == account.Id)
|
||
.FirstOrDefaultAsync();
|
||
|
||
// 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.)
|
||
if (string.IsNullOrWhiteSpace(ipAddress))
|
||
riskScore += 1;
|
||
else
|
||
{
|
||
if (!string.IsNullOrEmpty(lastActiveInfo?.Challenge.IpAddress) &&
|
||
!lastActiveInfo.Challenge.IpAddress.Equals(ipAddress, StringComparison.OrdinalIgnoreCase))
|
||
riskScore += 1;
|
||
}
|
||
|
||
// 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) 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
|
||
totalRequiredSteps = Math.Max(Math.Min(totalRequiredSteps, maxSteps), 1);
|
||
|
||
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)
|
||
{
|
||
var challenge = new AuthChallenge
|
||
{
|
||
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 AuthSession
|
||
{
|
||
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<AuthClient> 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 AuthClient
|
||
{
|
||
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
|
||
{
|
||
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.");
|
||
}
|
||
}
|
||
|
||
public string CreateToken(AuthSession 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(AuthChallenge 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 AuthSession
|
||
{
|
||
LastGrantedAt = now,
|
||
// Never expire server-side
|
||
ExpiredAt = null,
|
||
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(AuthSession 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 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
|
||
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);
|
||
}
|
||
} |