Add mobile login requests to admin dashboard, update migration (#1347)

This commit is contained in:
Leendert de Borst
2025-11-17 21:08:11 +01:00
parent eaa348bb23
commit bcd1353cf7
12 changed files with 580 additions and 7 deletions

View File

@@ -0,0 +1,29 @@
//-----------------------------------------------------------------------
// <copyright file="RecentUsageMobileLogins.cs" company="aliasvault">
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Admin.Main.Models;
/// <summary>
/// Model representing IP addresses with mobile login request counts.
/// </summary>
public class RecentUsageMobileLogins
{
/// <summary>
/// Gets or sets the anonymized IP address (last octet masked).
/// </summary>
public string IpAddress { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the original IP address for linking purposes.
/// </summary>
public string OriginalIpAddress { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the count of mobile login requests from this IP in the last 72 hours.
/// </summary>
public int MobileLoginCount72h { get; set; }
}

View File

@@ -26,4 +26,9 @@ public class RecentUsageStatistics
/// Gets or sets the list of IP addresses with most registrations in the last 72 hours.
/// </summary>
public List<RecentUsageRegistrations> TopIpsByRegistrations72h { get; set; } = new();
/// <summary>
/// Gets or sets the list of IP addresses with most mobile login requests in the last 72 hours.
/// </summary>
public List<RecentUsageMobileLogins> TopIpsByMobileLogins72h { get; set; } = new();
}

View File

@@ -0,0 +1,72 @@
@using AliasVault.Admin.Main.Models
@using AliasVault.RazorComponents.Tables
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Top IP Addresses by Mobile Login Requests (Last 72h)</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">IP addresses with the most mobile login requests in the last 72 hours (last octet anonymized)</p>
</div>
</div>
@if (Data != null && Data.Any())
{
<div class="mb-3">
<Paginator CurrentPage="@CurrentPage" PageSize="@PageSize" TotalRecords="@Data.Count" OnPageChanged="@HandlePageChanged" />
</div>
<div class="overflow-x-auto">
<SortableTable Columns="@_tableColumns">
@foreach (var ip in PagedData)
{
<SortableTableRow>
<SortableTableColumn IsPrimary="true">
<a href="mobile-login-history?search=@Uri.EscapeDataString(ip.OriginalIpAddress)" class="font-mono text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 cursor-pointer">
@ip.IpAddress
</a>
</SortableTableColumn>
<SortableTableColumn>@ip.MobileLoginCount72h.ToString("N0")</SortableTableColumn>
</SortableTableRow>
}
</SortableTable>
</div>
}
else if (Data != null)
{
<div class="text-center text-gray-500 dark:text-gray-400">
<svg class="mx-auto h-12 w-12 text-gray-300 dark:text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"/>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">No Recent Mobile Logins</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">No mobile login requests occurred in the last 72 hours.</p>
</div>
}
else
{
<div class="px-6 py-8 flex justify-center">
<LoadingIndicator />
</div>
}
</div>
@code {
[Parameter]
public List<RecentUsageMobileLogins>? Data { get; set; }
private int CurrentPage { get; set; } = 1;
private int PageSize { get; set; } = 20;
private IEnumerable<RecentUsageMobileLogins> PagedData =>
Data?.Skip((CurrentPage - 1) * PageSize).Take(PageSize) ?? Enumerable.Empty<RecentUsageMobileLogins>();
private readonly List<TableColumn> _tableColumns = new()
{
new() { Title = "Client IP Address", PropertyName = "IpAddress", Sortable = false },
new() { Title = "Mobile Logins (72h)", PropertyName = "MobileLoginCount72h", Sortable = false }
};
private void HandlePageChanged(int page)
{
CurrentPage = page;
StateHasChanged();
}
}

View File

@@ -27,6 +27,9 @@
<!-- Top IP Addresses by Registrations ---->
<RecentUsageRegistrationsTable Data="@_recentUsageStats?.TopIpsByRegistrations72h" />
<!-- Top IP Addresses by Mobile Login Requests ---->
<RecentUsageMobileLoginsTable Data="@_recentUsageStats?.TopIpsByMobileLogins72h" />
</div>
@if (_loadingError)

View File

@@ -0,0 +1,372 @@
@page "/mobile-login-history"
@using AliasVault.RazorComponents.Tables
@using Microsoft.AspNetCore.WebUtilities
@inject NavigationManager NavigationManager
@inherits MainBase
<LayoutPageTitle>Mobile Login History</LayoutPageTitle>
<PageHeader
BreadcrumbItems="@BreadcrumbItems"
Title="@(TotalRecords > 0 ? $"Mobile Login History ({TotalRecords:N0})" : "Mobile Login History")"
Description="View all mobile login requests with IP addresses, timestamps, and fulfillment status.">
<CustomActions>
<RefreshButton OnClick="() => RefreshData(CancellationToken.None)" ButtonText="Refresh" />
</CustomActions>
</PageHeader>
@if (IsInitialized)
{
<div class="px-4">
<ResponsivePaginator CurrentPage="CurrentPage" PageSize="PageSize" TotalRecords="TotalRecords" OnPageChanged="HandlePageChanged" />
<div class="mb-3 flex space-x-4">
<div class="w-3/4">
<div class="relative">
<SearchIcon />
<input type="text" @bind-value="SearchTerm" @bind-value:event="oninput" id="search" placeholder="Search by username or IP address..." class="w-full px-4 ps-10 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white">
</div>
</div>
<div class="w-1/4">
<select @bind="SelectedStatusFilter" class="w-full px-4 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white">
<option value="">All Requests</option>
<option value="retrieved">Retrieved</option>
<option value="fulfilled">Fulfilled</option>
<option value="pending">Pending</option>
</select>
</div>
</div>
</div>
}
@if (IsLoading)
{
<LoadingIndicator />
}
else
{
<div class="px-4">
<SortableTable Columns="@_tableColumns" SortColumn="@SortColumn" SortDirection="@SortDirection" OnSortChanged="HandleSortChanged">
@foreach (var request in RequestList)
{
<SortableTableRow>
<SortableTableColumn IsPrimary="true">@request.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss")</SortableTableColumn>
<SortableTableColumn>
<span class="font-mono text-sm">@(request.ClientIpAddress ?? "N/A")</span>
</SortableTableColumn>
<SortableTableColumn>
<span class="font-mono text-sm">@(request.MobileIpAddress ?? "N/A")</span>
</SortableTableColumn>
<SortableTableColumn>
@if (request.FulfilledAt.HasValue)
{
<span class="text-sm">@request.FulfilledAt.Value.ToString("yyyy-MM-dd HH:mm:ss")</span>
}
else
{
<span class="text-gray-500 dark:text-gray-400 text-sm italic">-</span>
}
</SortableTableColumn>
<SortableTableColumn>
@if (request.RetrievedAt.HasValue)
{
<span class="text-sm">@request.RetrievedAt.Value.ToString("yyyy-MM-dd HH:mm:ss")</span>
}
else
{
<span class="text-gray-500 dark:text-gray-400 text-sm italic">-</span>
}
</SortableTableColumn>
<SortableTableColumn>
@if (!string.IsNullOrEmpty(request.Username))
{
<a href="users/@request.UserId" class="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300">
@request.Username
</a>
}
else
{
<span class="text-gray-500 dark:text-gray-400 italic">-</span>
}
</SortableTableColumn>
<SortableTableColumn>
@if (request.RetrievedAt.HasValue)
{
<StatusPill Enabled="true" TextTrue="Retrieved" />
}
else if (request.FulfilledAt.HasValue)
{
<StatusPill Enabled="true" TextTrue="Fulfilled" />
}
else
{
<StatusPill Enabled="false" TextFalse="Pending" />
}
</SortableTableColumn>
</SortableTableRow>
}
</SortableTable>
</div>
}
@code {
private readonly List<TableColumn> _tableColumns = [
new TableColumn { Title = "Created At", PropertyName = "CreatedAt" },
new TableColumn { Title = "Client IP", PropertyName = "ClientIpAddress" },
new TableColumn { Title = "Mobile IP", PropertyName = "MobileIpAddress" },
new TableColumn { Title = "Fulfilled At", PropertyName = "FulfilledAt" },
new TableColumn { Title = "Retrieved At", PropertyName = "RetrievedAt" },
new TableColumn { Title = "Username", PropertyName = "Username" },
new TableColumn { Title = "Status", Sortable = false },
];
private List<MobileLoginRequestModel> RequestList { get; set; } = [];
private bool IsInitialized { get; set; } = false;
private bool IsLoading { get; set; } = true;
private int CurrentPage { get; set; } = 1;
private int PageSize { get; set; } = 50;
private int TotalRecords { get; set; }
private string _searchTerm = string.Empty;
private CancellationTokenSource? _searchCancellationTokenSource;
private string _lastSearchTerm = string.Empty;
private string SearchTerm
{
get => _searchTerm;
set
{
if (_searchTerm != value)
{
_searchTerm = value;
_searchCancellationTokenSource?.Cancel();
_searchCancellationTokenSource = new CancellationTokenSource();
_ = RefreshData(_searchCancellationTokenSource.Token);
}
}
}
private string _selectedStatusFilter = string.Empty;
private string _lastSelectedStatusFilter = string.Empty;
private string SelectedStatusFilter
{
get => _selectedStatusFilter;
set
{
if (_selectedStatusFilter != value)
{
_selectedStatusFilter = value;
_searchCancellationTokenSource?.Cancel();
_searchCancellationTokenSource = new CancellationTokenSource();
_ = RefreshData(_searchCancellationTokenSource.Token);
}
}
}
private string SortColumn { get; set; } = "CreatedAt";
private SortDirection SortDirection { get; set; } = SortDirection.Descending;
private async Task HandleSortChanged((string column, SortDirection direction) sort)
{
SortColumn = sort.column;
SortDirection = sort.direction;
await RefreshData(CancellationToken.None);
}
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "Users", Url = "users" });
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "Mobile Login History" });
// Check for search query parameter
var uri = new Uri(NavigationManager.Uri);
var queryParams = QueryHelpers.ParseQuery(uri.Query);
if (queryParams.TryGetValue("search", out var search))
{
_searchTerm = search.ToString();
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await RefreshData(CancellationToken.None);
}
}
private void HandlePageChanged(int newPage)
{
CurrentPage = newPage;
_ = RefreshData(CancellationToken.None);
}
private async Task RefreshData(CancellationToken cancellationToken = default)
{
try
{
IsLoading = true;
StateHasChanged();
await using var dbContext = await DbContextFactory.CreateDbContextAsync(cancellationToken);
IQueryable<MobileLoginRequest> query = dbContext.MobileLoginRequests;
query = ApplySearchFilter(query);
query = ApplyStatusFilter(query);
query = ApplySort(query);
TotalRecords = await query.CountAsync(cancellationToken);
var requests = await query
.Skip((CurrentPage - 1) * PageSize)
.Take(PageSize)
.Select(r => new
{
r.CreatedAt,
r.ClientIpAddress,
r.MobileIpAddress,
r.FulfilledAt,
r.RetrievedAt,
r.Username,
r.UserId
})
.ToListAsync(cancellationToken);
if (cancellationToken.IsCancellationRequested)
{
return;
}
RequestList = requests.Select(r => new MobileLoginRequestModel
{
CreatedAt = r.CreatedAt,
ClientIpAddress = r.ClientIpAddress,
MobileIpAddress = r.MobileIpAddress,
FulfilledAt = r.FulfilledAt,
RetrievedAt = r.RetrievedAt,
Username = r.Username,
UserId = r.UserId
}).ToList();
IsLoading = false;
IsInitialized = true;
StateHasChanged();
}
catch (OperationCanceledException)
{
// Expected when cancellation is requested, do nothing
}
}
private IQueryable<MobileLoginRequest> ApplySearchFilter(IQueryable<MobileLoginRequest> query)
{
if (SearchTerm.Length > 0)
{
// Reset page number back to 1 if the search term has changed
if (SearchTerm != _lastSearchTerm && CurrentPage != 1)
{
CurrentPage = 1;
}
_lastSearchTerm = SearchTerm;
var searchTerm = SearchTerm.Trim().ToLower();
query = query.Where(r =>
(r.Username != null && EF.Functions.Like(r.Username.ToLower(), "%" + searchTerm + "%")) ||
(r.ClientIpAddress != null && EF.Functions.Like(r.ClientIpAddress.ToLower(), "%" + searchTerm + "%")) ||
(r.MobileIpAddress != null && EF.Functions.Like(r.MobileIpAddress.ToLower(), "%" + searchTerm + "%"))
);
}
return query;
}
private IQueryable<MobileLoginRequest> ApplyStatusFilter(IQueryable<MobileLoginRequest> query)
{
if (!string.IsNullOrEmpty(SelectedStatusFilter))
{
// Reset page number back to 1 if the filter has changed
if (SelectedStatusFilter != _lastSelectedStatusFilter && CurrentPage != 1)
{
CurrentPage = 1;
}
_lastSelectedStatusFilter = SelectedStatusFilter;
switch (SelectedStatusFilter)
{
case "retrieved":
query = query.Where(r => r.RetrievedAt != null);
break;
case "fulfilled":
query = query.Where(r => r.FulfilledAt != null && r.RetrievedAt == null);
break;
case "pending":
query = query.Where(r => r.FulfilledAt == null);
break;
}
}
return query;
}
private IQueryable<MobileLoginRequest> ApplySort(IQueryable<MobileLoginRequest> query)
{
switch (SortColumn)
{
case "CreatedAt":
query = SortDirection == SortDirection.Ascending
? query.OrderBy(x => x.CreatedAt)
: query.OrderByDescending(x => x.CreatedAt);
break;
case "ClientIpAddress":
query = SortDirection == SortDirection.Ascending
? query.OrderBy(x => x.ClientIpAddress)
: query.OrderByDescending(x => x.ClientIpAddress);
break;
case "MobileIpAddress":
query = SortDirection == SortDirection.Ascending
? query.OrderBy(x => x.MobileIpAddress)
: query.OrderByDescending(x => x.MobileIpAddress);
break;
case "FulfilledAt":
query = SortDirection == SortDirection.Ascending
? query.OrderBy(x => x.FulfilledAt)
: query.OrderByDescending(x => x.FulfilledAt);
break;
case "RetrievedAt":
query = SortDirection == SortDirection.Ascending
? query.OrderBy(x => x.RetrievedAt)
: query.OrderByDescending(x => x.RetrievedAt);
break;
case "Username":
query = SortDirection == SortDirection.Ascending
? query.OrderBy(x => x.Username)
: query.OrderByDescending(x => x.Username);
break;
default:
query = query.OrderByDescending(x => x.CreatedAt);
break;
}
return query;
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_searchCancellationTokenSource?.Cancel();
_searchCancellationTokenSource?.Dispose();
}
base.Dispose(disposing);
}
public class MobileLoginRequestModel
{
public DateTime CreatedAt { get; set; }
public string? ClientIpAddress { get; set; }
public string? MobileIpAddress { get; set; }
public DateTime? FulfilledAt { get; set; }
public DateTime? RetrievedAt { get; set; }
public string? Username { get; set; }
public string? UserId { get; set; }
}
}

