Refactored change password to change pass and upload new vault in one atomic webapi operation (#200)

This commit is contained in:
Leendert de Borst
2024-09-02 19:21:18 +02:00
parent 6e6f24417a
commit b2aed24d8a
8 changed files with 201 additions and 154 deletions

View File

@@ -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<AliasServerDbContext> dbContextFac
/// </summary>
private static readonly string[] InvalidUsernameOrPasswordError = ["Invalid username or password. Please try again."];
/// <summary>
/// Error message for providing an invalid current password (during password change).
/// </summary>
private static readonly string[] InvalidCurrentPassword = ["The current password provided is invalid. Please try again."];
/// <summary>
/// Error message for invalid 2-factor authentication code.
/// </summary>
@@ -68,11 +64,6 @@ public class AuthController(IDbContextFactory<AliasServerDbContext> dbContextFac
/// </summary>
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.."];
/// <summary>
/// Cache prefix for storing generated login ephemeral.
/// </summary>
private static readonly string CachePrefixEphemeral = "LoginEphemeral_";
/// <summary>
/// Login endpoint used to process login attempt using credentials.
/// </summary>
@@ -96,13 +87,13 @@ public class AuthController(IDbContextFactory<AliasServerDbContext> 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<AliasServerDbContext> dbContextFac
/// <summary>
/// Password change request is done by verifying the current password and then saving the new password via SRP.
/// </summary>
/// <remarks>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.</remarks>
/// <returns>Task.</returns>
[HttpGet("change-password/initiate")]
[Authorize]
@@ -377,57 +371,17 @@ public class AuthController(IDbContextFactory<AliasServerDbContext> 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));
}
/// <summary>
/// Password change request is done by verifying the current password and then saving the new password via SRP.
/// </summary>
/// <param name="model">The password change initiation request model.</param>
/// <returns>Task.</returns>
[HttpPost("change-password/process")]
[Authorize]
public async Task<IActionResult> 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));
}
/// <summary>
/// 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<AliasServerDbContext> 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<AliasServerDbContext> dbContextFac
return (user, serverSession, null);
}
/// <summary>
/// Helper method that validates the SRP session based on provided username, ephemeral and proof.
/// </summary>
/// <param name="user">The user object.</param>
/// <param name="clientEphemeral">The client ephemeral value.</param>
/// <param name="clientSessionProof">The client session proof.</param>
/// <returns>Tuple.</returns>
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;
}
/// <summary>
/// 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<AliasServerDbContext> dbContextFac
return new TokenModel { Token = token, RefreshToken = refreshToken };
}
/// <summary>
/// Get the user's latest vault which contains the current salt and verifier.
/// </summary>
/// <param name="user">User object.</param>
/// <returns>Tuple with salt and verifier.</returns>
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);
}
}

View File

@@ -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;
/// <summary>
/// Vault controller for handling CRUD operations on the database for encrypted vault entities.
@@ -26,9 +31,16 @@ using Microsoft.EntityFrameworkCore;
/// <param name="dbContextFactory">DbContext instance.</param>
/// <param name="userManager">UserManager instance.</param>
/// <param name="timeProvider">ITimeProvider instance.</param>
/// <param name="authLoggingService">AuthLoggingService instance.</param>
/// <param name="cache">IMemoryCache instance.</param>
[ApiVersion("1")]
public class VaultController(ILogger<VaultController> logger, IDbContextFactory<AliasServerDbContext> dbContextFactory, UserManager<AliasVaultUser> userManager, ITimeProvider timeProvider) : AuthenticatedRequestController(userManager)
public class VaultController(ILogger<VaultController> logger, IDbContextFactory<AliasServerDbContext> dbContextFactory, UserManager<AliasVaultUser> userManager, ITimeProvider timeProvider, AuthLoggingService authLoggingService, IMemoryCache cache) : AuthenticatedRequestController(userManager)
{
/// <summary>
/// Error message for providing an invalid current password (during password change).
/// </summary>
private static readonly string[] InvalidCurrentPassword = ["The current password provided is invalid. Please try again."];
/// <summary>
/// Default retention policy for vaults.
/// </summary>
@@ -95,7 +107,7 @@ public class VaultController(ILogger<VaultController> 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<VaultController> logger, IDbContextFactory<
return Ok();
}
/// <summary>
/// Save a new vault to the database based on a new encryption password for the current user.
/// </summary>
/// <param name="model">Vault model.</param>
/// <returns>IActionResult.</returns>
[HttpPost("change-password")]
public async Task<IActionResult> 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." });
}
/// <summary>
/// Updates the user's email claims based on the provided email address list.
/// </summary>

