From 61a88e6715936564e44e10c8e42c054fa4cf4004 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Mon, 10 Mar 2025 14:21:10 +0100 Subject: [PATCH] Add credentials TOTP code scaffolding (#181) --- .../AliasVault.Client.csproj | 1 + .../Main/Components/TotpCodes/TotpCodes.razor | 258 +++++++++++++ .../Main/Pages/Credentials/View.razor | 1 + src/AliasVault.Client/Program.cs | 1 + .../Services/TotpCodeService.cs | 123 ++++++ src/AliasVault.Client/_Imports.razor | 1 + .../wwwroot/css/tailwind.css | 76 ++++ .../AliasClientDb/AliasClientDbContext.cs | 13 +- src/Databases/AliasClientDb/Credential.cs | 5 + ...50310131554_1.5.0-AddTotpCodes.Designer.cs | 364 ++++++++++++++++++ .../20250310131554_1.5.0-AddTotpCodes.cs | 51 +++ .../AliasClientDbContextModelSnapshot.cs | 50 ++- src/Databases/AliasClientDb/TotpCode.cs | 48 +++ 13 files changed, 990 insertions(+), 2 deletions(-) create mode 100644 src/AliasVault.Client/Main/Components/TotpCodes/TotpCodes.razor create mode 100644 src/AliasVault.Client/Services/TotpCodeService.cs create mode 100644 src/Databases/AliasClientDb/Migrations/20250310131554_1.5.0-AddTotpCodes.Designer.cs create mode 100644 src/Databases/AliasClientDb/Migrations/20250310131554_1.5.0-AddTotpCodes.cs create mode 100644 src/Databases/AliasClientDb/TotpCode.cs diff --git a/src/AliasVault.Client/AliasVault.Client.csproj b/src/AliasVault.Client/AliasVault.Client.csproj index 48ef67ae3..d425b8257 100644 --- a/src/AliasVault.Client/AliasVault.Client.csproj +++ b/src/AliasVault.Client/AliasVault.Client.csproj @@ -84,6 +84,7 @@ + wwwroot/service-worker.published.js diff --git a/src/AliasVault.Client/Main/Components/TotpCodes/TotpCodes.razor b/src/AliasVault.Client/Main/Components/TotpCodes/TotpCodes.razor new file mode 100644 index 000000000..20a5a18cc --- /dev/null +++ b/src/AliasVault.Client/Main/Components/TotpCodes/TotpCodes.razor @@ -0,0 +1,258 @@ +@inherits ComponentBase +@inject TotpCodeService TotpCodeService +@inject GlobalNotificationService GlobalNotificationService +@implements IDisposable + +
+
+
+

Two-Factor Authentication

+
+ @if (TotpCodeList.Count > 0) + { +
+ +
+ } +
+ + @if (IsLoading) + { + + } + else if (TotpCodeList.Count == 0) + { +
+

No 2FA TOTP codes added yet.

+ +
+ } + else + { +
+ @foreach (var totpCode in TotpCodeList) + { +
+
+

@totpCode.Name

+ +
+
+
@GetTotpCode(totpCode.SecretKey)
+
+
+
+
Refreshes in @GetRemainingSeconds() seconds
+
+
+ } +
+ } +
+ +@if (IsAddTotpCodeModalVisible) +{ +
+
+
+
+

+ Add 2FA TOTP Code +

+ +
+
+
+ + +
+
+ + +

Enter the secret key provided by the service.

+
+ +
+
+
+
+} + +@code { + /// + /// The credential ID. + /// + [Parameter] + public Guid CredentialId { get; set; } + + /// + /// The service name. + /// + [Parameter] + public string ServiceName { get; set; } = string.Empty; + + private List TotpCodeList { get; set; } = new(); + private bool IsLoading { get; set; } = true; + private bool IsAddTotpCodeModalVisible { get; set; } = false; + private TotpCode NewTotpCode { get; set; } = new(); + private Timer? _refreshTimer; + private Dictionary _currentCodes = new(); + + /// + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + await LoadTotpCodesAsync(); + + // Start a timer to refresh the TOTP codes every second + _refreshTimer = new Timer(async _ => await RefreshCodesAsync(), null, 0, 1000); + } + + private async Task LoadTotpCodesAsync() + { + IsLoading = true; + StateHasChanged(); + + TotpCodeList = await TotpCodeService.GetTotpCodesAsync(CredentialId); + + // Generate initial codes + foreach (var code in TotpCodeList) + { + _currentCodes[code.SecretKey] = TotpCodeService.GenerateTotpCode(code.SecretKey); + } + + IsLoading = false; + StateHasChanged(); + } + + private async Task RefreshCodesAsync() + { + bool hasChanges = false; + + foreach (var code in TotpCodeList) + { + var newCode = TotpCodeService.GenerateTotpCode(code.SecretKey); + if (!_currentCodes.ContainsKey(code.SecretKey) || _currentCodes[code.SecretKey] != newCode) + { + _currentCodes[code.SecretKey] = newCode; + hasChanges = true; + } + } + + if (hasChanges) + { + await InvokeAsync(StateHasChanged); + } + } + + private string GetTotpCode(string secretKey) + { + if (_currentCodes.TryGetValue(secretKey, out var code)) + { + return code; + } + + var newCode = TotpCodeService.GenerateTotpCode(secretKey); + _currentCodes[secretKey] = newCode; + return newCode; + } + + private int GetRemainingSeconds() + { + return TotpCodeService.GetRemainingSeconds(); + } + + private int GetRemainingPercentage() + { + var remaining = GetRemainingSeconds(); + return (int)((remaining / 30.0) * 100); + } + + private void ShowAddTotpCodeModal() + { + NewTotpCode = new TotpCode + { + CredentialId = CredentialId, + Name = ServiceName + }; + IsAddTotpCodeModalVisible = true; + } + + private void HideAddTotpCodeModal() + { + IsAddTotpCodeModalVisible = false; + } + + private async Task AddTotpCode() + { + if (string.IsNullOrWhiteSpace(NewTotpCode.Name)) + { + GlobalNotificationService.AddErrorMessage("Name is required.", true); + return; + } + + if (string.IsNullOrWhiteSpace(NewTotpCode.SecretKey)) + { + GlobalNotificationService.AddErrorMessage("Secret key is required.", true); + return; + } + + try + { + // Validate the secret key by trying to generate a code + TotpCodeService.GenerateTotpCode(NewTotpCode.SecretKey); + } + catch (Exception) + { + GlobalNotificationService.AddErrorMessage("Invalid secret key. Please check and try again.", true); + return; + } + + var result = await TotpCodeService.AddTotpCodeAsync(NewTotpCode); + if (result != null) + { + GlobalNotificationService.AddSuccessMessage("TOTP code added successfully.", true); + HideAddTotpCodeModal(); + await LoadTotpCodesAsync(); + } + else + { + GlobalNotificationService.AddErrorMessage("Failed to add TOTP code. Please try again.", true); + } + } + + private async Task DeleteTotpCode(Guid totpCodeId) + { + var result = await TotpCodeService.DeleteTotpCodeAsync(totpCodeId); + if (result) + { + GlobalNotificationService.AddSuccessMessage("TOTP code deleted successfully.", true); + await LoadTotpCodesAsync(); + } + else + { + GlobalNotificationService.AddErrorMessage("Failed to delete TOTP code. Please try again.", true); + } + } + + /// + public void Dispose() + { + _refreshTimer?.Dispose(); + } +} diff --git a/src/AliasVault.Client/Main/Pages/Credentials/View.razor b/src/AliasVault.Client/Main/Pages/Credentials/View.razor index f502cb967..bdbc65a56 100644 --- a/src/AliasVault.Client/Main/Pages/Credentials/View.razor +++ b/src/AliasVault.Client/Main/Pages/Credentials/View.razor @@ -44,6 +44,7 @@ else + @if (Alias.Notes != null && Alias.Notes.Length > 0) { diff --git a/src/AliasVault.Client/Program.cs b/src/AliasVault.Client/Program.cs index 149a6dcea..b184b6c15 100644 --- a/src/AliasVault.Client/Program.cs +++ b/src/AliasVault.Client/Program.cs @@ -74,6 +74,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); diff --git a/src/AliasVault.Client/Services/TotpCodeService.cs b/src/AliasVault.Client/Services/TotpCodeService.cs new file mode 100644 index 000000000..c7c4c901c --- /dev/null +++ b/src/AliasVault.Client/Services/TotpCodeService.cs @@ -0,0 +1,123 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Client.Services; + +using AliasClientDb; +using AliasVault.Client.Services.Database; +using AliasVault.TotpGenerator; +using Microsoft.EntityFrameworkCore; + +/// +/// Service for managing TOTP codes. +/// +public class TotpCodeService +{ + private readonly DbService _dbService; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The database service. + /// The logger. + public TotpCodeService(DbService dbService, ILogger logger) + { + _dbService = dbService; + _logger = logger; + } + + /// + /// Gets all TOTP codes for a credential. + /// + /// The credential ID. + /// A list of TOTP codes. + public async Task> GetTotpCodesAsync(Guid credentialId) + { + try + { + var dbContext = await _dbService.GetDbContextAsync(); + return await dbContext.TotpCodes + .Where(t => t.CredentialId == credentialId) + .ToListAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting TOTP codes for credential {CredentialId}", credentialId); + return new List(); + } + } + + /// + /// Adds a new TOTP code. + /// + /// The TOTP code to add. + /// The added TOTP code. + public async Task AddTotpCodeAsync(TotpCode totpCode) + { + try + { + var dbContext = await _dbService.GetDbContextAsync(); + dbContext.TotpCodes.Add(totpCode); + await dbContext.SaveChangesAsync(); + return totpCode; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error adding TOTP code for credential {CredentialId}", totpCode.CredentialId); + return null; + } + } + + /// + /// Deletes a TOTP code. + /// + /// The TOTP code ID. + /// True if the TOTP code was deleted, false otherwise. + public async Task DeleteTotpCodeAsync(Guid totpCodeId) + { + try + { + var dbContext = await _dbService.GetDbContextAsync(); + var totpCode = await dbContext.TotpCodes.FindAsync(totpCodeId); + if (totpCode == null) + { + return false; + } + + dbContext.TotpCodes.Remove(totpCode); + await dbContext.SaveChangesAsync(); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting TOTP code {TotpCodeId}", totpCodeId); + return false; + } + } + + /// + /// Generates a TOTP code for a given secret key. + /// + /// The secret key. + /// The generated TOTP code. + public string GenerateTotpCode(string secretKey) + { + return TotpGenerator.GenerateTotpCode(secretKey); + } + + /// + /// Gets the remaining seconds until the TOTP code expires. + /// + /// The time step in seconds. Default is 30. + /// The remaining seconds. + public int GetRemainingSeconds(int step = 30) + { + var unixTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + return step - (int)(unixTimestamp % step); + } +} diff --git a/src/AliasVault.Client/_Imports.razor b/src/AliasVault.Client/_Imports.razor index c52d02bb5..12334b443 100644 --- a/src/AliasVault.Client/_Imports.razor +++ b/src/AliasVault.Client/_Imports.razor @@ -19,6 +19,7 @@ @using AliasVault.Client.Main.Components.Forms @using AliasVault.Client.Main.Components.Layout @using AliasVault.Client.Main.Components.Loading +@using AliasVault.Client.Main.Components.TotpCodes @using AliasVault.Client.Main.Components.Widgets @using AliasVault.Client.Main.Models @using AliasVault.Client.Services diff --git a/src/AliasVault.Client/wwwroot/css/tailwind.css b/src/AliasVault.Client/wwwroot/css/tailwind.css index 7d1972afc..f3b83fc9f 100644 --- a/src/AliasVault.Client/wwwroot/css/tailwind.css +++ b/src/AliasVault.Client/wwwroot/css/tailwind.css @@ -833,6 +833,10 @@ video { margin-top: 2rem; } +.ms-auto { + margin-inline-start: auto; +} + .line-clamp-2 { overflow: hidden; display: -webkit-box; @@ -936,6 +940,10 @@ video { max-height: 90vh; } +.max-h-full { + max-height: 100%; +} + .min-h-\[250px\] { min-height: 250px; } @@ -1317,6 +1325,11 @@ video { border-bottom-right-radius: 0.5rem; } +.rounded-t { + border-top-left-radius: 0.25rem; + border-top-right-radius: 0.25rem; +} + .border { border-width: 1px; } @@ -1549,6 +1562,25 @@ video { background-color: rgb(254 252 232 / var(--tw-bg-opacity)); } +.bg-black { + --tw-bg-opacity: 1; + background-color: rgb(0 0 0 / var(--tw-bg-opacity)); +} + +.bg-blue-600 { + --tw-bg-opacity: 1; + background-color: rgb(37 99 235 / var(--tw-bg-opacity)); +} + +.bg-blue-700 { + --tw-bg-opacity: 1; + background-color: rgb(29 78 216 / var(--tw-bg-opacity)); +} + +.bg-transparent { + background-color: transparent; +} + .bg-opacity-50 { --tw-bg-opacity: 0.5; } @@ -2149,6 +2181,11 @@ video { background-color: rgb(153 27 27 / var(--tw-bg-opacity)); } +.hover\:bg-blue-800:hover { + --tw-bg-opacity: 1; + background-color: rgb(30 64 175 / var(--tw-bg-opacity)); +} + .hover\:from-primary-600:hover { --tw-gradient-from: #d68338 var(--tw-gradient-from-position); --tw-gradient-to: rgb(214 131 56 / 0) var(--tw-gradient-to-position); @@ -2209,6 +2246,11 @@ video { color: rgb(255 255 255 / var(--tw-text-opacity)); } +.hover\:text-red-800:hover { + --tw-text-opacity: 1; + color: rgb(153 27 27 / var(--tw-text-opacity)); +} + .hover\:underline:hover { text-decoration-line: underline; } @@ -2379,6 +2421,11 @@ video { border-color: rgb(234 179 8 / var(--tw-border-opacity)); } +.dark\:border-gray-500:is(.dark *) { + --tw-border-opacity: 1; + border-color: rgb(107 114 128 / var(--tw-border-opacity)); +} + .dark\:bg-blue-800:is(.dark *) { --tw-bg-opacity: 1; background-color: rgb(30 64 175 / var(--tw-bg-opacity)); @@ -2477,6 +2524,11 @@ video { background-color: rgb(113 63 18 / var(--tw-bg-opacity)); } +.dark\:bg-blue-600:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(37 99 235 / var(--tw-bg-opacity)); +} + .dark\:bg-opacity-80:is(.dark *) { --tw-bg-opacity: 0.8; } @@ -2571,6 +2623,11 @@ video { color: rgb(250 204 21 / var(--tw-text-opacity)); } +.dark\:text-red-500:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(239 68 68 / var(--tw-text-opacity)); +} + .dark\:placeholder-gray-400:is(.dark *)::-moz-placeholder { --tw-placeholder-opacity: 1; color: rgb(156 163 175 / var(--tw-placeholder-opacity)); @@ -2630,6 +2687,16 @@ video { background-color: rgb(185 28 28 / var(--tw-bg-opacity)); } +.dark\:hover\:bg-blue-600:hover:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(37 99 235 / var(--tw-bg-opacity)); +} + +.dark\:hover\:bg-blue-700:hover:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(29 78 216 / var(--tw-bg-opacity)); +} + .dark\:hover\:from-primary-500:hover:is(.dark *) { --tw-gradient-from: #f49541 var(--tw-gradient-from-position); --tw-gradient-to: rgb(244 149 65 / 0) var(--tw-gradient-to-position); @@ -2665,6 +2732,11 @@ video { color: rgb(255 255 255 / var(--tw-text-opacity)); } +.dark\:hover\:text-red-400:hover:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(248 113 113 / var(--tw-text-opacity)); +} + .dark\:focus\:border-blue-500:focus:is(.dark *) { --tw-border-opacity: 1; border-color: rgb(59 130 246 / var(--tw-border-opacity)); @@ -2880,6 +2952,10 @@ video { margin-right: calc(0.5rem * var(--tw-space-x-reverse)); margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse))); } + + .md\:p-5 { + padding: 1.25rem; + } } @media (min-width: 1024px) { diff --git a/src/Databases/AliasClientDb/AliasClientDbContext.cs b/src/Databases/AliasClientDb/AliasClientDbContext.cs index e9fd71d0b..13e248f83 100644 --- a/src/Databases/AliasClientDb/AliasClientDbContext.cs +++ b/src/Databases/AliasClientDb/AliasClientDbContext.cs @@ -10,7 +10,6 @@ namespace AliasClientDb; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; /// /// The AliasClientDbContext class. @@ -78,6 +77,11 @@ public class AliasClientDbContext : DbContext /// public DbSet Settings { get; set; } + /// + /// Gets or sets the TotpCodes DbSet. + /// + public DbSet TotpCodes { get; set; } + /// /// The OnModelCreating method. /// @@ -125,6 +129,13 @@ public class AliasClientDbContext : DbContext .WithMany(c => c.Passwords) .HasForeignKey(l => l.CredentialId) .OnDelete(DeleteBehavior.Cascade); + + // Configure TotpCode - Credential relationship + modelBuilder.Entity() + .HasOne(l => l.Credential) + .WithMany(c => c.TotpCodes) + .HasForeignKey(l => l.CredentialId) + .OnDelete(DeleteBehavior.Cascade); } /// diff --git a/src/Databases/AliasClientDb/Credential.cs b/src/Databases/AliasClientDb/Credential.cs index cfc7f1b07..27a4db461 100644 --- a/src/Databases/AliasClientDb/Credential.cs +++ b/src/Databases/AliasClientDb/Credential.cs @@ -53,6 +53,11 @@ public class Credential : SyncableEntity /// public virtual ICollection Attachments { get; set; } = []; + /// + /// Gets or sets the TOTP code objects. + /// + public virtual ICollection TotpCodes { get; set; } = []; + /// /// Gets or sets the service ID foreign key. /// diff --git a/src/Databases/AliasClientDb/Migrations/20250310131554_1.5.0-AddTotpCodes.Designer.cs b/src/Databases/AliasClientDb/Migrations/20250310131554_1.5.0-AddTotpCodes.Designer.cs new file mode 100644 index 000000000..ae320ee3e --- /dev/null +++ b/src/Databases/AliasClientDb/Migrations/20250310131554_1.5.0-AddTotpCodes.Designer.cs @@ -0,0 +1,364 @@ +// +using System; +using AliasClientDb; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace AliasClientDb.Migrations +{ + [DbContext(typeof(AliasClientDbContext))] + [Migration("20250310131554_1.5.0-AddTotpCodes")] + partial class _150AddTotpCodes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.2") + .HasAnnotation("Proxies:ChangeTracking", false) + .HasAnnotation("Proxies:CheckEquality", false) + .HasAnnotation("Proxies:LazyLoading", true); + + modelBuilder.Entity("AliasClientDb.Alias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BirthDate") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("FirstName") + .HasMaxLength(255) + .HasColumnType("VARCHAR"); + + b.Property("Gender") + .HasMaxLength(255) + .HasColumnType("VARCHAR"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("LastName") + .HasMaxLength(255) + .HasColumnType("VARCHAR"); + + b.Property("NickName") + .HasMaxLength(255) + .HasColumnType("VARCHAR"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Aliases"); + }); + + modelBuilder.Entity("AliasClientDb.Attachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Blob") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CredentialId") + .HasColumnType("TEXT"); + + b.Property("Filename") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CredentialId"); + + b.ToTable("Attachments"); + }); + + modelBuilder.Entity("AliasClientDb.Credential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AliasId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("ServiceId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AliasId"); + + b.HasIndex("ServiceId"); + + b.ToTable("Credentials"); + }); + + modelBuilder.Entity("AliasClientDb.EncryptionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsPrimary") + .HasColumnType("INTEGER"); + + b.Property("PrivateKey") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("EncryptionKeys"); + }); + + modelBuilder.Entity("AliasClientDb.Password", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CredentialId") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CredentialId"); + + b.ToTable("Passwords"); + }); + + modelBuilder.Entity("AliasClientDb.Service", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("Logo") + .HasColumnType("BLOB"); + + b.Property("Name") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Services"); + }); + + modelBuilder.Entity("AliasClientDb.Setting", b => + { + b.Property("Key") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("AliasClientDb.TotpCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CredentialId") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("SecretKey") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CredentialId"); + + b.ToTable("TotpCodes"); + }); + + modelBuilder.Entity("AliasClientDb.Attachment", b => + { + b.HasOne("AliasClientDb.Credential", "Credential") + .WithMany("Attachments") + .HasForeignKey("CredentialId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Credential"); + }); + + modelBuilder.Entity("AliasClientDb.Credential", b => + { + b.HasOne("AliasClientDb.Alias", "Alias") + .WithMany("Credentials") + .HasForeignKey("AliasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("AliasClientDb.Service", "Service") + .WithMany("Credentials") + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Alias"); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("AliasClientDb.Password", b => + { + b.HasOne("AliasClientDb.Credential", "Credential") + .WithMany("Passwords") + .HasForeignKey("CredentialId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Credential"); + }); + + modelBuilder.Entity("AliasClientDb.TotpCode", b => + { + b.HasOne("AliasClientDb.Credential", "Credential") + .WithMany("TotpCodes") + .HasForeignKey("CredentialId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Credential"); + }); + + modelBuilder.Entity("AliasClientDb.Alias", b => + { + b.Navigation("Credentials"); + }); + + modelBuilder.Entity("AliasClientDb.Credential", b => + { + b.Navigation("Attachments"); + + b.Navigation("Passwords"); + + b.Navigation("TotpCodes"); + }); + + modelBuilder.Entity("AliasClientDb.Service", b => + { + b.Navigation("Credentials"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Databases/AliasClientDb/Migrations/20250310131554_1.5.0-AddTotpCodes.cs b/src/Databases/AliasClientDb/Migrations/20250310131554_1.5.0-AddTotpCodes.cs new file mode 100644 index 000000000..bd56711f3 --- /dev/null +++ b/src/Databases/AliasClientDb/Migrations/20250310131554_1.5.0-AddTotpCodes.cs @@ -0,0 +1,51 @@ +// +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace AliasClientDb.Migrations +{ + /// + public partial class _150AddTotpCodes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "TotpCodes", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 255, nullable: false), + SecretKey = table.Column(type: "TEXT", maxLength: 255, nullable: false), + CredentialId = table.Column(type: "TEXT", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false), + IsDeleted = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TotpCodes", x => x.Id); + table.ForeignKey( + name: "FK_TotpCodes_Credentials_CredentialId", + column: x => x.CredentialId, + principalTable: "Credentials", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_TotpCodes_CredentialId", + table: "TotpCodes", + column: "CredentialId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "TotpCodes"); + } + } +} diff --git a/src/Databases/AliasClientDb/Migrations/AliasClientDbContextModelSnapshot.cs b/src/Databases/AliasClientDb/Migrations/AliasClientDbContextModelSnapshot.cs index 9d854e1ef..5b4dab588 100644 --- a/src/Databases/AliasClientDb/Migrations/AliasClientDbContextModelSnapshot.cs +++ b/src/Databases/AliasClientDb/Migrations/AliasClientDbContextModelSnapshot.cs @@ -16,7 +16,7 @@ namespace AliasClientDb.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("ProductVersion", "9.0.2") .HasAnnotation("Proxies:ChangeTracking", false) .HasAnnotation("Proxies:CheckEquality", false) .HasAnnotation("Proxies:LazyLoading", true); @@ -250,6 +250,41 @@ namespace AliasClientDb.Migrations b.ToTable("Settings"); }); + modelBuilder.Entity("AliasClientDb.TotpCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CredentialId") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("SecretKey") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CredentialId"); + + b.ToTable("TotpCodes"); + }); + modelBuilder.Entity("AliasClientDb.Attachment", b => { b.HasOne("AliasClientDb.Credential", "Credential") @@ -291,6 +326,17 @@ namespace AliasClientDb.Migrations b.Navigation("Credential"); }); + modelBuilder.Entity("AliasClientDb.TotpCode", b => + { + b.HasOne("AliasClientDb.Credential", "Credential") + .WithMany("TotpCodes") + .HasForeignKey("CredentialId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Credential"); + }); + modelBuilder.Entity("AliasClientDb.Alias", b => { b.Navigation("Credentials"); @@ -301,6 +347,8 @@ namespace AliasClientDb.Migrations b.Navigation("Attachments"); b.Navigation("Passwords"); + + b.Navigation("TotpCodes"); }); modelBuilder.Entity("AliasClientDb.Service", b => diff --git a/src/Databases/AliasClientDb/TotpCode.cs b/src/Databases/AliasClientDb/TotpCode.cs new file mode 100644 index 000000000..e36e23e5a --- /dev/null +++ b/src/Databases/AliasClientDb/TotpCode.cs @@ -0,0 +1,48 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasClientDb; + +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using AliasClientDb.Abstracts; + +/// +/// The TotpCode class that stores 2FA information associated with a credential. +/// +public class TotpCode : SyncableEntity +{ + /// + /// Gets or sets the ID. + /// + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public Guid Id { get; set; } + + /// + /// Gets or sets the name of the TOTP code. + /// + [MaxLength(255)] + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets the secret key for the TOTP code. + /// + [MaxLength(255)] + public string SecretKey { get; set; } = string.Empty; + + /// + /// Gets or sets the credential ID. + /// + public Guid CredentialId { get; set; } + + /// + /// Gets or sets the credential. + /// + [ForeignKey("CredentialId")] + public virtual Credential? Credential { get; set; } +}