diff --git a/src/AliasVault.Admin/Main/Models/UserViewModel.cs b/src/AliasVault.Admin/Main/Models/UserViewModel.cs index 79afc9817..6962209f4 100644 --- a/src/AliasVault.Admin/Main/Models/UserViewModel.cs +++ b/src/AliasVault.Admin/Main/Models/UserViewModel.cs @@ -32,6 +32,11 @@ public class UserViewModel /// public bool TwoFactorEnabled { get; set; } + /// + /// Gets or sets a value indicating whether the user is blocked. + /// + public bool Blocked { get; set; } + /// /// Gets or sets the vault count. /// diff --git a/src/AliasVault.Admin/Main/Pages/Logging/Auth.razor b/src/AliasVault.Admin/Main/Pages/Logging/Auth.razor index dedbdedca..67a1d42d5 100644 --- a/src/AliasVault.Admin/Main/Pages/Logging/Auth.razor +++ b/src/AliasVault.Admin/Main/Pages/Logging/Auth.razor @@ -49,7 +49,7 @@ else @log.Timestamp.ToString("yyyy-MM-dd HH:mm") @log.Username @log.EventType - + @log.IpAddress } diff --git a/src/AliasVault.Admin/Main/Pages/Users/Users.razor b/src/AliasVault.Admin/Main/Pages/Users/Users.razor index 6f6ae4dcd..9d5d26669 100644 --- a/src/AliasVault.Admin/Main/Pages/Users/Users.razor +++ b/src/AliasVault.Admin/Main/Pages/Users/Users.razor @@ -30,14 +30,19 @@ else @foreach (var user in UserList) { - @user.Id - @user.CreatedAt.ToString("yyyy-MM-dd HH:mm") + @user.CreatedAt.ToString("yyyy-MM-dd HH:mm") @user.UserName @user.VaultCount @user.EmailClaimCount @Math.Round((double)user.VaultStorageInKb / 1024, 1) MB @user.LastVaultUpdate.ToString("yyyy-MM-dd HH:mm") + + @if (user.Blocked) + { + + } + @@ -49,7 +54,6 @@ else @code { private readonly List _tableColumns = [ - new TableColumn { Title = "ID", PropertyName = "Id" }, new TableColumn { Title = "Registered", PropertyName = "CreatedAt" }, new TableColumn { Title = "Username", PropertyName = "UserName" }, new TableColumn { Title = "# Vaults", PropertyName = "VaultCount" }, @@ -57,6 +61,7 @@ else new TableColumn { Title = "Storage", PropertyName = "VaultStorageInKb" }, new TableColumn { Title = "2FA", PropertyName = "TwoFactorEnabled" }, new TableColumn { Title = "LastVaultUpdate", PropertyName = "LastVaultUpdate" }, + new TableColumn { Title = "Status", Sortable = false }, new TableColumn { Title = "Actions", Sortable = false}, ]; @@ -130,6 +135,7 @@ else u.UserName, u.CreatedAt, u.TwoFactorEnabled, + u.Blocked, Vaults = u.Vaults.Select(v => new { v.FileSize, @@ -147,6 +153,7 @@ else Id = user.Id, UserName = user.UserName?.ToLower() ?? "N/A", TwoFactorEnabled = user.TwoFactorEnabled, + Blocked = user.Blocked, CreatedAt = user.CreatedAt, VaultCount = user.Vaults.Count(), EmailClaimCount = user.EmailClaims.Count(), diff --git a/src/AliasVault.Admin/Main/Pages/Users/View/Index.razor b/src/AliasVault.Admin/Main/Pages/Users/View/Index.razor index ff85a41db..a36ba5cb9 100644 --- a/src/AliasVault.Admin/Main/Pages/Users/View/Index.razor +++ b/src/AliasVault.Admin/Main/Pages/Users/View/Index.razor @@ -48,6 +48,17 @@ else } } +
+ Account Status: + + + + + Blocking a user prevents them from logging in or accessing AliasVault + +
@@ -291,4 +302,19 @@ Do you want to proceed with the restoration?")) { await RefreshData(); } } + + /// + /// Toggles the blocked status of the user. + /// + private async Task ToggleBlockStatus() + { + User = await DbContext.AliasVaultUsers.FindAsync(Id); + + if (User != null) + { + User.Blocked = !User.Blocked; + await DbContext.SaveChangesAsync(); + await RefreshData(); + } + } } diff --git a/src/AliasVault.Api/Controllers/AuthController.cs b/src/AliasVault.Api/Controllers/AuthController.cs index df168c1d4..98e929c0d 100644 --- a/src/AliasVault.Api/Controllers/AuthController.cs +++ b/src/AliasVault.Api/Controllers/AuthController.cs @@ -61,9 +61,14 @@ public class AuthController(IDbContextFactory dbContextFac private static readonly string[] InvalidRecoveryCode = ["Invalid recovery code."]; /// - /// Error message for invalid 2-factor authentication recovery code. + /// Error message for too many failed login attempts. /// - private static readonly string[] AccountLocked = ["You have entered an incorrect password too many times and your account has now been locked out. You can try again in 30 minutes.."]; + private static readonly string[] AccountLocked = ["You have entered an incorrect password too many times and your account has now been locked out. You can try again in 30 minutes."]; + + /// + /// Error message for if user is (manually) blocked by admin. + /// + private static readonly string[] AccountBlocked = ["Your account has been disabled. If you believe this is a mistake, please contact support."]; /// /// Semaphore to prevent concurrent access to the database when generating new tokens for a user. @@ -103,6 +108,13 @@ public class AuthController(IDbContextFactory dbContextFac return BadRequest(ServerValidationErrorResponse.Create(AccountLocked, 400)); } + // Check if the account is blocked. + if (user.Blocked) + { + await authLoggingService.LogAuthEventFailAsync(model.Username, AuthEventType.Login, AuthFailureReason.AccountBlocked); + return BadRequest(ServerValidationErrorResponse.Create(AccountBlocked, 400)); + } + // Retrieve latest vault of user which contains the current salt and verifier. var latestVaultEncryptionSettings = AuthHelper.GetUserLatestVaultEncryptionSettings(user); @@ -263,6 +275,13 @@ public class AuthController(IDbContextFactory dbContextFac return Unauthorized("User not found (name-2)"); } + // Check if the account is blocked. + if (user.Blocked) + { + await authLoggingService.LogAuthEventFailAsync(user.UserName!, AuthEventType.TokenRefresh, AuthFailureReason.AccountBlocked); + return Unauthorized("Account blocked"); + } + // Generate new tokens for the user. var token = await GenerateNewTokensForUser(user, tokenModel.RefreshToken); if (token == null) @@ -610,6 +629,13 @@ public class AuthController(IDbContextFactory dbContextFac return (null, null, BadRequest(ServerValidationErrorResponse.Create(AccountLocked, 400))); } + // Check if the account is blocked. + if (user.Blocked) + { + await authLoggingService.LogAuthEventFailAsync(model.Username, AuthEventType.Login, AuthFailureReason.AccountBlocked); + return (null, null, BadRequest(ServerValidationErrorResponse.Create(AccountBlocked, 400))); + } + // Validate the SRP session (actual password check). var serverSession = AuthHelper.ValidateSrpSession(cache, user, model.ClientPublicEphemeral, model.ClientSessionProof); if (serverSession is null) diff --git a/src/Databases/AliasServerDb/AliasVaultUser.cs b/src/Databases/AliasServerDb/AliasVaultUser.cs index 1e0fbc785..5946a60e1 100644 --- a/src/Databases/AliasServerDb/AliasVaultUser.cs +++ b/src/Databases/AliasServerDb/AliasVaultUser.cs @@ -24,6 +24,11 @@ public class AliasVaultUser : IdentityUser /// public DateTime PasswordChangedAt { get; set; } + /// + /// Gets or sets a value indicating whether the user is blocked and should not be able to log in. + /// + public bool Blocked { get; set; } + /// /// Gets or sets updated timestamp. /// diff --git a/src/Databases/AliasServerDb/AuthLog.cs b/src/Databases/AliasServerDb/AuthLog.cs index 02601dfe4..152956d64 100644 --- a/src/Databases/AliasServerDb/AuthLog.cs +++ b/src/Databases/AliasServerDb/AuthLog.cs @@ -47,6 +47,11 @@ public enum AuthFailureReason /// InvalidRefreshToken = 6, + /// + /// Indicates that the account is manually blocked by an administrator. + /// + AccountBlocked = 7, + /// /// Indicates that the failure reason was unknown. /// diff --git a/src/Databases/AliasServerDb/Migrations/20241220164855_AddUserBlockedStatus.Designer.cs b/src/Databases/AliasServerDb/Migrations/20241220164855_AddUserBlockedStatus.Designer.cs new file mode 100644 index 000000000..137594d57 --- /dev/null +++ b/src/Databases/AliasServerDb/Migrations/20241220164855_AddUserBlockedStatus.Designer.cs @@ -0,0 +1,884 @@ +// +using System; +using AliasServerDb; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace AliasServerDb.Migrations +{ + [DbContext(typeof(AliasServerDbContext))] + [Migration("20241220164855_AddUserBlockedStatus")] + partial class AddUserBlockedStatus + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.0") + .HasAnnotation("Proxies:ChangeTracking", false) + .HasAnnotation("Proxies:CheckEquality", false) + .HasAnnotation("Proxies:LazyLoading", true); + + modelBuilder.Entity("AliasServerDb.AdminRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AdminRoles"); + }); + + modelBuilder.Entity("AliasServerDb.AdminUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastPasswordChanged") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AdminUsers"); + }); + + modelBuilder.Entity("AliasServerDb.AliasVaultRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AliasVaultRoles"); + }); + + modelBuilder.Entity("AliasServerDb.AliasVaultUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("Blocked") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasColumnType("TEXT"); + + b.Property("PasswordChangedAt") + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AliasVaultUsers"); + }); + + modelBuilder.Entity("AliasServerDb.AliasVaultUserRefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceIdentifier") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("ExpireDate") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("PreviousTokenValue") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AliasVaultUserRefreshTokens"); + }); + + modelBuilder.Entity("AliasServerDb.AuthLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdditionalInfo") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Browser") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Country") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("DeviceType") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("EventType") + .HasColumnType("nvarchar(50)"); + + b.Property("FailureReason") + .HasColumnType("INTEGER"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("IsSuccess") + .HasColumnType("INTEGER"); + + b.Property("IsSuspiciousActivity") + .HasColumnType("INTEGER"); + + b.Property("OperatingSystem") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("RequestPath") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("UserAgent") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "EventType" }, "IX_EventType"); + + b.HasIndex(new[] { "IpAddress" }, "IX_IpAddress"); + + b.HasIndex(new[] { "Timestamp" }, "IX_Timestamp"); + + b.HasIndex(new[] { "Username", "IsSuccess", "Timestamp" }, "IX_Username_IsSuccess_Timestamp") + .IsDescending(false, false, true); + + b.HasIndex(new[] { "Username", "Timestamp" }, "IX_Username_Timestamp") + .IsDescending(false, true); + + b.ToTable("AuthLogs"); + }); + + modelBuilder.Entity("AliasServerDb.Email", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("DateSystem") + .HasColumnType("TEXT"); + + b.Property("EncryptedSymmetricKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("From") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FromDomain") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FromLocal") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("MessageHtml") + .HasColumnType("TEXT"); + + b.Property("MessagePlain") + .HasColumnType("TEXT"); + + b.Property("MessagePreview") + .HasColumnType("TEXT"); + + b.Property("MessageSource") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PushNotificationSent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("To") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ToDomain") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ToLocal") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserEncryptionKeyId") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.HasIndex("DateSystem"); + + b.HasIndex("PushNotificationSent"); + + b.HasIndex("ToLocal"); + + b.HasIndex("UserEncryptionKeyId"); + + b.HasIndex("Visible"); + + b.ToTable("Emails"); + }); + + modelBuilder.Entity("AliasServerDb.EmailAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("EmailId") + .HasColumnType("INTEGER"); + + b.Property("Filename") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Filesize") + .HasColumnType("INTEGER"); + + b.Property("MimeType") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EmailId"); + + b.ToTable("EmailAttachments"); + }); + + modelBuilder.Entity("AliasServerDb.Log", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Application") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Exception") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Level") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LogEvent") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("LogEvent"); + + b.Property("Message") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("MessageTemplate") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Properties") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SourceContext") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TimeStamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Application"); + + b.HasIndex("TimeStamp"); + + b.ToTable("Logs", (string)null); + }); + + modelBuilder.Entity("AliasServerDb.ServerSetting", b => + { + b.Property("Key") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSettings"); + }); + + modelBuilder.Entity("AliasServerDb.TaskRunnerJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EndTime") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("IsOnDemand") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RunDate") + .HasColumnType("TEXT"); + + b.Property("StartTime") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("TaskRunnerJobs"); + }); + + modelBuilder.Entity("AliasServerDb.UserEmailClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Address") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AddressDomain") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AddressLocal") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Address") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("UserEmailClaims"); + }); + + modelBuilder.Entity("AliasServerDb.UserEncryptionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsPrimary") + .HasColumnType("INTEGER"); + + b.Property("PublicKey") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserEncryptionKeys"); + }); + + modelBuilder.Entity("AliasServerDb.Vault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CredentialsCount") + .HasColumnType("INTEGER"); + + b.Property("EmailClaimsCount") + .HasColumnType("INTEGER"); + + b.Property("EncryptionSettings") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("EncryptionType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FileSize") + .HasColumnType("INTEGER"); + + b.Property("RevisionNumber") + .HasColumnType("INTEGER"); + + b.Property("Salt") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("VaultBlob") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Verifier") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Vaults"); + }); + + modelBuilder.Entity("AliasVault.WorkerStatus.Database.WorkerServiceStatus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CurrentStatus") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("DesiredStatus") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Heartbeat") + .HasColumnType("TEXT"); + + b.Property("ServiceName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar"); + + b.HasKey("Id"); + + b.ToTable("WorkerServiceStatuses"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FriendlyName") + .HasColumnType("TEXT"); + + b.Property("Xml") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("AliasServerDb.AliasVaultUserRefreshToken", b => + { + b.HasOne("AliasServerDb.AliasVaultUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AliasServerDb.Email", b => + { + b.HasOne("AliasServerDb.UserEncryptionKey", "EncryptionKey") + .WithMany("Emails") + .HasForeignKey("UserEncryptionKeyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EncryptionKey"); + }); + + modelBuilder.Entity("AliasServerDb.EmailAttachment", b => + { + b.HasOne("AliasServerDb.Email", "Email") + .WithMany("Attachments") + .HasForeignKey("EmailId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Email"); + }); + + modelBuilder.Entity("AliasServerDb.UserEmailClaim", b => + { + b.HasOne("AliasServerDb.AliasVaultUser", "User") + .WithMany("EmailClaims") + .HasForeignKey("UserId"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AliasServerDb.UserEncryptionKey", b => + { + b.HasOne("AliasServerDb.AliasVaultUser", "User") + .WithMany("EncryptionKeys") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AliasServerDb.Vault", b => + { + b.HasOne("AliasServerDb.AliasVaultUser", "User") + .WithMany("Vaults") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AliasServerDb.AliasVaultUser", b => + { + b.Navigation("EmailClaims"); + + b.Navigation("EncryptionKeys"); + + b.Navigation("Vaults"); + }); + + modelBuilder.Entity("AliasServerDb.Email", b => + { + b.Navigation("Attachments"); + }); + + modelBuilder.Entity("AliasServerDb.UserEncryptionKey", b => + { + b.Navigation("Emails"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Databases/AliasServerDb/Migrations/20241220164855_AddUserBlockedStatus.cs b/src/Databases/AliasServerDb/Migrations/20241220164855_AddUserBlockedStatus.cs new file mode 100644 index 000000000..e129dc8b0 --- /dev/null +++ b/src/Databases/AliasServerDb/Migrations/20241220164855_AddUserBlockedStatus.cs @@ -0,0 +1,30 @@ +// +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace AliasServerDb.Migrations +{ + /// + public partial class AddUserBlockedStatus : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Blocked", + table: "AliasVaultUsers", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Blocked", + table: "AliasVaultUsers"); + } + } +} diff --git a/src/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs b/src/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs index 2817f8a67..e24bee53d 100644 --- a/src/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs +++ b/src/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs @@ -122,6 +122,9 @@ namespace AliasServerDb.Migrations b.Property("AccessFailedCount") .HasColumnType("INTEGER"); + b.Property("Blocked") + .HasColumnType("INTEGER"); + b.Property("ConcurrencyStamp") .HasColumnType("TEXT"); diff --git a/src/Shared/AliasVault.RazorComponents/Tables/SortableTable.razor b/src/Shared/AliasVault.RazorComponents/Tables/SortableTable.razor index c0352bc23..b9aeadc7a 100644 --- a/src/Shared/AliasVault.RazorComponents/Tables/SortableTable.razor +++ b/src/Shared/AliasVault.RazorComponents/Tables/SortableTable.razor @@ -1,5 +1,5 @@  - + @foreach (var column in Columns) { diff --git a/src/Tests/AliasVault.E2ETests/Tests/Client/Shard5/UserBlockedTests.cs b/src/Tests/AliasVault.E2ETests/Tests/Client/Shard5/UserBlockedTests.cs new file mode 100644 index 000000000..e65c72277 --- /dev/null +++ b/src/Tests/AliasVault.E2ETests/Tests/Client/Shard5/UserBlockedTests.cs @@ -0,0 +1,93 @@ +//----------------------------------------------------------------------- +// +// 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.E2ETests.Tests.Client.Shard5; + +using Microsoft.EntityFrameworkCore; + +/// +/// End-to-end tests for user block functionality. +/// +[Parallelizable(ParallelScope.Self)] +[Category("ClientTests")] +[TestFixture] +public class UserBlockedTests : ClientPlaywrightTest +{ + /// + /// Test that a blocked user cannot log in. + /// + /// Async task. + [Test] + public async Task BlockedUserLoginTest() + { + // First logout the current user + await Logout(); + + // Find the current test user in the database and set their blocked status to true + var user = await ApiDbContext.AliasVaultUsers.FirstAsync(x => x.UserName == TestUserUsername); + user.Blocked = true; + await ApiDbContext.SaveChangesAsync(); + + // Attempt to log in with the blocked user's credentials + await NavigateToLogin(); + + // Try to log in with test credentials + var emailField = await WaitForAndGetElement("input[id='email']"); + var passwordField = await WaitForAndGetElement("input[id='password']"); + await emailField.FillAsync(TestUserUsername); + await passwordField.FillAsync(TestUserPassword); + + var loginButton = await WaitForAndGetElement("button[type='submit']"); + await loginButton.ClickAsync(); + + // Check if we get an error message about the account being blocked + await WaitForUrlAsync("user/login**", "Your account has been disabled"); + + var pageContent = await Page.TextContentAsync("body"); + Assert.That(pageContent, Does.Contain("Your account has been disabled"), "No blocked account message shown when attempting to login with blocked account."); + } + + /// + /// Test that a blocked user gets logged out when their access token expires and cannot be refreshed. + /// + /// Async task. + [Test] + public async Task BlockedUserTokenRefreshTest() + { + // Make sure test user is not blocked. + var user = await ApiDbContext.AliasVaultUsers.FirstAsync(x => x.UserName == TestUserUsername); + user.Blocked = false; + await ApiDbContext.SaveChangesAsync(); + + // Make sure we're logged in. + await Logout(); + await Login(); + + // Wait for the index page to load which should show "Credentials" in the top menu. + await WaitForUrlAsync("**", "Credentials"); + + // First navigate to a test page to verify we're logged in + await NavigateUsingBlazorRouter("test/1"); + await WaitForUrlAsync("test/1", "Test 1 OK"); + + // Find the current test user in the database and set their blocked status to true + user.Blocked = true; + await ApiDbContext.SaveChangesAsync(); + + // Increase the time by 1 hour to make the JWT token expire + ApiTimeProvider.AdvanceBy(TimeSpan.FromHours(1)); + + // Navigate to another test page which will trigger a token refresh attempt + await NavigateUsingBlazorRouter("test/2"); + + // We should be redirected to the login page with an error message + await WaitForUrlAsync("user/login", "Log in to AliasVault"); + + var pageContent = await Page.TextContentAsync("body"); + Assert.That(pageContent, Does.Contain("Log in to AliasVault"), "No login page shown after token refresh attempt with blocked account."); + } +}