diff --git a/DysonNetwork.Sphere/Account/Account.cs b/DysonNetwork.Sphere/Account/Account.cs index 345b2ea..608991b 100644 --- a/DysonNetwork.Sphere/Account/Account.cs +++ b/DysonNetwork.Sphere/Account/Account.cs @@ -5,6 +5,7 @@ using DysonNetwork.Sphere.Permission; using DysonNetwork.Sphere.Storage; using Microsoft.EntityFrameworkCore; using NodaTime; +using OtpNet; namespace DysonNetwork.Sphere.Account; @@ -21,7 +22,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 Sessions { get; set; } = new List(); [JsonIgnore] public ICollection Challenges { get; set; } = new List(); @@ -32,18 +33,19 @@ public class Account : ModelBase public abstract class Leveling { - public static readonly List ExperiencePerLevel = [ - 0, // Level 0 - 100, // Level 1 - 250, // Level 2 - 500, // Level 3 - 1000, // Level 4 - 2000, // Level 5 - 4000, // Level 6 - 8000, // Level 7 - 16000, // Level 8 - 32000, // Level 9 - 64000, // Level 10 + public static readonly List ExperiencePerLevel = + [ + 0, // Level 0 + 100, // Level 1 + 250, // Level 2 + 500, // Level 3 + 1000, // Level 4 + 2000, // Level 5 + 4000, // Level 6 + 8000, // Level 7 + 16000, // Level 8 + 32000, // Level 9 + 64000, // Level 10 128000, // Level 11 256000, // Level 12 512000, // Level 13 @@ -62,17 +64,20 @@ public class Profile : ModelBase [MaxLength(1024)] public string? Pronouns { get; set; } public Instant? Birthday { get; set; } public Instant? LastSeenAt { get; set; } - + public int Experience { get; set; } = 0; [NotMapped] public int Level => Leveling.ExperiencePerLevel.Count(xp => Experience >= xp) - 1; - [NotMapped] public double LevelingProgress => Level >= Leveling.ExperiencePerLevel.Count - 1 ? 100 : - (Experience - Leveling.ExperiencePerLevel[Level]) * 100.0 / - (Leveling.ExperiencePerLevel[Level + 1] - Leveling.ExperiencePerLevel[Level]); + + [NotMapped] + public double LevelingProgress => Level >= Leveling.ExperiencePerLevel.Count - 1 + ? 100 + : (Experience - Leveling.ExperiencePerLevel[Level]) * 100.0 / + (Leveling.ExperiencePerLevel[Level + 1] - Leveling.ExperiencePerLevel[Level]); // Outdated fields, for backward compability [MaxLength(32)] public string? PictureId { get; set; } [MaxLength(32)] public string? BackgroundId { get; set; } - + [Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; } [Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { get; set; } @@ -102,7 +107,18 @@ public class AccountAuthFactor : ModelBase { public Guid Id { get; set; } public AccountAuthFactorType Type { get; set; } - [MaxLength(8196)] public string? Secret { get; set; } = null; + [MaxLength(8196)] public string? Secret { get; set; } + [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. + /// + public int Trustworthy { get; set; } = 1; + + public Instant? EnabledAt { get; set; } + public Instant? ExpiredAt { get; set; } public Guid AccountId { get; set; } [JsonIgnore] public Account Account { get; set; } = null!; @@ -118,8 +134,24 @@ public class AccountAuthFactor : ModelBase { if (Secret == null) throw new InvalidOperationException("Auth factor with no secret cannot be verified with password."); - return BCrypt.Net.BCrypt.Verify(password, Secret); + switch (Type) + { + case AccountAuthFactorType.Password: + return BCrypt.Net.BCrypt.Verify(password, Secret); + case AccountAuthFactorType.TimedCode: + var otp = new Totp(Base32Encoding.ToBytes(Secret)); + return otp.VerifyTotp(DateTime.UtcNow, password, out _, new VerificationWindow(previous: 5, future: 5)); + default: + throw new InvalidOperationException("Unsupported verification type, use CheckDeliveredCode instead."); + } } + + /// + /// This dictionary will be returned to the client and should only be set when it just created. + /// Useful for passing the client some data to finishing setup and recovery code. + /// + [NotMapped] + public Dictionary? CreatedResponse { get; set; } } public enum AccountAuthFactorType diff --git a/DysonNetwork.Sphere/Account/AccountCurrentController.cs b/DysonNetwork.Sphere/Account/AccountCurrentController.cs index b96131e..5144aa9 100644 --- a/DysonNetwork.Sphere/Account/AccountCurrentController.cs +++ b/DysonNetwork.Sphere/Account/AccountCurrentController.cs @@ -15,7 +15,6 @@ namespace DysonNetwork.Sphere.Account; public class AccountCurrentController( AppDatabase db, AccountService accounts, - FileService fs, FileReferenceService fileRefService, AccountEventService events, AuthService auth @@ -93,8 +92,10 @@ public class AccountCurrentController( var profileResourceId = $"profile:{profile.Id}"; // Remove old references for the profile picture - if (profile.Picture is not null) { - var oldPictureRefs = await fileRefService.GetResourceReferencesAsync(profileResourceId, "profile.picture"); + if (profile.Picture is not null) + { + var oldPictureRefs = + await fileRefService.GetResourceReferencesAsync(profileResourceId, "profile.picture"); foreach (var oldRef in oldPictureRefs) { await fileRefService.DeleteReferenceAsync(oldRef.Id); @@ -105,8 +106,8 @@ public class AccountCurrentController( // Create new reference await fileRefService.CreateReferenceAsync( - picture.Id, - "profile.picture", + picture.Id, + "profile.picture", profileResourceId ); } @@ -119,8 +120,10 @@ public class AccountCurrentController( var profileResourceId = $"profile:{profile.Id}"; // Remove old references for the profile background - if (profile.Background is not null) { - var oldBackgroundRefs = await fileRefService.GetResourceReferencesAsync(profileResourceId, "profile.background"); + if (profile.Background is not null) + { + var oldBackgroundRefs = + await fileRefService.GetResourceReferencesAsync(profileResourceId, "profile.background"); foreach (var oldRef in oldBackgroundRefs) { await fileRefService.DeleteReferenceAsync(oldRef.Id); @@ -131,8 +134,8 @@ public class AccountCurrentController( // Create new reference await fileRefService.CreateReferenceAsync( - background.Id, - "profile.background", + background.Id, + "profile.background", profileResourceId ); } @@ -334,8 +337,48 @@ public class AccountCurrentController( return Ok(factors); } + public class AuthFactorRequest + { + public AccountAuthFactorType Type { get; set; } + public string? Secret { get; set; } + } + + [HttpPost("factors")] + [Authorize] + public async Task> CreateAuthFactor([FromBody] AuthFactorRequest request) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + if (await accounts.CheckAuthFactorExists(currentUser, request.Type)) + return BadRequest($"Auth factor with type {request.Type} is already exists."); + + var factor = await accounts.CreateAuthFactor(currentUser, request.Type, request.Secret); + return Ok(factor); + } + + [HttpPost("factors/{id:guid}")] + [Authorize] + public async Task> CreateAuthFactor(Guid id, [FromBody] string code) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + + var factor = await db.AccountAuthFactors + .Where(f => f.AccountId == id && f.Id == id) + .FirstOrDefaultAsync(); + if(factor is null) return NotFound(); + + try + { + factor = await accounts.EnableAuthFactor(factor, code); + return Ok(factor); + } + catch (Exception ex) + { + return BadRequest(ex.Message); + } + } + [HttpGet("sessions")] - public async Task>> GetSessions( + public async Task>> GetSessions( [FromQuery] int take = 20, [FromQuery] int offset = 0 ) diff --git a/DysonNetwork.Sphere/Account/AccountService.cs b/DysonNetwork.Sphere/Account/AccountService.cs index 4b75b8b..2a5156c 100644 --- a/DysonNetwork.Sphere/Account/AccountService.cs +++ b/DysonNetwork.Sphere/Account/AccountService.cs @@ -2,6 +2,7 @@ using DysonNetwork.Sphere.Storage; using EFCore.BulkExtensions; using Microsoft.EntityFrameworkCore; using NodaTime; +using OtpNet; namespace DysonNetwork.Sphere.Account; @@ -64,6 +65,91 @@ public class AccountService( await spells.NotifyMagicSpell(spell); } + public async Task CheckAuthFactorExists(Account account, AccountAuthFactorType type) + { + var isExists = await db.AccountAuthFactors + .Where(x => x.AccountId == account.Id && x.Type == type) + .AnyAsync(); + return isExists; + } + + public async Task CreateAuthFactor(Account account, AccountAuthFactorType type, string? secret) + { + AccountAuthFactor? factor = null; + switch (type) + { + case AccountAuthFactorType.Password: + if (string.IsNullOrWhiteSpace(secret)) throw new ArgumentNullException(nameof(secret)); + factor = new AccountAuthFactor + { + Type = AccountAuthFactorType.Password, + Trustworthy = 1, + AccountId = account.Id, + Secret = secret, + EnabledAt = SystemClock.Instance.GetCurrentInstant(), + }.HashSecret(); + break; + case AccountAuthFactorType.EmailCode: + factor = new AccountAuthFactor + { + Type = AccountAuthFactorType.EmailCode, + Trustworthy = 2, + EnabledAt = SystemClock.Instance.GetCurrentInstant(), + }; + break; + case AccountAuthFactorType.InAppCode: + factor = new AccountAuthFactor + { + Type = AccountAuthFactorType.InAppCode, + Trustworthy = 1, + EnabledAt = SystemClock.Instance.GetCurrentInstant() + }; + break; + case AccountAuthFactorType.TimedCode: + var skOtp = KeyGeneration.GenerateRandomKey(20); + var skOtp32 = Base32Encoding.ToString(skOtp); + factor = new AccountAuthFactor + { + Secret = skOtp32, + Type = AccountAuthFactorType.InAppCode, + Trustworthy = 2, + EnabledAt = null, // It needs to be tired once to enable + CreatedResponse = new Dictionary + { + ["uri"] = new OtpUri( + OtpType.Totp, + skOtp32, + account.Id.ToString(), + "Solar Network" + ).ToString(), + } + }; + break; + default: + throw new ArgumentOutOfRangeException(nameof(type), type, null); + } + + if (factor is null) throw new InvalidOperationException("Unable to create auth factor."); + db.AccountAuthFactors.Add(factor); + await db.SaveChangesAsync(); + return factor; + } + + public async Task EnableAuthFactor(AccountAuthFactor factor, string code) + { + if (factor.EnabledAt is not null) throw new ArgumentException("The factor has been enabled."); + if (!factor.VerifyPassword(code)) + throw new InvalidOperationException( + "Invalid code, you need to enter the correct code to enable the factor." + ); + + factor.EnabledAt = SystemClock.Instance.GetCurrentInstant(); + db.Update(factor); + await db.SaveChangesAsync(); + + return factor; + } + /// Maintenance methods for server administrator public async Task EnsureAccountProfileCreated() { diff --git a/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj b/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj index 32d73d4..4827ce7 100644 --- a/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj +++ b/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj @@ -53,6 +53,7 @@ + diff --git a/DysonNetwork.sln.DotSettings.user b/DysonNetwork.sln.DotSettings.user index 74836b1..43f6825 100644 --- a/DysonNetwork.sln.DotSettings.user +++ b/DysonNetwork.sln.DotSettings.user @@ -76,6 +76,7 @@ ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded