This commit is contained in:
Leendert de Borst
2025-03-11 10:13:26 +01:00
committed by Leendert de Borst
parent fe4b11cf4d
commit 90156dd1f8
5 changed files with 124 additions and 154 deletions

View File

@@ -1,5 +1,4 @@
@inherits ComponentBase
@inject TotpCodeService TotpCodeService
@inject GlobalNotificationService GlobalNotificationService
@inject ConfirmModalService ConfirmModalService
@using AliasVault.RazorComponents.Services
@@ -10,7 +9,7 @@
<div>
<h3 class="mb-4 text-xl font-semibold dark:text-white">Two-factor authentication</h3>
</div>
@if (TotpCodeList.Where(t => !t.IsDeleted).Any() && !IsAddFormVisible)
@if (TotpCodeList.Any(t => !t.IsDeleted) && !IsAddFormVisible)
{
<div>
<button id="add-totp-code" @onclick="ShowAddForm" type="button" class="text-blue-700 hover:text-white border border-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-3 py-2 text-center dark:border-blue-500 dark:text-blue-500 dark:hover:text-white dark:hover:bg-blue-600 dark:focus:ring-blue-800">
@@ -100,11 +99,6 @@
[Parameter]
public EventCallback<List<TotpCode>> TotpCodesChanged { get; set; }
/// <summary>
/// The dictionary of current cached TOTP codes.
/// </summary>
private Dictionary<string, string> _currentCodes = new();
private bool IsAddFormVisible { get; set; } = false;
private TotpCodeEdit NewTotpCode { get; set; } = new();
private List<Guid> OriginalTotpCodeIds { get; set; } = [];
@@ -150,7 +144,6 @@
// Sanitize the secret key (remove whitespace and hyphens)
secretKey = secretKey.Replace(" ", string.Empty).Replace("-", string.Empty);
string? name = NewTotpCode.Name;
// Check if the input is a TOTP URI
@@ -162,7 +155,7 @@
var queryParams = System.Web.HttpUtility.ParseQueryString(uri.Query);
// Extract the secret from query parameters
secretKey = queryParams["secret"] ?? throw new Exception("Secret not found in URI");
secretKey = queryParams["secret"] ?? throw new ArgumentException("Secret not found in URI");
// If no name was provided, try to get it from the URI
if (string.IsNullOrWhiteSpace(name))
@@ -211,9 +204,6 @@
await TotpCodesChanged.InvokeAsync(TotpCodeList);
HideAddForm();
// Refresh the codes
_currentCodes[newTotpCode.SecretKey] = TotpGenerator.GenerateTotpCode(newTotpCode.SecretKey);
StateHasChanged();
}

View File

@@ -1,5 +1,4 @@
@inherits ComponentBase
@inject TotpCodeService TotpCodeService
@inject ClipboardCopyService ClipboardCopyService
@inject JsInteropService JsInteropService
@implements IDisposable
@@ -69,7 +68,7 @@
/// <summary>
/// The dictionary of current cached TOTP codes.
/// </summary>
private Dictionary<string, string> _currentCodes = new();
private readonly Dictionary<string, string> _currentCodes = new();
private bool IsLoading { get; set; } = true;
private Timer? _refreshTimer;
@@ -86,9 +85,9 @@
await base.OnInitializedAsync();
// Generate initial codes
foreach (var code in TotpCodeList)
foreach (var code in TotpCodeList.Select(t => t.SecretKey))
{
_currentCodes[code.SecretKey] = TotpGenerator.GenerateTotpCode(code.SecretKey);
_currentCodes[code] = TotpGenerator.GenerateTotpCode(code);
}
// Start a timer to refresh the TOTP codes every second
@@ -97,17 +96,27 @@
IsLoading = false;
}
/// <summary>
/// Gets the remaining seconds for the TOTP code.
/// </summary>
/// <returns>The remaining seconds.</returns>
private static int GetRemainingSeconds(int step = 30)
{
var unixTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
return step - (int)(unixTimestamp % step);
}
/// <summary>
/// Refreshes the TOTP codes by generating new codes based on the secret keys.
/// </summary>
private async Task RefreshCodesAsync()
{
foreach (var code in TotpCodeList)
foreach (var code in TotpCodeList.Select(t => t.SecretKey))
{
var newCode = TotpGenerator.GenerateTotpCode(code.SecretKey);
if (!_currentCodes.ContainsKey(code.SecretKey) || _currentCodes[code.SecretKey] != newCode)
var newCode = TotpGenerator.GenerateTotpCode(code);
if (!_currentCodes.ContainsKey(code) || _currentCodes[code] != newCode)
{
_currentCodes[code.SecretKey] = newCode;
_currentCodes[code] = newCode;
}
}
@@ -132,15 +141,6 @@
return newCode;
}
/// <summary>
/// Gets the remaining seconds for the TOTP code.
/// </summary>
/// <returns>The remaining seconds.</returns>
private int GetRemainingSeconds()
{
return TotpCodeService.GetRemainingSeconds();
}
/// <summary>
/// Gets the remaining percentage for the TOTP code.
/// </summary>

View File

@@ -74,7 +74,6 @@ builder.Services.AddScoped<GlobalLoadingService>();
builder.Services.AddScoped<KeyboardShortcutService>();
builder.Services.AddScoped<JsInteropService>();
builder.Services.AddScoped<EmailService>();
builder.Services.AddScoped<TotpCodeService>();
builder.Services.AddSingleton<ClipboardCopyService>();
builder.Services.AddScoped<ConfirmModalService>();

View File

@@ -222,8 +222,6 @@ public sealed class CredentialService(HttpClient httpClient, DbService dbService
throw new InvalidOperationException("Login object not found.");
}
// If the email starts with an @ it is most likely still the placeholder which hasn't been filled.
// So we remove it.
if (loginObject.Alias.Email is not null && loginObject.Alias.Email.StartsWith('@'))
{
loginObject.Alias.Email = null;
@@ -235,87 +233,13 @@ public sealed class CredentialService(HttpClient httpClient, DbService dbService
loginObject.Service.Url = null;
}
login.UpdatedAt = DateTime.UtcNow;
login.Notes = loginObject.Notes;
login.Username = loginObject.Username;
// Update all fields and collections.
UpdateBasicCredentialInfo(login, loginObject);
UpdateAttachments(context, login, loginObject);
UpdateTotpCodes(context, login, loginObject);
login.Alias.NickName = loginObject.Alias.NickName;
login.Alias.FirstName = loginObject.Alias.FirstName;
login.Alias.LastName = loginObject.Alias.LastName;
login.Alias.BirthDate = loginObject.Alias.BirthDate;
login.Alias.Gender = loginObject.Alias.Gender;
login.Alias.Email = loginObject.Alias.Email;
login.Alias.UpdatedAt = DateTime.UtcNow;
login.Passwords = loginObject.Passwords;
if (login.Passwords.Count > 0)
{
login.Passwords.First().UpdatedAt = DateTime.UtcNow;
}
login.Service.Name = loginObject.Service.Name;
login.Service.Url = loginObject.Service.Url;
login.Service.Logo = loginObject.Service.Logo;
login.Service.UpdatedAt = DateTime.UtcNow;
// Remove attachments that are no longer in the list
var attachmentsToRemove = login.Attachments.Where(existingAttachment => !loginObject.Attachments.Any(a => a.Id == existingAttachment.Id)).ToList();
foreach (var attachmentToRemove in attachmentsToRemove)
{
login.Attachments.Remove(attachmentToRemove);
context.Entry(attachmentToRemove).State = EntityState.Deleted;
}
// Update existing attachments and add new ones
foreach (var attachment in loginObject.Attachments)
{
if (attachment.Id != Guid.Empty)
{
var existingAttachment = login.Attachments.FirstOrDefault(a => a.Id == attachment.Id);
if (existingAttachment != null)
{
// Update existing attachment
context.Entry(existingAttachment).CurrentValues.SetValues(attachment);
}
}
else
{
// Add new attachment
login.Attachments.Add(attachment);
}
}
// Remove TOTP codes that are no longer in the list
var totpCodesToRemove = login.TotpCodes.Where(existingTotp => !loginObject.TotpCodes.Any(t => t.Id == existingTotp.Id)).ToList();
foreach (var totpToRemove in totpCodesToRemove)
{
login.TotpCodes.Remove(totpToRemove);
context.Entry(totpToRemove).State = EntityState.Deleted;
}
// Update existing TOTP codes and add new ones
foreach (var totpCode in loginObject.TotpCodes)
{
if (totpCode.Id != Guid.Empty)
{
var existingTotpCode = login.TotpCodes.FirstOrDefault(t => t.Id == totpCode.Id);
if (existingTotpCode != null)
{
// Update existing TOTP code
context.Entry(existingTotpCode).CurrentValues.SetValues(totpCode);
}
}
else
{
// Add new TOTP code
login.TotpCodes.Add(totpCode);
}
}
// Save the database to the server.
if (!await dbService.SaveDatabaseAsync())
{
// If saving database failed, return empty guid to indicate error.
return Guid.Empty;
}
@@ -434,6 +358,107 @@ public sealed class CredentialService(HttpClient httpClient, DbService dbService
return await dbService.SaveDatabaseAsync();
}
/// <summary>
/// Update the basic credential information.
/// </summary>
/// <param name="login">The login object to update.</param>
/// <param name="loginObject">The login object to update from.</param>
private static void UpdateBasicCredentialInfo(Credential login, Credential loginObject)
{
login.UpdatedAt = DateTime.UtcNow;
login.Notes = loginObject.Notes;
login.Username = loginObject.Username;
login.Alias.NickName = loginObject.Alias.NickName;
login.Alias.FirstName = loginObject.Alias.FirstName;
login.Alias.LastName = loginObject.Alias.LastName;
login.Alias.BirthDate = loginObject.Alias.BirthDate;
login.Alias.Gender = loginObject.Alias.Gender;
login.Alias.Email = loginObject.Alias.Email;
login.Alias.UpdatedAt = DateTime.UtcNow;
login.Passwords = loginObject.Passwords;
if (login.Passwords.Count > 0)
{
login.Passwords.First().UpdatedAt = DateTime.UtcNow;
}
login.Service.Name = loginObject.Service.Name;
login.Service.Url = loginObject.Service.Url;
login.Service.Logo = loginObject.Service.Logo;
login.Service.UpdatedAt = DateTime.UtcNow;
}
/// <summary>
/// Update the attachments.
/// </summary>
/// <param name="context">The database context.</param>
/// <param name="login">The login object to update.</param>
/// <param name="loginObject">The login object to update from.</param>
private static void UpdateAttachments(DbContext context, Credential login, Credential loginObject)
{
var attachmentsToRemove = login.Attachments
.Where(existingAttachment => !loginObject.Attachments.Any(a => a.Id == existingAttachment.Id))
.ToList();
foreach (var attachmentToRemove in attachmentsToRemove)
{
login.Attachments.Remove(attachmentToRemove);
context.Entry(attachmentToRemove).State = EntityState.Deleted;
}
foreach (var attachment in loginObject.Attachments)
{
if (attachment.Id != Guid.Empty)
{
var existingAttachment = login.Attachments.FirstOrDefault(a => a.Id == attachment.Id);
if (existingAttachment != null)
{
context.Entry(existingAttachment).CurrentValues.SetValues(attachment);
}
}
else
{
login.Attachments.Add(attachment);
}
}
}
/// <summary>
/// Update the TOTP codes.
/// </summary>
/// <param name="context">The database context.</param>
/// <param name="login">The login object to update.</param>
/// <param name="loginObject">The login object to update from.</param>
private static void UpdateTotpCodes(DbContext context, Credential login, Credential loginObject)
{
var totpCodesToRemove = login.TotpCodes
.Where(existingTotp => !loginObject.TotpCodes.Any(t => t.Id == existingTotp.Id))
.ToList();
foreach (var totpToRemove in totpCodesToRemove)
{
login.TotpCodes.Remove(totpToRemove);
context.Entry(totpToRemove).State = EntityState.Deleted;
}
foreach (var totpCode in loginObject.TotpCodes)
{
if (totpCode.Id != Guid.Empty)
{
var existingTotpCode = login.TotpCodes.FirstOrDefault(t => t.Id == totpCode.Id);
if (existingTotpCode != null)
{
context.Entry(existingTotpCode).CurrentValues.SetValues(totpCode);
}
}
else
{
login.TotpCodes.Add(totpCode);
}
}
}
/// <summary>
/// Extract favicon from service URL if available in object. If successful the passed object itself will be updated with the bytes.
/// </summary>

View File

@@ -1,44 +0,0 @@
//-----------------------------------------------------------------------
// <copyright file="TotpCodeService.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.Client.Services;
using AliasClientDb;
using AliasVault.Client.Services.Database;
using AliasVault.TotpGenerator;
using Microsoft.EntityFrameworkCore;
/// <summary>
/// Service for managing TOTP codes.
/// </summary>
public class TotpCodeService
{
private readonly DbService _dbService;
private readonly ILogger<TotpCodeService> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="TotpCodeService"/> class.
/// </summary>
/// <param name="dbService">The database service.</param>
/// <param name="logger">The logger.</param>
public TotpCodeService(DbService dbService, ILogger<TotpCodeService> logger)
{
_dbService = dbService;
_logger = logger;
}
/// <summary>
/// Gets the remaining seconds until the TOTP code expires.
/// </summary>
/// <param name="step">The time step in seconds. Default is 30.</param>
/// <returns>The remaining seconds.</returns>
public int GetRemainingSeconds(int step = 30)
{
var unixTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
return step - (int)(unixTimestamp % step);
}
}