diff --git a/src/AliasVault.Api/Controllers/AuthController.cs b/src/AliasVault.Api/Controllers/AuthController.cs index 6493fd861..7759a3c6a 100644 --- a/src/AliasVault.Api/Controllers/AuthController.cs +++ b/src/AliasVault.Api/Controllers/AuthController.cs @@ -12,6 +12,7 @@ using System.Security.Claims; using System.Security.Cryptography; using System.Text; using AliasServerDb; +using AliasVault.Api.Helpers; using AliasVault.AuthLogging; using AliasVault.Shared.Models.Enums; using AliasVault.Shared.Models.WebApi; @@ -48,11 +49,6 @@ public class AuthController(IDbContextFactory dbContextFac /// private static readonly string[] InvalidUsernameOrPasswordError = ["Invalid username or password. Please try again."]; - /// - /// Error message for providing an invalid current password (during password change). - /// - private static readonly string[] InvalidCurrentPassword = ["The current password provided is invalid. Please try again."]; - /// /// Error message for invalid 2-factor authentication code. /// @@ -68,11 +64,6 @@ public class AuthController(IDbContextFactory dbContextFac /// 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.."]; - /// - /// Cache prefix for storing generated login ephemeral. - /// - private static readonly string CachePrefixEphemeral = "LoginEphemeral_"; - /// /// Login endpoint used to process login attempt using credentials. /// @@ -96,13 +87,13 @@ public class AuthController(IDbContextFactory dbContextFac } // Retrieve latest vault of user which contains the current salt and verifier. - var latestVaultSaltAndVerifier = GetUserLatestVaultSaltAndVerifier(user); + var latestVaultSaltAndVerifier = AuthHelper.GetUserLatestVaultSaltAndVerifier(user); // Server creates ephemeral and sends to client var ephemeral = Srp.GenerateEphemeralServer(latestVaultSaltAndVerifier.Verifier); // Store the server ephemeral in memory cache for Validate() endpoint to use. - cache.Set(CachePrefixEphemeral + model.Username, ephemeral.Secret, TimeSpan.FromMinutes(5)); + cache.Set(AuthHelper.CachePrefixEphemeral + model.Username, ephemeral.Secret, TimeSpan.FromMinutes(5)); return Ok(new LoginInitiateResponse(latestVaultSaltAndVerifier.Salt, ephemeral.Public)); } @@ -365,6 +356,9 @@ public class AuthController(IDbContextFactory dbContextFac /// /// Password change request is done by verifying the current password and then saving the new password via SRP. /// + /// The submit handler for the change password logic is in VaultController.UpdateChangePassword() + /// because changing the password of the AliasVault user also requires a new vault encrypted with that same + /// password in order for things to work properly. /// Task. [HttpGet("change-password/initiate")] [Authorize] @@ -377,57 +371,17 @@ public class AuthController(IDbContextFactory dbContextFac } // Retrieve latest vault of user which contains the current salt and verifier. - var latestVaultSaltAndVerifier = GetUserLatestVaultSaltAndVerifier(user); + var latestVaultSaltAndVerifier = AuthHelper.GetUserLatestVaultSaltAndVerifier(user); // Server creates ephemeral and sends to client var ephemeral = Srp.GenerateEphemeralServer(latestVaultSaltAndVerifier.Verifier); - // Store the server ephemeral in memory cache for Validate() endpoint to use. - cache.Set(CachePrefixEphemeral + user.UserName!, ephemeral.Secret, TimeSpan.FromMinutes(5)); + // Store the server ephemeral in memory cache for the Vault update (and set new password) endpoint to use. + cache.Set(AuthHelper.CachePrefixEphemeral + user.UserName!, ephemeral.Secret, TimeSpan.FromMinutes(5)); return Ok(new PasswordChangeInitiateResponse(latestVaultSaltAndVerifier.Salt, ephemeral.Public)); } - /// - /// Password change request is done by verifying the current password and then saving the new password via SRP. - /// - /// The password change initiation request model. - /// Task. - [HttpPost("change-password/process")] - [Authorize] - public async Task ProcessPasswordChange([FromBody] PasswordChangeRequest model) - { - var user = await userManager.GetUserAsync(User); - if (user == null) - { - return NotFound(ServerValidationErrorResponse.Create("User not found.", 404)); - } - - // Verify current password using SRP - var (_, _, error) = await ValidateUserAndPassword(new ValidateLoginRequest(user.UserName!, model.CurrentClientPublicEphemeral, model.CurrentClientSessionProof)); - - if (error != null) - { - await authLoggingService.LogAuthEventFailAsync(user.UserName!, AuthEventType.PasswordChange, AuthFailureReason.InvalidPassword); - return BadRequest(ServerValidationErrorResponse.Create(InvalidCurrentPassword, 400)); - } - - // Set new password using SRP. - user.Salt = model.NewPasswordSalt; - user.Verifier = model.NewPasswordVerifier; - user.UpdatedAt = DateTime.UtcNow; - var result = await userManager.UpdateAsync(user); - - if (result.Succeeded) - { - await authLoggingService.LogAuthEventSuccessAsync(user.UserName!, AuthEventType.PasswordChange); - return Ok(new { Message = "Password changed successfully." }); - } - - var errors = result.Errors.Select(e => e.Description).ToArray(); - return BadRequest(ServerValidationErrorResponse.Create(errors, 400)); - } - /// /// Generate a device identifier based on request headers. This is used to associate refresh tokens /// with a specific device for a specific user. @@ -529,7 +483,7 @@ public class AuthController(IDbContextFactory dbContextFac } // Validate the SRP session (actual password check). - var serverSession = ValidateSrpSession(user, model.ClientPublicEphemeral, model.ClientSessionProof); + var serverSession = AuthHelper.ValidateSrpSession(cache, user, model.ClientPublicEphemeral, model.ClientSessionProof); if (serverSession is null) { // Increment failed login attempts in order to lock out the account when the limit is reached. @@ -542,39 +496,6 @@ public class AuthController(IDbContextFactory dbContextFac return (user, serverSession, null); } - /// - /// Helper method that validates the SRP session based on provided username, ephemeral and proof. - /// - /// The user object. - /// The client ephemeral value. - /// The client session proof. - /// Tuple. - private SrpSession? ValidateSrpSession(AliasVaultUser user, string clientEphemeral, string clientSessionProof) - { - if (!cache.TryGetValue(CachePrefixEphemeral + user.UserName, out var serverSecretEphemeral) || serverSecretEphemeral is not string) - { - return null; - } - - // Retrieve latest vault of user which contains the current salt and verifier. - var latestVaultSaltAndVerifier = GetUserLatestVaultSaltAndVerifier(user); - - var serverSession = Srp.DeriveSessionServer( - serverSecretEphemeral.ToString() ?? string.Empty, - clientEphemeral, - latestVaultSaltAndVerifier.Salt, - user.UserName ?? string.Empty, - latestVaultSaltAndVerifier.Verifier, - clientSessionProof); - - if (serverSession is null) - { - return null; - } - - return serverSession; - } - /// /// Generate a Jwt access token for a user. This token is used to authenticate the user for a limited time /// and is short-lived by design. With the separate refresh token, the user can request a new access token @@ -638,16 +559,4 @@ public class AuthController(IDbContextFactory dbContextFac return new TokenModel { Token = token, RefreshToken = refreshToken }; } - - /// - /// Get the user's latest vault which contains the current salt and verifier. - /// - /// User object. - /// Tuple with salt and verifier. - private (string Salt, string Verifier) GetUserLatestVaultSaltAndVerifier(AliasVaultUser user) - { - // Retrieve latest vault of user which contains the current salt and verifier. - var latestVault = user.Vaults.OrderByDescending(x => x.UpdatedAt).Select(x => new { x.Salt, x.Verifier }).First(); - return (latestVault.Salt, latestVault.Verifier); - } } diff --git a/src/AliasVault.Api/Controllers/VaultController.cs b/src/AliasVault.Api/Controllers/VaultController.cs index a4975ee1c..32b1a1748 100644 --- a/src/AliasVault.Api/Controllers/VaultController.cs +++ b/src/AliasVault.Api/Controllers/VaultController.cs @@ -13,11 +13,16 @@ using AliasVault.Api.Controllers.Abstracts; using AliasVault.Api.Helpers; using AliasVault.Api.Vault; using AliasVault.Api.Vault.RetentionRules; +using AliasVault.AuthLogging; +using AliasVault.Shared.Models.Enums; +using AliasVault.Shared.Models.WebApi; +using AliasVault.Shared.Models.WebApi.PasswordChange; using AliasVault.Shared.Providers.Time; using Asp.Versioning; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; /// /// Vault controller for handling CRUD operations on the database for encrypted vault entities. @@ -26,9 +31,16 @@ using Microsoft.EntityFrameworkCore; /// DbContext instance. /// UserManager instance. /// ITimeProvider instance. +/// AuthLoggingService instance. +/// IMemoryCache instance. [ApiVersion("1")] -public class VaultController(ILogger logger, IDbContextFactory dbContextFactory, UserManager userManager, ITimeProvider timeProvider) : AuthenticatedRequestController(userManager) +public class VaultController(ILogger logger, IDbContextFactory dbContextFactory, UserManager userManager, ITimeProvider timeProvider, AuthLoggingService authLoggingService, IMemoryCache cache) : AuthenticatedRequestController(userManager) { + /// + /// Error message for providing an invalid current password (during password change). + /// + private static readonly string[] InvalidCurrentPassword = ["The current password provided is invalid. Please try again."]; + /// /// Default retention policy for vaults. /// @@ -95,7 +107,7 @@ public class VaultController(ILogger logger, IDbContextFactory< var latestVault = user.Vaults.OrderByDescending(x => x.UpdatedAt).Select(x => new { x.Salt, x.Verifier }).First(); // Create new vault entry with salt and verifier of current vault. - var newVault = new Vault + var newVault = new AliasServerDb.Vault { UserId = user.Id, VaultBlob = model.Blob, @@ -140,6 +152,68 @@ public class VaultController(ILogger logger, IDbContextFactory< return Ok(); } + /// + /// Save a new vault to the database based on a new encryption password for the current user. + /// + /// Vault model. + /// IActionResult. + [HttpPost("change-password")] + public async Task UpdateChangePassword([FromBody] VaultPasswordChangeRequest model) + { + await using var context = await dbContextFactory.CreateDbContextAsync(); + + var user = await GetCurrentUserAsync(); + if (user == null) + { + return Unauthorized(); + } + + // Validate the SRP session (actual password check). , + var serverSession = AuthHelper.ValidateSrpSession(cache, user, model.CurrentClientPublicEphemeral, model.CurrentClientSessionProof); + if (serverSession is null) + { + // Increment failed login attempts in order to lock out the account when the limit is reached. + await GetUserManager().AccessFailedAsync(user); + + await authLoggingService.LogAuthEventFailAsync(user.UserName!, AuthEventType.PasswordChange, AuthFailureReason.InvalidPassword); + return BadRequest(ServerValidationErrorResponse.Create(InvalidCurrentPassword, 400)); + } + + // Create new vault entry with salt and verifier of current vault. + var newVault = new AliasServerDb.Vault + { + UserId = user.Id, + VaultBlob = model.Blob, + Version = model.Version, + FileSize = FileHelper.Base64StringToKilobytes(model.Blob), + Salt = model.NewPasswordSalt, + Verifier = model.NewPasswordVerifier, + CreatedAt = timeProvider.UtcNow, + UpdatedAt = timeProvider.UtcNow, + }; + + // Run the vault retention manager to keep the required vaults according + // to the applied retention policies and delete the rest. + // We only select the Id and UpdatedAt fields to reduce the amount of data transferred from the database. + var existingVaults = await context.Vaults + .Where(x => x.UserId == user.Id) + .OrderByDescending(v => v.UpdatedAt) + .Select(x => new AliasServerDb.Vault { Id = x.Id, UpdatedAt = x.UpdatedAt }) + .ToListAsync(); + + var vaultsToDelete = VaultRetentionManager.ApplyRetention(_retentionPolicy, existingVaults, timeProvider.UtcNow, newVault); + + // Delete vaults that are not needed anymore. + context.Vaults.RemoveRange(vaultsToDelete); + + // Add the new vault and commit to database. + context.Vaults.Add(newVault); + await context.SaveChangesAsync(); + + await authLoggingService.LogAuthEventSuccessAsync(user.UserName!, AuthEventType.PasswordChange); + return Ok(new { Message = "Password changed successfully." }); + } + /// /// Updates the user's email claims based on the provided email address list. /// diff --git a/src/AliasVault.Api/Helpers/AuthHelper.cs b/src/AliasVault.Api/Helpers/AuthHelper.cs new file mode 100644 index 000000000..2145d87cd --- /dev/null +++ b/src/AliasVault.Api/Helpers/AuthHelper.cs @@ -0,0 +1,70 @@ +//----------------------------------------------------------------------- +// +// 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.Api.Helpers; + +using AliasServerDb; +using Cryptography.Client; +using Microsoft.Extensions.Caching.Memory; +using SecureRemotePassword; + +/// +/// AuthHelper class. +/// +public static class AuthHelper +{ + /// + /// Cache prefix for storing generated login ephemeral. + /// + public static readonly string CachePrefixEphemeral = "LoginEphemeral_"; + + /// + /// Helper method that validates the SRP session based on provided username, ephemeral and proof. + /// + /// IMemoryCache instance. + /// The user object. + /// The client ephemeral value. + /// The client session proof. + /// Tuple. + public static SrpSession? ValidateSrpSession(IMemoryCache cache, AliasVaultUser user, string clientEphemeral, string clientSessionProof) + { + if (!cache.TryGetValue(CachePrefixEphemeral + user.UserName, out var serverSecretEphemeral) || serverSecretEphemeral is not string) + { + return null; + } + + // Retrieve latest vault of user which contains the current salt and verifier. + var latestVaultSaltAndVerifier = GetUserLatestVaultSaltAndVerifier(user); + + var serverSession = Srp.DeriveSessionServer( + serverSecretEphemeral.ToString() ?? string.Empty, + clientEphemeral, + latestVaultSaltAndVerifier.Salt, + user.UserName ?? string.Empty, + latestVaultSaltAndVerifier.Verifier, + clientSessionProof); + + if (serverSession is null) + { + return null; + } + + return serverSession; + } + + /// + /// Get the user's latest vault which contains the current salt and verifier. + /// + /// User object. + /// Tuple with salt and verifier. + public static (string Salt, string Verifier) GetUserLatestVaultSaltAndVerifier(AliasVaultUser user) + { + // Retrieve latest vault of user which contains the current salt and verifier. + var latestVault = user.Vaults.OrderByDescending(x => x.UpdatedAt).Select(x => new { x.Salt, x.Verifier }).First(); + return (latestVault.Salt, latestVault.Verifier); + } +} diff --git a/src/AliasVault.Api/Program.cs b/src/AliasVault.Api/Program.cs index 5e37d8e89..1090274e3 100644 --- a/src/AliasVault.Api/Program.cs +++ b/src/AliasVault.Api/Program.cs @@ -28,8 +28,8 @@ builder.Services.ConfigureLogging(builder.Configuration, Assembly.GetExecutingAs builder.Services.AddSingleton(); builder.Services.AddScoped(); -builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); +builder.Services.AddHttpContextAccessor(); builder.Services.AddLogging(logging => { diff --git a/src/AliasVault.Client/Main/Pages/Settings/Security/ChangePassword.razor b/src/AliasVault.Client/Main/Pages/Settings/Security/ChangePassword.razor index f932409b9..8fa98ab8f 100644 --- a/src/AliasVault.Client/Main/Pages/Settings/Security/ChangePassword.razor +++ b/src/AliasVault.Client/Main/Pages/Settings/Security/ChangePassword.razor @@ -146,11 +146,31 @@ else byte[] newPasswordHash = await Encryption.DeriveKeyFromPasswordAsync(PasswordChangeModel.NewPassword, newSalt); var newPasswordHashString = BitConverter.ToString(newPasswordHash).Replace("-", string.Empty); + // Backup current password hash in case of failure. + var backupPasswordHash = AuthService.GetEncryptionKey(); + + // Set new currentPasswordHash locally as it is required for the new database encryption call below so + // it is encrypted with the new password hash. + AuthService.StoreEncryptionKey(newPasswordHash); + // TODO: rename Srp.SignupPrepareAsync to something more generic as its used for both signup and password change now. var srpSignup = Srp.SignupPrepareAsync(client, newSalt, username, newPasswordHashString); + // Prepare new vault model to update to. + var databaseVersion = await DbService.GetCurrentDatabaseVersionAsync(); + var encryptedBase64String = await DbService.GetEncryptedDatabaseBase64String(); + var vaultPasswordChangeObject = new VaultPasswordChangeRequest( + encryptedBase64String, + databaseVersion, + DateTime.Now, + DateTime.Now, + ClientEphemeral.Public, + ClientSession.Proof, + srpSignup.Salt, + srpSignup.Verifier); + // 4. Client sends proof of session key to server. - var response = await Http.PostAsJsonAsync("api/v1/Auth/change-password/process", new PasswordChangeRequest(ClientEphemeral.Public, ClientSession.Proof, srpSignup.Salt, srpSignup.Verifier)); + var response = await Http.PostAsJsonAsync("api/v1/Vault/change-password", vaultPasswordChangeObject); var responseContent = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) @@ -163,17 +183,14 @@ else // Clear form. PasswordChangeModel = new PasswordChangeModel(); + // Set currentPasswordHash back to original so we're back to the original state. + AuthService.StoreEncryptionKey(backupPasswordHash); + GlobalLoadingSpinner.Hide(); StateHasChanged(); return; } - // Set new currentPasswordHash locally. - AuthService.StoreEncryptionKey(newPasswordHash); - - // Upload new database encrypted with new password. - await DbService.SaveDatabaseAsync(); - // Set success message. GlobalNotificationService.AddSuccessMessage("Password changed successfully.", true); diff --git a/src/AliasVault.Client/Services/Auth/AuthService.cs b/src/AliasVault.Client/Services/Auth/AuthService.cs index 53748e7df..5845e2c13 100644 --- a/src/AliasVault.Client/Services/Auth/AuthService.cs +++ b/src/AliasVault.Client/Services/Auth/AuthService.cs @@ -96,7 +96,7 @@ public sealed class AuthService(HttpClient httpClient, ILocalStorageService loca /// Get encryption key. /// /// SrpArgonEncryption key as byte[]. - public byte[] GetEncryptionKeyAsync() + public byte[] GetEncryptionKey() { return _encryptionKey; } @@ -115,7 +115,7 @@ public sealed class AuthService(HttpClient httpClient, ILocalStorageService loca return "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB="; } - return Convert.ToBase64String(GetEncryptionKeyAsync()); + return Convert.ToBase64String(GetEncryptionKey()); } /// diff --git a/src/AliasVault.Shared/Models/WebApi/PasswordChange/PasswordChangeResponse.cs b/src/AliasVault.Shared/Models/WebApi/PasswordChange/PasswordChangeResponse.cs deleted file mode 100644 index b9627a8a9..000000000 --- a/src/AliasVault.Shared/Models/WebApi/PasswordChange/PasswordChangeResponse.cs +++ /dev/null @@ -1,36 +0,0 @@ -//----------------------------------------------------------------------- -// -// 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.Shared.Models.WebApi.PasswordChange -{ - /// - /// Represents a response to initiate a password change. - /// - public class PasswordChangeResponse - { - /// - /// Initializes a new instance of the class. - /// - /// New salt. - /// Current password server proof. - public PasswordChangeResponse(string newSalt, string currentPasswordServerProof) - { - NewSalt = newSalt; - CurrentPasswordServerProof = currentPasswordServerProof; - } - - /// - /// Gets or sets the new salt for the new password. - /// - public string NewSalt { get; set; } - - /// - /// Gets or sets the server's proof for the current password verification. - /// - public string CurrentPasswordServerProof { get; set; } - } -} diff --git a/src/AliasVault.Shared/Models/WebApi/PasswordChange/PasswordChangeRequest.cs b/src/AliasVault.Shared/Models/WebApi/PasswordChange/VaultPasswordChangeRequest.cs similarity index 63% rename from src/AliasVault.Shared/Models/WebApi/PasswordChange/PasswordChangeRequest.cs rename to src/AliasVault.Shared/Models/WebApi/PasswordChange/VaultPasswordChangeRequest.cs index 0f820cd5b..ed2d8e9b1 100644 --- a/src/AliasVault.Shared/Models/WebApi/PasswordChange/PasswordChangeRequest.cs +++ b/src/AliasVault.Shared/Models/WebApi/PasswordChange/VaultPasswordChangeRequest.cs @@ -1,5 +1,5 @@ //----------------------------------------------------------------------- -// +// // Copyright (c) lanedirt. All rights reserved. // Licensed under the MIT license. See LICENSE.md file in the project root for full license information. // @@ -8,18 +8,31 @@ namespace AliasVault.Shared.Models.WebApi.PasswordChange; /// -/// Represents a request to initiate a password change. +/// Represents a request to change the users password including a new vault that is encrypted with the new password. /// -public class PasswordChangeRequest +public class VaultPasswordChangeRequest : Vault { /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// + /// Blob. + /// Version of the vault data model (migration). + /// CreatedAt. + /// UpdatedAt. /// Client public ephemeral. /// Client session proof. /// New password salt. /// New password verifier. - public PasswordChangeRequest(string currentClientPublicEphemeral, string currentClientSessionProof, string newPasswordSalt, string newPasswordVerifier) + public VaultPasswordChangeRequest( + string blob, + string version, + DateTime createdAt, + DateTime updatedAt, + string currentClientPublicEphemeral, + string currentClientSessionProof, + string newPasswordSalt, + string newPasswordVerifier) + : base(blob, version, string.Empty, new List(), createdAt, updatedAt) { CurrentClientPublicEphemeral = currentClientPublicEphemeral; CurrentClientSessionProof = currentClientSessionProof;