View File

@@ -0,0 +1,70 @@
//-----------------------------------------------------------------------
// <copyright file="AuthHelper.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Api.Helpers;
using AliasServerDb;
using Cryptography.Client;
using Microsoft.Extensions.Caching.Memory;
using SecureRemotePassword;
/// <summary>
/// AuthHelper class.
/// </summary>
public static class AuthHelper
{
/// <summary>
/// Cache prefix for storing generated login ephemeral.
/// </summary>
public static readonly string CachePrefixEphemeral = "LoginEphemeral_";
/// <summary>
/// Helper method that validates the SRP session based on provided username, ephemeral and proof.
/// </summary>
/// <param name="cache">IMemoryCache instance.</param>
/// <param name="user">The user object.</param>
/// <param name="clientEphemeral">The client ephemeral value.</param>
/// <param name="clientSessionProof">The client session proof.</param>
/// <returns>Tuple.</returns>
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;
}
/// <summary>
/// Get the user's latest vault which contains the current salt and verifier.
/// </summary>
/// <param name="user">User object.</param>
/// <returns>Tuple with salt and verifier.</returns>
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);
}
}

View File

@@ -28,8 +28,8 @@ builder.Services.ConfigureLogging(builder.Configuration, Assembly.GetExecutingAs
builder.Services.AddSingleton<ITimeProvider, SystemTimeProvider>();
builder.Services.AddScoped<TimeValidationJwtBearerEvents>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<AuthLoggingService>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddLogging(logging =>
{

View File

@@ -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);

View File

@@ -96,7 +96,7 @@ public sealed class AuthService(HttpClient httpClient, ILocalStorageService loca
/// Get encryption key.
/// </summary>
/// <returns>SrpArgonEncryption key as byte[].</returns>
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());
}
/// <summary>

View File

@@ -1,36 +0,0 @@
//-----------------------------------------------------------------------
// <copyright file="PasswordChangeResponse.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Shared.Models.WebApi.PasswordChange
{
/// <summary>
/// Represents a response to initiate a password change.
/// </summary>
public class PasswordChangeResponse
{
/// <summary>
/// Initializes a new instance of the <see cref="PasswordChangeResponse"/> class.
/// </summary>
/// <param name="newSalt">New salt.</param>
/// <param name="currentPasswordServerProof">Current password server proof.</param>
public PasswordChangeResponse(string newSalt, string currentPasswordServerProof)
{
NewSalt = newSalt;
CurrentPasswordServerProof = currentPasswordServerProof;
}
/// <summary>
/// Gets or sets the new salt for the new password.
/// </summary>
public string NewSalt { get; set; }
/// <summary>
/// Gets or sets the server's proof for the current password verification.
/// </summary>
public string CurrentPasswordServerProof { get; set; }
}
}

View File

@@ -1,5 +1,5 @@
//-----------------------------------------------------------------------
// <copyright file="PasswordChangeRequest.cs" company="lanedirt">
// <copyright file="VaultPasswordChangeRequest.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
@@ -8,18 +8,31 @@
namespace AliasVault.Shared.Models.WebApi.PasswordChange;
/// <summary>
/// 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.
/// </summary>
public class PasswordChangeRequest
public class VaultPasswordChangeRequest : Vault
{
/// <summary>
/// Initializes a new instance of the <see cref="PasswordChangeRequest"/> class.
/// Initializes a new instance of the <see cref="VaultPasswordChangeRequest"/> class.
/// </summary>
/// <param name="blob">Blob.</param>
/// <param name="version">Version of the vault data model (migration).</param>
/// <param name="createdAt">CreatedAt.</param>
/// <param name="updatedAt">UpdatedAt.</param>
/// <param name="currentClientPublicEphemeral">Client public ephemeral.</param>
/// <param name="currentClientSessionProof">Client session proof.</param>
/// <param name="newPasswordSalt">New password salt.</param>
/// <param name="newPasswordVerifier">New password verifier.</param>
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<string>(), createdAt, updatedAt)
{
CurrentClientPublicEphemeral = currentClientPublicEphemeral;
CurrentClientSessionProof = currentClientSessionProof;