View File

@@ -11,6 +11,12 @@
Title="@(TotalRecords > 0 ? $"Users ({TotalRecords:N0})" : "Users")"
Description="This page shows an overview of all registered users and the associated vaults.">
<CustomActions>
<a href="mobile-login-history" class="inline-flex items-center px-3 py-2 text-sm font-medium text-gray-600 bg-gray-100 border border-gray-300 rounded-md hover:bg-gray-200 hover:text-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600 dark:hover:text-gray-200 mr-3">
<svg class="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"/>
</svg>
Mobile Login History
</a>
<RefreshButton OnClick="() => RefreshData(CancellationToken.None)" ButtonText="Refresh" />
</CustomActions>
</PageHeader>

View File

@@ -115,6 +115,7 @@ public class StatisticsService
GetTopUsersByAliases72hAsync().ContinueWith(t => stats.TopUsersByAliases72h = t.Result),
GetTopUsersByEmails72hAsync().ContinueWith(t => stats.TopUsersByEmails72h = t.Result),
GetTopIpsByRegistrations72hAsync().ContinueWith(t => stats.TopIpsByRegistrations72h = t.Result),
GetTopIpsByMobileLogins72hAsync().ContinueWith(t => stats.TopIpsByMobileLogins72h = t.Result),
};
await Task.WhenAll(tasks);
@@ -570,4 +571,36 @@ public class StatisticsService
RegistrationCount72h = ip.RegistrationCount72h,
}).ToList();
}
/// <summary>
/// Gets the top 20 IP addresses by number of mobile login requests in the last 72 hours.
/// </summary>
/// <returns>List of top IP addresses by mobile login requests.</returns>
private async Task<List<RecentUsageMobileLogins>> GetTopIpsByMobileLogins72hAsync()
{
await using var context = await _contextFactory.CreateDbContextAsync();
var cutoffDate = DateTime.UtcNow.AddHours(-72);
// Get mobile login requests by client IP
var topIps = await context.MobileLoginRequests
.Where(mlr => mlr.CreatedAt >= cutoffDate &&
mlr.ClientIpAddress != null &&
mlr.ClientIpAddress != "xxx.xxx.xxx.xxx")
.GroupBy(mlr => mlr.ClientIpAddress)
.Select(g => new
{
IpAddress = g.Key,
MobileLoginCount72h = g.Count(),
})
.OrderByDescending(ip => ip.MobileLoginCount72h)
.Take(20)
.ToListAsync();
return topIps.Select(ip => new RecentUsageMobileLogins
{
OriginalIpAddress = ip.IpAddress!,
IpAddress = AnonymizeIpAddress(ip.IpAddress!),
MobileLoginCount72h = ip.MobileLoginCount72h,
}).ToList();
}
}

