From bcd1353cf796d0069e80fc20892883bf995426c4 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Mon, 17 Nov 2025 21:08:11 +0100 Subject: [PATCH] Add mobile login requests to admin dashboard, update migration (#1347) --- .../Main/Models/RecentUsageMobileLogins.cs | 29 ++ .../Main/Models/RecentUsageStatistics.cs | 5 + .../RecentUsageMobileLoginsTable.razor | 72 ++++ .../Pages/Dashboard/RecentUsageStats.razor | 3 + .../Main/Pages/MobileLoginHistory.razor | 372 ++++++++++++++++++ .../Main/Pages/Users/Users.razor | 6 + .../Services/StatisticsService.cs | 33 ++ .../Auth/Pages/MobileLogin.razor | 2 +- ...7175358_AddMobileLoginRequest.Designer.cs} | 14 +- ...> 20251117175358_AddMobileLoginRequest.cs} | 27 +- .../AliasServerDbContextModelSnapshot.cs | 12 +- .../AliasServerDb/MobileLoginRequest.cs | 12 +- 12 files changed, 580 insertions(+), 7 deletions(-) create mode 100644 apps/server/AliasVault.Admin/Main/Models/RecentUsageMobileLogins.cs create mode 100644 apps/server/AliasVault.Admin/Main/Pages/Dashboard/RecentUsage/Components/RecentUsageMobileLoginsTable.razor create mode 100644 apps/server/AliasVault.Admin/Main/Pages/MobileLoginHistory.razor rename apps/server/Databases/AliasServerDb/Migrations/{20251117162503_AddMobileLoginRequest.Designer.cs => 20251117175358_AddMobileLoginRequest.Designer.cs} (98%) rename apps/server/Databases/AliasServerDb/Migrations/{20251117162503_AddMobileLoginRequest.cs => 20251117175358_AddMobileLoginRequest.cs} (73%) diff --git a/apps/server/AliasVault.Admin/Main/Models/RecentUsageMobileLogins.cs b/apps/server/AliasVault.Admin/Main/Models/RecentUsageMobileLogins.cs new file mode 100644 index 000000000..6bd8efc27 --- /dev/null +++ b/apps/server/AliasVault.Admin/Main/Models/RecentUsageMobileLogins.cs @@ -0,0 +1,29 @@ +//----------------------------------------------------------------------- +// +// 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.Admin.Main.Models; + +/// +/// Model representing IP addresses with mobile login request counts. +/// +public class RecentUsageMobileLogins +{ + /// + /// Gets or sets the anonymized IP address (last octet masked). + /// + public string IpAddress { get; set; } = string.Empty; + + /// + /// Gets or sets the original IP address for linking purposes. + /// + public string OriginalIpAddress { get; set; } = string.Empty; + + /// + /// Gets or sets the count of mobile login requests from this IP in the last 72 hours. + /// + public int MobileLoginCount72h { get; set; } +} diff --git a/apps/server/AliasVault.Admin/Main/Models/RecentUsageStatistics.cs b/apps/server/AliasVault.Admin/Main/Models/RecentUsageStatistics.cs index 5e3b35893..4c461b131 100644 --- a/apps/server/AliasVault.Admin/Main/Models/RecentUsageStatistics.cs +++ b/apps/server/AliasVault.Admin/Main/Models/RecentUsageStatistics.cs @@ -26,4 +26,9 @@ public class RecentUsageStatistics /// Gets or sets the list of IP addresses with most registrations in the last 72 hours. /// public List TopIpsByRegistrations72h { get; set; } = new(); + + /// + /// Gets or sets the list of IP addresses with most mobile login requests in the last 72 hours. + /// + public List TopIpsByMobileLogins72h { get; set; } = new(); } diff --git a/apps/server/AliasVault.Admin/Main/Pages/Dashboard/RecentUsage/Components/RecentUsageMobileLoginsTable.razor b/apps/server/AliasVault.Admin/Main/Pages/Dashboard/RecentUsage/Components/RecentUsageMobileLoginsTable.razor new file mode 100644 index 000000000..e77521759 --- /dev/null +++ b/apps/server/AliasVault.Admin/Main/Pages/Dashboard/RecentUsage/Components/RecentUsageMobileLoginsTable.razor @@ -0,0 +1,72 @@ +@using AliasVault.Admin.Main.Models +@using AliasVault.RazorComponents.Tables + +
+
+
+

Top IP Addresses by Mobile Login Requests (Last 72h)

+

IP addresses with the most mobile login requests in the last 72 hours (last octet anonymized)

+
+
+ + @if (Data != null && Data.Any()) + { +
+ +
+
+ + @foreach (var ip in PagedData) + { + + + + @ip.IpAddress + + + @ip.MobileLoginCount72h.ToString("N0") + + } + +
+ } + else if (Data != null) + { +
+ + + +

No Recent Mobile Logins

+

No mobile login requests occurred in the last 72 hours.

+
+ } + else + { +
+ +
+ } +
+ +@code { + [Parameter] + public List? Data { get; set; } + + private int CurrentPage { get; set; } = 1; + private int PageSize { get; set; } = 20; + + private IEnumerable PagedData => + Data?.Skip((CurrentPage - 1) * PageSize).Take(PageSize) ?? Enumerable.Empty(); + + private readonly List _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(); + } +} diff --git a/apps/server/AliasVault.Admin/Main/Pages/Dashboard/RecentUsageStats.razor b/apps/server/AliasVault.Admin/Main/Pages/Dashboard/RecentUsageStats.razor index 3b610210b..4c313e585 100644 --- a/apps/server/AliasVault.Admin/Main/Pages/Dashboard/RecentUsageStats.razor +++ b/apps/server/AliasVault.Admin/Main/Pages/Dashboard/RecentUsageStats.razor @@ -27,6 +27,9 @@ + + + @if (_loadingError) diff --git a/apps/server/AliasVault.Admin/Main/Pages/MobileLoginHistory.razor b/apps/server/AliasVault.Admin/Main/Pages/MobileLoginHistory.razor new file mode 100644 index 000000000..f461f7577 --- /dev/null +++ b/apps/server/AliasVault.Admin/Main/Pages/MobileLoginHistory.razor @@ -0,0 +1,372 @@ +@page "/mobile-login-history" +@using AliasVault.RazorComponents.Tables +@using Microsoft.AspNetCore.WebUtilities +@inject NavigationManager NavigationManager +@inherits MainBase + +Mobile Login History + + + + + + + +@if (IsInitialized) +{ +
+ +
+
+
+ + +
+
+
+ +
+
+
+} + +@if (IsLoading) +{ + +} +else +{ +
+ + @foreach (var request in RequestList) + { + + @request.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss") + + @(request.ClientIpAddress ?? "N/A") + + + @(request.MobileIpAddress ?? "N/A") + + + @if (request.FulfilledAt.HasValue) + { + @request.FulfilledAt.Value.ToString("yyyy-MM-dd HH:mm:ss") + } + else + { + - + } + + + @if (request.RetrievedAt.HasValue) + { + @request.RetrievedAt.Value.ToString("yyyy-MM-dd HH:mm:ss") + } + else + { + - + } + + + @if (!string.IsNullOrEmpty(request.Username)) + { + + @request.Username + + } + else + { + - + } + + + @if (request.RetrievedAt.HasValue) + { + + } + else if (request.FulfilledAt.HasValue) + { + + } + else + { + + } + + + } + +
+} + +@code { + private readonly List _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 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 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 ApplySearchFilter(IQueryable 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 ApplyStatusFilter(IQueryable 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 ApplySort(IQueryable 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; } + } +} diff --git a/apps/server/AliasVault.Admin/Main/Pages/Users/Users.razor b/apps/server/AliasVault.Admin/Main/Pages/Users/Users.razor index f46cf9110..13d1c5df0 100644 --- a/apps/server/AliasVault.Admin/Main/Pages/Users/Users.razor +++ b/apps/server/AliasVault.Admin/Main/Pages/Users/Users.razor @@ -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."> + + + + + Mobile Login History + diff --git a/apps/server/AliasVault.Admin/Services/StatisticsService.cs b/apps/server/AliasVault.Admin/Services/StatisticsService.cs index 94f033148..236e24acc 100644 --- a/apps/server/AliasVault.Admin/Services/StatisticsService.cs +++ b/apps/server/AliasVault.Admin/Services/StatisticsService.cs @@ -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(); } + + /// + /// Gets the top 20 IP addresses by number of mobile login requests in the last 72 hours. + /// + /// List of top IP addresses by mobile login requests. + private async Task> 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(); + } } diff --git a/apps/server/AliasVault.Client/Auth/Pages/MobileLogin.razor b/apps/server/AliasVault.Client/Auth/Pages/MobileLogin.razor index 848089c35..ea41182ee 100644 --- a/apps/server/AliasVault.Client/Auth/Pages/MobileLogin.razor +++ b/apps/server/AliasVault.Client/Auth/Pages/MobileLogin.razor @@ -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(); diff --git a/apps/server/Databases/AliasServerDb/Migrations/20251117162503_AddMobileLoginRequest.Designer.cs b/apps/server/Databases/AliasServerDb/Migrations/20251117175358_AddMobileLoginRequest.Designer.cs similarity index 98% rename from apps/server/Databases/AliasServerDb/Migrations/20251117162503_AddMobileLoginRequest.Designer.cs rename to apps/server/Databases/AliasServerDb/Migrations/20251117175358_AddMobileLoginRequest.Designer.cs index eeec7c7c1..68a99934b 100644 --- a/apps/server/Databases/AliasServerDb/Migrations/20251117162503_AddMobileLoginRequest.Designer.cs +++ b/apps/server/Databases/AliasServerDb/Migrations/20251117175358_AddMobileLoginRequest.Designer.cs @@ -12,7 +12,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; namespace AliasServerDb.Migrations { [DbContext(typeof(AliasServerDbContext))] - [Migration("20251117162503_AddMobileLoginRequest")] + [Migration("20251117175358_AddMobileLoginRequest")] partial class AddMobileLoginRequest { /// @@ -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"); }); diff --git a/apps/server/Databases/AliasServerDb/Migrations/20251117162503_AddMobileLoginRequest.cs b/apps/server/Databases/AliasServerDb/Migrations/20251117175358_AddMobileLoginRequest.cs similarity index 73% rename from apps/server/Databases/AliasServerDb/Migrations/20251117162503_AddMobileLoginRequest.cs rename to apps/server/Databases/AliasServerDb/Migrations/20251117175358_AddMobileLoginRequest.cs index 0487daa3d..b9a4a54c4 100644 --- a/apps/server/Databases/AliasServerDb/Migrations/20251117162503_AddMobileLoginRequest.cs +++ b/apps/server/Databases/AliasServerDb/Migrations/20251117175358_AddMobileLoginRequest.cs @@ -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"); } diff --git a/apps/server/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs b/apps/server/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs index 8fea293db..6c39e5759 100644 --- a/apps/server/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs +++ b/apps/server/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs @@ -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"); }); diff --git a/apps/server/Databases/AliasServerDb/MobileLoginRequest.cs b/apps/server/Databases/AliasServerDb/MobileLoginRequest.cs index 613b56e77..b2e14e6cc 100644 --- a/apps/server/Databases/AliasServerDb/MobileLoginRequest.cs +++ b/apps/server/Databases/AliasServerDb/MobileLoginRequest.cs @@ -7,13 +7,21 @@ namespace AliasServerDb; +using Microsoft.EntityFrameworkCore; + /// -/// Mobile unlock request entity for storing temporary unlock requests. +/// Mobile login request entity for storing temporary login requests. /// +[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 { /// - /// Gets or sets the unique identifier for this unlock request. + /// Gets or sets the unique identifier for this login request. /// public string Id { get; set; } = string.Empty;