_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:
+
+
+ @(User.Blocked ? "Unblock User" : "Block User")
+
+
+
+ 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.");
+ }
+}