View File

@@ -118,7 +118,7 @@
await JsInteropService.GenerateQrCode("mobile-login-qr");
// Wait for QR code to be fully rendered before hiding loading animation
await Task.Delay(500);
await Task.Delay(300);
_isLoading = false;
StateHasChanged();

View File

@@ -12,7 +12,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace AliasServerDb.Migrations
{
[DbContext(typeof(AliasServerDbContext))]
[Migration("20251117162503_AddMobileLoginRequest")]
[Migration("20251117175358_AddMobileLoginRequest")]
partial class AddMobileLoginRequest
{
/// <inheritdoc />
@@ -535,7 +535,17 @@ namespace AliasServerDb.Migrations
b.HasKey("Id");
b.HasIndex("UserId");
b.HasIndex(new[] { "ClientIpAddress" }, "IX_ClientIpAddress");
b.HasIndex(new[] { "CreatedAt" }, "IX_CreatedAt");
b.HasIndex(new[] { "FulfilledAt" }, "IX_FulfilledAt");
b.HasIndex(new[] { "MobileIpAddress" }, "IX_MobileIpAddress");
b.HasIndex(new[] { "RetrievedAt" }, "IX_RetrievedAt");
b.HasIndex(new[] { "UserId" }, "IX_UserId");
b.ToTable("MobileLoginRequests");
});

