205 lines
7.8 KiB
C#
205 lines
7.8 KiB
C#
using System.Security.Cryptography;
|
||
using System.Text.Json;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using NodaTime;
|
||
|
||
namespace DysonNetwork.Sphere.Auth;
|
||
|
||
public class AuthService(AppDatabase db, IConfiguration config, IHttpClientFactory httpClientFactory)
|
||
{
|
||
/// <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.
|
||
// You might choose to make “maxSteps + riskScore” your total required steps,
|
||
// or clamp it to maxSteps if you only want to require existing available factors.
|
||
var totalRequiredSteps = maxSteps + riskScore;
|
||
|
||
// Clamp the step
|
||
totalRequiredSteps = Math.Max(Math.Min(totalRequiredSteps, maxSteps), 1);
|
||
|
||
return totalRequiredSteps;
|
||
}
|
||
|
||
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/api/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(Session session)
|
||
{
|
||
// Load the private key for signing
|
||
var privateKeyPem = File.ReadAllText(config["Jwt: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["Jwt: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);
|
||
}
|
||
} |