From c6450757be84032f189d169d8def13d6db8d3a15 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sun, 22 Jun 2025 00:18:50 +0800 Subject: [PATCH] :sparkles: Add pin code --- DysonNetwork.Sphere/Account/Account.cs | 19 +++-- DysonNetwork.Sphere/Account/AccountService.cs | 12 +++ DysonNetwork.Sphere/Auth/AuthService.cs | 73 ++++++++++++++++++- 3 files changed, 96 insertions(+), 8 deletions(-) diff --git a/DysonNetwork.Sphere/Account/Account.cs b/DysonNetwork.Sphere/Account/Account.cs index 576d1fb..5d05364 100644 --- a/DysonNetwork.Sphere/Account/Account.cs +++ b/DysonNetwork.Sphere/Account/Account.cs @@ -23,7 +23,7 @@ public class Account : ModelBase public Profile Profile { get; set; } = null!; public ICollection Contacts { get; set; } = new List(); public ICollection Badges { get; set; } = new List(); - + [JsonIgnore] public ICollection AuthFactors { get; set; } = new List(); [JsonIgnore] public ICollection Connections { get; set; } = new List(); [JsonIgnore] public ICollection Sessions { get; set; } = new List(); @@ -31,7 +31,7 @@ public class Account : ModelBase [JsonIgnore] public ICollection OutgoingRelationships { get; set; } = new List(); [JsonIgnore] public ICollection IncomingRelationships { get; set; } = new List(); - + [JsonIgnore] public ICollection Subscriptions { get; set; } = new List(); } @@ -119,12 +119,15 @@ public class AccountAuthFactor : ModelBase public Guid Id { get; set; } public AccountAuthFactorType Type { get; set; } [JsonIgnore] [MaxLength(8196)] public string? Secret { get; set; } - [JsonIgnore] [Column(TypeName = "jsonb")] public Dictionary? Config { get; set; } = new(); + + [JsonIgnore] + [Column(TypeName = "jsonb")] + public Dictionary? Config { get; set; } = new(); /// /// The trustworthy stands for how safe is this auth factor. /// Basically, it affects how many steps it can complete in authentication. - /// Besides, users may need to use some high trustworthy level auth factors when confirming some dangerous operations. + /// Besides, users may need to use some high-trustworthy level auth factors when confirming some dangerous operations. /// public int Trustworthy { get; set; } = 1; @@ -148,6 +151,7 @@ public class AccountAuthFactor : ModelBase switch (Type) { case AccountAuthFactorType.Password: + case AccountAuthFactorType.PinCode: return BCrypt.Net.BCrypt.Verify(password, Secret); case AccountAuthFactorType.TimedCode: var otp = new Totp(Base32Encoding.ToBytes(Secret)); @@ -172,7 +176,8 @@ public enum AccountAuthFactorType Password, EmailCode, InAppCode, - TimedCode + TimedCode, + PinCode, } public class AccountConnection : ModelBase @@ -181,11 +186,11 @@ public class AccountConnection : ModelBase [MaxLength(4096)] public string Provider { get; set; } = null!; [MaxLength(8192)] public string ProvidedIdentifier { get; set; } = null!; [Column(TypeName = "jsonb")] public Dictionary? Meta { get; set; } = new(); - + [JsonIgnore] [MaxLength(4096)] public string? AccessToken { get; set; } [JsonIgnore] [MaxLength(4096)] public string? RefreshToken { get; set; } public Instant? LastUsedAt { get; set; } - + public Guid AccountId { get; set; } public Account Account { get; set; } = null!; } \ No newline at end of file diff --git a/DysonNetwork.Sphere/Account/AccountService.cs b/DysonNetwork.Sphere/Account/AccountService.cs index 472be04..a445ea2 100644 --- a/DysonNetwork.Sphere/Account/AccountService.cs +++ b/DysonNetwork.Sphere/Account/AccountService.cs @@ -257,6 +257,18 @@ public class AccountService( } }; break; + case AccountAuthFactorType.PinCode: + if (string.IsNullOrWhiteSpace(secret)) throw new ArgumentNullException(nameof(secret)); + if (!secret.All(char.IsDigit) || secret.Length != 6) + throw new ArgumentException("PIN code must be exactly 6 digits"); + factor = new AccountAuthFactor + { + Type = AccountAuthFactorType.PinCode, + Trustworthy = 0, // Only for confirming, can't be used for login + Secret = secret, + EnabledAt = SystemClock.Instance.GetCurrentInstant(), + }.HashSecret(); + break; default: throw new ArgumentOutOfRangeException(nameof(type), type, null); } diff --git a/DysonNetwork.Sphere/Auth/AuthService.cs b/DysonNetwork.Sphere/Auth/AuthService.cs index c4ba150..9dcc1f4 100644 --- a/DysonNetwork.Sphere/Auth/AuthService.cs +++ b/DysonNetwork.Sphere/Auth/AuthService.cs @@ -1,11 +1,19 @@ using System.Security.Cryptography; using System.Text.Json; +using DysonNetwork.Sphere.Account; +using DysonNetwork.Sphere.Storage; using Microsoft.EntityFrameworkCore; using NodaTime; namespace DysonNetwork.Sphere.Auth; -public class AuthService(AppDatabase db, IConfiguration config, IHttpClientFactory httpClientFactory, IHttpContextAccessor httpContextAccessor) +public class AuthService( + AppDatabase db, + IConfiguration config, + IHttpClientFactory httpClientFactory, + IHttpContextAccessor httpContextAccessor, + ICacheService cache +) { private HttpContext HttpContext => httpContextAccessor.HttpContext!; @@ -174,6 +182,69 @@ public class AuthService(AppDatabase db, IConfiguration config, IHttpClientFacto return $"{payloadBase64}.{signatureBase64}"; } + public async Task ValidateSudoMode(Session session, string? pinCode) + { + // Check if the session is already in sudo mode (cached) + var sudoModeKey = $"accounts:{session.Id}:sudo"; + var (found, _) = await cache.GetAsyncWithStatus(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 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;