View File

@@ -40,7 +40,32 @@ namespace AliasServerDb.Migrations
});
migrationBuilder.CreateIndex(
name: "IX_MobileLoginRequests_UserId",
name: "IX_ClientIpAddress",
table: "MobileLoginRequests",
column: "ClientIpAddress");
migrationBuilder.CreateIndex(
name: "IX_CreatedAt",
table: "MobileLoginRequests",
column: "CreatedAt");
migrationBuilder.CreateIndex(
name: "IX_FulfilledAt",
table: "MobileLoginRequests",
column: "FulfilledAt");
migrationBuilder.CreateIndex(
name: "IX_MobileIpAddress",
table: "MobileLoginRequests",
column: "MobileIpAddress");
migrationBuilder.CreateIndex(
name: "IX_RetrievedAt",
table: "MobileLoginRequests",
column: "RetrievedAt");
migrationBuilder.CreateIndex(
name: "IX_UserId",
table: "MobileLoginRequests",
column: "UserId");
}

View File

@@ -532,7 +532,17 @@ namespace AliasServerDb.Migrations
b.HasKey("Id");
b.HasIndex("UserId");
b.HasIndex(new[] { "ClientIpAddress" }, "IX_ClientIpAddress");
b.HasIndex(new[] { "CreatedAt" }, "IX_CreatedAt");
b.HasIndex(new[] { "FulfilledAt" }, "IX_FulfilledAt");
b.HasIndex(new[] { "MobileIpAddress" }, "IX_MobileIpAddress");
b.HasIndex(new[] { "RetrievedAt" }, "IX_RetrievedAt");
b.HasIndex(new[] { "UserId" }, "IX_UserId");
b.ToTable("MobileLoginRequests");
});

View File

@@ -7,13 +7,21 @@
namespace AliasServerDb;
using Microsoft.EntityFrameworkCore;
/// <summary>
/// Mobile unlock request entity for storing temporary unlock requests.
/// Mobile login request entity for storing temporary login requests.
/// </summary>
[Index(nameof(ClientIpAddress), Name = "IX_ClientIpAddress")]
[Index(nameof(MobileIpAddress), Name = "IX_MobileIpAddress")]
[Index(nameof(CreatedAt), Name = "IX_CreatedAt")]
[Index(nameof(FulfilledAt), Name = "IX_FulfilledAt")]
[Index(nameof(RetrievedAt), Name = "IX_RetrievedAt")]
[Index(nameof(UserId), Name = "IX_UserId")]
public class MobileLoginRequest
{
/// <summary>
/// Gets or sets the unique identifier for this unlock request.
/// Gets or sets the unique identifier for this login request.
/// </summary>
public string Id { get; set; } = string.Empty;