From 224e4ee7415914e8eda557c825b4eed78dffd7b6 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Sat, 15 Nov 2025 11:50:23 +0100 Subject: [PATCH] Add mobile unlock request database and API scaffolding (#1347) --- .../Controllers/AuthController.cs | 185 ++++ .../AliasServerDb/AliasServerDbContext.cs | 5 + ...5104944_AddMobileUnlockRequest.Designer.cs | 971 ++++++++++++++++++ .../20251115104944_AddMobileUnlockRequest.cs | 45 + .../AliasServerDbContextModelSnapshot.cs | 47 + .../AliasServerDb/MobileUnlockRequest.cs | 84 ++ .../Models/Enums/ApiErrorCode.cs | 10 + .../Auth/MobileUnlockInitiateRequest.cs | 14 + .../Auth/MobileUnlockInitiateResponse.cs | 14 + .../WebApi/Auth/MobileUnlockPollResponse.cs | 27 + .../WebApi/Auth/MobileUnlockSubmitRequest.cs | 16 + 11 files changed, 1418 insertions(+) create mode 100644 apps/server/Databases/AliasServerDb/Migrations/20251115104944_AddMobileUnlockRequest.Designer.cs create mode 100644 apps/server/Databases/AliasServerDb/Migrations/20251115104944_AddMobileUnlockRequest.cs create mode 100644 apps/server/Databases/AliasServerDb/MobileUnlockRequest.cs create mode 100644 apps/server/Shared/AliasVault.Shared/Models/WebApi/Auth/MobileUnlockInitiateRequest.cs create mode 100644 apps/server/Shared/AliasVault.Shared/Models/WebApi/Auth/MobileUnlockInitiateResponse.cs create mode 100644 apps/server/Shared/AliasVault.Shared/Models/WebApi/Auth/MobileUnlockPollResponse.cs create mode 100644 apps/server/Shared/AliasVault.Shared/Models/WebApi/Auth/MobileUnlockSubmitRequest.cs diff --git a/apps/server/AliasVault.Api/Controllers/AuthController.cs b/apps/server/AliasVault.Api/Controllers/AuthController.cs index 3be6e48f4..82e934d9d 100644 --- a/apps/server/AliasVault.Api/Controllers/AuthController.cs +++ b/apps/server/AliasVault.Api/Controllers/AuthController.cs @@ -533,6 +533,191 @@ public class AuthController(IAliasServerDbContextFactory dbContextFactory, UserM latestVaultEncryptionSettings.EncryptionSettings)); } + /// + /// Initiates a mobile unlock request by creating a QR code challenge. + /// + /// The mobile unlock initiate request model. + /// IActionResult. + [HttpPost("mobile-unlock/initiate")] + [AllowAnonymous] + public async Task InitiateMobileUnlock([FromBody] MobileUnlockInitiateRequest model) + { + await using var context = await dbContextFactory.CreateDbContextAsync(); + + // Generate a unique request ID + var requestId = Guid.NewGuid().ToString("N"); + + // Create the unlock request + var unlockRequest = new MobileUnlockRequest + { + Id = requestId, + ClientPublicKey = model.ClientPublicKey, + Fulfilled = false, + CreatedAt = timeProvider.UtcNow, + ExpiresAt = timeProvider.UtcNow.AddMinutes(2), + ClientIpAddress = IpAddressUtility.GetIpFromContext(HttpContext), + }; + + context.MobileUnlockRequests.Add(unlockRequest); + await context.SaveChangesAsync(); + + return Ok(new MobileUnlockInitiateResponse(requestId)); + } + + /// + /// Polls the status of a mobile unlock request. + /// + /// The unique identifier for the unlock request. + /// IActionResult. + [HttpGet("mobile-unlock/poll/{requestId}")] + [AllowAnonymous] + public async Task PollMobileUnlock(string requestId) + { + await using var context = await dbContextFactory.CreateDbContextAsync(); + + var unlockRequest = await context.MobileUnlockRequests.FirstOrDefaultAsync(r => r.Id == requestId); + + // Check if request exists and hasn't expired + if (unlockRequest == null || unlockRequest.ExpiresAt < timeProvider.UtcNow) + { + // Clean up expired request if it exists + if (unlockRequest != null) + { + context.MobileUnlockRequests.Remove(unlockRequest); + await context.SaveChangesAsync(); + } + + return NotFound(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.MOBILE_UNLOCK_REQUEST_NOT_FOUND, 404)); + } + + // If not fulfilled, return pending status + if (!unlockRequest.Fulfilled) + { + return Ok(new MobileUnlockPollResponse(false, null, null, null, null, null, null)); + } + + // Request is fulfilled - get user and generate token + var user = await userManager.FindByNameAsync(unlockRequest.Username!); + if (user == null) + { + // Clean up the request + context.MobileUnlockRequests.Remove(unlockRequest); + await context.SaveChangesAsync(); + return BadRequest(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.USER_NOT_FOUND, 400)); + } + + // Check if the account is blocked. + if (user.Blocked) + { + context.MobileUnlockRequests.Remove(unlockRequest); + await context.SaveChangesAsync(); + await authLoggingService.LogAuthEventFailAsync(user.UserName!, AuthEventType.Login, AuthFailureReason.AccountBlocked); + return BadRequest(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.ACCOUNT_BLOCKED, 400)); + } + + // Check if the account is locked out. + if (await userManager.IsLockedOutAsync(user)) + { + context.MobileUnlockRequests.Remove(unlockRequest); + await context.SaveChangesAsync(); + await authLoggingService.LogAuthEventFailAsync(user.UserName!, AuthEventType.Login, AuthFailureReason.AccountLocked); + return BadRequest(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.ACCOUNT_LOCKED, 400)); + } + + // Generate token for the user + var tokenModel = await GenerateNewTokensForUser(user, extendedLifetime: true); + + // Log successful authentication + await authLoggingService.LogAuthEventSuccessAsync(user.UserName!, AuthEventType.Login); + + // Reset failed login attempts. + await userManager.ResetAccessFailedCountAsync(user); + + // Return fulfilled response with encrypted key and token + var response = new MobileUnlockPollResponse( + true, + unlockRequest.EncryptedDecryptionKey, + unlockRequest.Username, + tokenModel, + unlockRequest.Salt, + unlockRequest.EncryptionType, + unlockRequest.EncryptionSettings); + + // Clear sensitive data but keep the record for statistics + unlockRequest.ClientPublicKey = string.Empty; + unlockRequest.EncryptedDecryptionKey = null; + unlockRequest.Salt = null; + unlockRequest.EncryptionType = null; + unlockRequest.EncryptionSettings = null; + unlockRequest.RetrievedAt = timeProvider.UtcNow; + await context.SaveChangesAsync(); + + return Ok(response); + } + + /// + /// Submits a mobile unlock response from the mobile app. + /// + /// The mobile unlock submit request model. + /// IActionResult. + [HttpPost("mobile-unlock/submit")] + [Authorize] + public async Task SubmitMobileUnlock([FromBody] MobileUnlockSubmitRequest model) + { + await using var context = await dbContextFactory.CreateDbContextAsync(); + + // Get the authenticated user + var user = await userManager.GetUserAsync(User); + if (user == null) + { + return Unauthorized(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.USER_NOT_FOUND, 401)); + } + + // Verify the username matches the authenticated user + if (user.UserName != model.Username) + { + return BadRequest(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.USERNAME_MISMATCH, 400)); + } + + var unlockRequest = await context.MobileUnlockRequests.FirstOrDefaultAsync(r => r.Id == model.RequestId); + + // Check if request exists and hasn't expired + if (unlockRequest == null || unlockRequest.ExpiresAt < timeProvider.UtcNow) + { + // Clean up expired request if it exists + if (unlockRequest != null) + { + context.MobileUnlockRequests.Remove(unlockRequest); + await context.SaveChangesAsync(); + } + + return NotFound(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.MOBILE_UNLOCK_REQUEST_NOT_FOUND, 404)); + } + + // Check if already fulfilled + if (unlockRequest.Fulfilled) + { + return BadRequest(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.MOBILE_UNLOCK_REQUEST_ALREADY_FULFILLED, 400)); + } + + // Get latest vault encryption settings for the user + var latestVaultEncryptionSettings = AuthHelper.GetUserLatestVaultEncryptionSettings(user); + + // Update the unlock request with the encrypted key and user info + unlockRequest.EncryptedDecryptionKey = model.EncryptedDecryptionKey; + unlockRequest.Username = model.Username; + unlockRequest.Salt = latestVaultEncryptionSettings.Salt; + unlockRequest.EncryptionType = latestVaultEncryptionSettings.EncryptionType; + unlockRequest.EncryptionSettings = latestVaultEncryptionSettings.EncryptionSettings; + unlockRequest.Fulfilled = true; + unlockRequest.FulfilledAt = timeProvider.UtcNow; + unlockRequest.MobileIpAddress = IpAddressUtility.GetIpFromContext(HttpContext); + + await context.SaveChangesAsync(); + + return Ok(); + } + /// /// Confirms the account deletion process. /// diff --git a/apps/server/Databases/AliasServerDb/AliasServerDbContext.cs b/apps/server/Databases/AliasServerDb/AliasServerDbContext.cs index 41e7f74e3..e6dc25a1f 100644 --- a/apps/server/Databases/AliasServerDb/AliasServerDbContext.cs +++ b/apps/server/Databases/AliasServerDb/AliasServerDbContext.cs @@ -137,6 +137,11 @@ public class AliasServerDbContext : WorkerStatusDbContext, IDataProtectionKeyCon /// public DbSet TaskRunnerJobs { get; set; } + /// + /// Gets or sets the MobileUnlockRequests DbSet. + /// + public DbSet MobileUnlockRequests { get; set; } + /// /// Sets up the connection string if it is not already configured. /// diff --git a/apps/server/Databases/AliasServerDb/Migrations/20251115104944_AddMobileUnlockRequest.Designer.cs b/apps/server/Databases/AliasServerDb/Migrations/20251115104944_AddMobileUnlockRequest.Designer.cs new file mode 100644 index 000000000..7978225d2 --- /dev/null +++ b/apps/server/Databases/AliasServerDb/Migrations/20251115104944_AddMobileUnlockRequest.Designer.cs @@ -0,0 +1,971 @@ +// +using System; +using AliasServerDb; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace AliasServerDb.Migrations +{ + [DbContext(typeof(AliasServerDbContext))] + [Migration("20251115104944_AddMobileUnlockRequest")] + partial class AddMobileUnlockRequest + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.4") + .HasAnnotation("Proxies:ChangeTracking", false) + .HasAnnotation("Proxies:CheckEquality", false) + .HasAnnotation("Proxies:LazyLoading", true) + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + 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("boolean"); + + b.Property("LastPasswordChanged") + .HasColumnType("timestamp with time zone"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasColumnType("text"); + + b.Property("NormalizedUserName") + .HasColumnType("text"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + 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("boolean"); + + b.Property("ConcurrencyStamp") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LastActivityDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("MaxEmailAgeDays") + .HasColumnType("integer"); + + b.Property("MaxEmails") + .HasColumnType("integer"); + + b.Property("NormalizedEmail") + .HasColumnType("text"); + + b.Property("NormalizedUserName") + .HasColumnType("text"); + + b.Property("PasswordChangedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserName") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("AliasVaultUsers"); + }); + + modelBuilder.Entity("AliasServerDb.AliasVaultUserRefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeviceIdentifier") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpireDate") + .HasMaxLength(255) + .HasColumnType("timestamp with time zone"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("PreviousTokenValue") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AliasVaultUserRefreshTokens"); + }); + + modelBuilder.Entity("AliasServerDb.AuthLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdditionalInfo") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Browser") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Client") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Country") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DeviceType") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventType") + .HasColumnType("integer"); + + b.Property("FailureReason") + .HasColumnType("integer"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IsSuccess") + .HasColumnType("boolean"); + + b.Property("IsSuspiciousActivity") + .HasColumnType("boolean"); + + b.Property("OperatingSystem") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequestPath") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + 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"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("DateSystem") + .HasColumnType("timestamp with time zone"); + + 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("boolean"); + + 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("uuid"); + + b.Property("Visible") + .HasColumnType("boolean"); + + 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"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Bytes") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + 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"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Application") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Exception") + .IsRequired() + .HasColumnType("text"); + + b.Property("Level") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + 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() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("TimeStamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Application"); + + b.HasIndex("TimeStamp"); + + b.ToTable("Logs", (string)null); + }); + + modelBuilder.Entity("AliasServerDb.MobileUnlockRequest", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ClientIpAddress") + .HasColumnType("text"); + + b.Property("ClientPublicKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedDecryptionKey") + .HasColumnType("text"); + + b.Property("EncryptionSettings") + .HasColumnType("text"); + + b.Property("EncryptionType") + .HasColumnType("text"); + + b.Property("Fulfilled") + .HasColumnType("boolean"); + + b.Property("FulfilledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MobileIpAddress") + .HasColumnType("text"); + + b.Property("RetrievedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Salt") + .HasColumnType("text"); + + b.Property("Username") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("MobileUnlockRequests"); + }); + + modelBuilder.Entity("AliasServerDb.ServerSetting", b => + { + b.Property("Key") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Key"); + + b.ToTable("ServerSettings"); + }); + + modelBuilder.Entity("AliasServerDb.TaskRunnerJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("EndTime") + .HasColumnType("time without time zone"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("IsOnDemand") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RunDate") + .HasColumnType("timestamp with time zone"); + + b.Property("StartTime") + .HasColumnType("time without time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("TaskRunnerJobs"); + }); + + modelBuilder.Entity("AliasServerDb.UserEmailClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AddressDomain") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AddressLocal") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Disabled") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.HasIndex("Address") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("UserEmailClaims"); + }); + + modelBuilder.Entity("AliasServerDb.UserEncryptionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.Property("PublicKey") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserEncryptionKeys"); + }); + + modelBuilder.Entity("AliasServerDb.Vault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Client") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + 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("bigint"); + + b.Property("Salt") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("VaultBlob") + .IsRequired() + .HasColumnType("text"); + + b.Property("Verifier") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Vaults"); + }); + + modelBuilder.Entity("AliasVault.WorkerStatus.Database.WorkerServiceStatus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CurrentStatus") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DesiredStatus") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Heartbeat") + .HasColumnType("timestamp with time zone"); + + b.Property("ServiceName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.ToTable("WorkerServiceStatuses"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + 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"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + 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"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + 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") + .OnDelete(DeleteBehavior.SetNull); + + 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/apps/server/Databases/AliasServerDb/Migrations/20251115104944_AddMobileUnlockRequest.cs b/apps/server/Databases/AliasServerDb/Migrations/20251115104944_AddMobileUnlockRequest.cs new file mode 100644 index 000000000..c62609e24 --- /dev/null +++ b/apps/server/Databases/AliasServerDb/Migrations/20251115104944_AddMobileUnlockRequest.cs @@ -0,0 +1,45 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace AliasServerDb.Migrations +{ + /// + public partial class AddMobileUnlockRequest : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "MobileUnlockRequests", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + ClientPublicKey = table.Column(type: "text", nullable: false), + EncryptedDecryptionKey = table.Column(type: "text", nullable: true), + Username = table.Column(type: "text", nullable: true), + Salt = table.Column(type: "text", nullable: true), + EncryptionType = table.Column(type: "text", nullable: true), + EncryptionSettings = table.Column(type: "text", nullable: true), + Fulfilled = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + FulfilledAt = table.Column(type: "timestamp with time zone", nullable: true), + RetrievedAt = table.Column(type: "timestamp with time zone", nullable: true), + ClientIpAddress = table.Column(type: "text", nullable: true), + MobileIpAddress = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_MobileUnlockRequests", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "MobileUnlockRequests"); + } + } +} diff --git a/apps/server/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs b/apps/server/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs index 23cf10910..82e2439c1 100644 --- a/apps/server/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs +++ b/apps/server/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs @@ -488,6 +488,53 @@ namespace AliasServerDb.Migrations b.ToTable("Logs", (string)null); }); + modelBuilder.Entity("AliasServerDb.MobileUnlockRequest", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ClientIpAddress") + .HasColumnType("text"); + + b.Property("ClientPublicKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedDecryptionKey") + .HasColumnType("text"); + + b.Property("EncryptionSettings") + .HasColumnType("text"); + + b.Property("EncryptionType") + .HasColumnType("text"); + + b.Property("Fulfilled") + .HasColumnType("boolean"); + + b.Property("FulfilledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MobileIpAddress") + .HasColumnType("text"); + + b.Property("RetrievedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Salt") + .HasColumnType("text"); + + b.Property("Username") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("MobileUnlockRequests"); + }); + modelBuilder.Entity("AliasServerDb.ServerSetting", b => { b.Property("Key") diff --git a/apps/server/Databases/AliasServerDb/MobileUnlockRequest.cs b/apps/server/Databases/AliasServerDb/MobileUnlockRequest.cs new file mode 100644 index 000000000..0500fe294 --- /dev/null +++ b/apps/server/Databases/AliasServerDb/MobileUnlockRequest.cs @@ -0,0 +1,84 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) aliasvault. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasServerDb; + +/// +/// Mobile unlock request entity for storing temporary unlock requests. +/// +public class MobileUnlockRequest +{ + /// + /// Gets or sets the unique identifier for this unlock request. + /// + public string Id { get; set; } = string.Empty; + + /// + /// Gets or sets the public key from the client (base64 encoded). + /// + public string ClientPublicKey { get; set; } = string.Empty; + + /// + /// Gets or sets the encrypted decryption key from the mobile app (base64 encoded). + /// Will be null until mobile app responds. + /// + public string? EncryptedDecryptionKey { get; set; } + + /// + /// Gets or sets the username provided by the mobile app. + /// Will be null until mobile app responds. + /// + public string? Username { get; set; } + + /// + /// Gets or sets the salt for the user. + /// Will be populated when mobile app provides the username. + /// + public string? Salt { get; set; } + + /// + /// Gets or sets the encryption type for the user. + /// Will be populated when mobile app provides the username. + /// + public string? EncryptionType { get; set; } + + /// + /// Gets or sets the encryption settings for the user. + /// Will be populated when mobile app provides the username. + /// + public string? EncryptionSettings { get; set; } + + /// + /// Gets or sets a value indicating whether this request has been fulfilled. + /// + public bool Fulfilled { get; set; } + + /// + /// Gets or sets the created timestamp. + /// + public DateTime CreatedAt { get; set; } + + /// + /// Gets or sets the fulfilled timestamp (when mobile app submitted the response). + /// + public DateTime? FulfilledAt { get; set; } + + /// + /// Gets or sets the retrieved timestamp (when client successfully retrieved and decrypted). + /// + public DateTime? RetrievedAt { get; set; } + + /// + /// Gets or sets the IP address of the client that initiated the request. + /// + public string? ClientIpAddress { get; set; } + + /// + /// Gets or sets the IP address of the mobile device that fulfilled the request. + /// + public string? MobileIpAddress { get; set; } +} diff --git a/apps/server/Shared/AliasVault.Shared/Models/Enums/ApiErrorCode.cs b/apps/server/Shared/AliasVault.Shared/Models/Enums/ApiErrorCode.cs index 2b92b1e40..02b556682 100644 --- a/apps/server/Shared/AliasVault.Shared/Models/Enums/ApiErrorCode.cs +++ b/apps/server/Shared/AliasVault.Shared/Models/Enums/ApiErrorCode.cs @@ -133,4 +133,14 @@ public enum ApiErrorCode /// Vault is not up-to-date and requires synchronization. /// VAULT_NOT_UP_TO_DATE, + + /// + /// Mobile unlock request not found or expired. + /// + MOBILE_UNLOCK_REQUEST_NOT_FOUND, + + /// + /// Mobile unlock request already fulfilled. + /// + MOBILE_UNLOCK_REQUEST_ALREADY_FULFILLED, } diff --git a/apps/server/Shared/AliasVault.Shared/Models/WebApi/Auth/MobileUnlockInitiateRequest.cs b/apps/server/Shared/AliasVault.Shared/Models/WebApi/Auth/MobileUnlockInitiateRequest.cs new file mode 100644 index 000000000..b9f5ec0fd --- /dev/null +++ b/apps/server/Shared/AliasVault.Shared/Models/WebApi/Auth/MobileUnlockInitiateRequest.cs @@ -0,0 +1,14 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) aliasvault. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Shared.Models.WebApi.Auth; + +/// +/// Request model for initiating a mobile unlock request. +/// +/// The public key from the client (base64 encoded). +public record MobileUnlockInitiateRequest(string ClientPublicKey); diff --git a/apps/server/Shared/AliasVault.Shared/Models/WebApi/Auth/MobileUnlockInitiateResponse.cs b/apps/server/Shared/AliasVault.Shared/Models/WebApi/Auth/MobileUnlockInitiateResponse.cs new file mode 100644 index 000000000..9f319ca71 --- /dev/null +++ b/apps/server/Shared/AliasVault.Shared/Models/WebApi/Auth/MobileUnlockInitiateResponse.cs @@ -0,0 +1,14 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) aliasvault. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Shared.Models.WebApi.Auth; + +/// +/// Response model for mobile unlock initiate request. +/// +/// The unique identifier for this unlock request. +public record MobileUnlockInitiateResponse(string RequestId); diff --git a/apps/server/Shared/AliasVault.Shared/Models/WebApi/Auth/MobileUnlockPollResponse.cs b/apps/server/Shared/AliasVault.Shared/Models/WebApi/Auth/MobileUnlockPollResponse.cs new file mode 100644 index 000000000..0f21ce0ea --- /dev/null +++ b/apps/server/Shared/AliasVault.Shared/Models/WebApi/Auth/MobileUnlockPollResponse.cs @@ -0,0 +1,27 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) aliasvault. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Shared.Models.WebApi.Auth; + +/// +/// Response model for polling mobile unlock status. +/// +/// Whether the request has been fulfilled by the mobile app. +/// The encrypted decryption key (base64 encoded) if fulfilled. +/// The username of the user logging in if fulfilled. +/// The authentication token if fulfilled. +/// The salt for key derivation if fulfilled. +/// The encryption type if fulfilled. +/// The encryption settings if fulfilled. +public record MobileUnlockPollResponse( + bool Fulfilled, + string? EncryptedDecryptionKey, + string? Username, + TokenModel? Token, + string? Salt, + string? EncryptionType, + string? EncryptionSettings); diff --git a/apps/server/Shared/AliasVault.Shared/Models/WebApi/Auth/MobileUnlockSubmitRequest.cs b/apps/server/Shared/AliasVault.Shared/Models/WebApi/Auth/MobileUnlockSubmitRequest.cs new file mode 100644 index 000000000..1545dbd1a --- /dev/null +++ b/apps/server/Shared/AliasVault.Shared/Models/WebApi/Auth/MobileUnlockSubmitRequest.cs @@ -0,0 +1,16 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) aliasvault. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Shared.Models.WebApi.Auth; + +/// +/// Request model for submitting mobile unlock response from mobile app. +/// +/// The unique identifier for this unlock request. +/// The encrypted decryption key (base64 encoded). +/// The username of the user logging in. +public record MobileUnlockSubmitRequest(string RequestId, string EncryptedDecryptionKey, string Username);