PoS: Any store users can now generate a login QR any other store user (#7303)
What changed, and why it matters
This commit changes how login QR codes are generated for BTCPay Server's Point of Sale (PoS) feature. Previously, only store owners or admins could generate login codes, and there was a warning when selecting a store owner. After this change, any store user can generate a login QR code for any other store user, including store owners, without the previous confirmation step. The commit title itself says this is intentional: 'Any store users can now generate a login QR any other store user.' This appears to be a deliberate feature change, but it weakens access controls around sensitive login credentials.
Treat this commit as a security-relevant access-control change. Review whether any store user should be able to generate login credentials for any other store user, especially store owners. Verify that the login code/token mechanism behind UIAccountController.Login and UserLoginCode is single-use, time-limited, and requires the target user's explicit authentication. If the login code is a long-lived or reusable credential, consider reverting the removal of the owner confirmation and non-admin filtering. Add audit logging for QR-code generation on behalf of other users.
Security signals we found
Removal of privileged-user confirmation before displaying owner login QR code
Removal of non-admin self-only filter in FillUsers, exposing all store user emails to every store user
Change from user ID to email as login identifier in QR generation
Login action now redirects already-authenticated users when email matches, enabling QR-based login-as-other-user flows
Commit title explicitly frames change as 'Any store users can now generate a login QR any other store user'
Evidence from the diff
The patch removes the IsSelectedUserOwner() check and owner confirmation flow from PosLoginCode.razor. It changes the user selection from user IDs to email addresses, and the QR code now links to UIAccountController.Login with an email parameter and returnUrl. The FillUsers method no longer filters users to only the current user for non-server-admins; instead it returns all store users. UIAccountController.Login was modified so that an already-authenticated user is redirected only if no email is provided or if the authenticated user’s email matches the supplied email. The UserLoginCode component no longer accepts an external UserId parameter and instead always uses the currently authenticated user. Overall, the change broadens who can request login codes on behalf of others.
Changed components
BTCPayServer/Blazor/PosLoginCode.razorBTCPayServer/Blazor/QrCode.razorBTCPayServer/Blazor/UserLoginCode.razorBTCPayServer/Controllers/UIAccountController.csBTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.csBTCPayServer/Plugins/PointOfSale/Models/UpdatePointOfSaleViewModel.csBTCPayServer/Plugins/PointOfSale/Views/UpdatePointOfSale.cshtmlBTCPayServer/Views/UIManage/LoginCodes.cshtmlInspect captured patch +78 / −70
diff --git a/BTCPayServer/Blazor/PosLoginCode.razor b/BTCPayServer/Blazor/PosLoginCode.razor
index 6038325..bfc4ba2 100644
--- a/BTCPayServer/Blazor/PosLoginCode.razor
+++ b/BTCPayServer/Blazor/PosLoginCode.razor
@@ -1,10 +1,17 @@
-@if (Users?.Any() is true)
+@using BTCPayServer.Abstractions
+@using BTCPayServer.Controllers
+@using BTCPayServer.Plugins.PointOfSale.Controllers
+@using Microsoft.AspNetCore.Mvc
+@using Microsoft.AspNetCore.Routing
+@inject LinkGenerator LinkGenerator
+
+@if (UserEmails?.Any() is true)
{
<div @attributes="Attrs" class="@CssClass">
<label for="SignedInUser" class="form-label" text-translate="true">Signed in user</label>
- <select id="SignedInUser" class="form-select" value="@_userId" @onchange="@OnUserChanged">
+ <select id="SignedInUser" class="form-select" @onchange="@OnUserChanged">
<option value="" text-translate="true">None, just open the URL</option>
- @foreach (var u in Users)
+ @foreach (var u in UserEmails)
{
<option value="@u.Key">@u.Value</option>
}
@@ -12,62 +19,53 @@
</div>
}
-@if (string.IsNullOrEmpty(_userId))
-{
- <QrCode Data="@PosUrl" />
-}
-else
-{
- @if (IsSelectedUserOwner() && !_ownerConfirmed)
- {
- <div>
- <p><strong>This user is Store Owner</strong></p>
- <p>Please confirm you want this QR code to be displayed.</p>
-
- <button type="button" class="btn btn-danger" @onclick="ConfirmOwnerDisplay">Yes, show QR code</button>
- </div>
- }
- else
- {
- <UserLoginCode UserId="@_userId" RedirectUrl="@PosPath" />
- }
-}
+<QrCode Data="@_qr" CanCopy="true" />
@code {
- [Parameter, EditorRequired] public string PosPath { get; set; }
-
- [Parameter] public Dictionary<string, string> Users { get; set; }
- [Parameter] public string PosUrl { get; set; }
+ [Parameter] public Dictionary<string, string> UserEmails { get; set; }
+ [Parameter] public string BaseUrl { get; set; }
+ [Parameter] public string AppId { get; set; }
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object> Attrs { get; set; }
- private string _userId;
- private bool _ownerConfirmed = false;
private string CssClass => $"form-group {(Attrs?.ContainsKey("class") is true ? Attrs["class"] : "")}".Trim();
- private void OnUserChanged(ChangeEventArgs e)
+ string _qr = null;
+ string _posUrlAbsolute = null;
+ string _posPath = null;
+ protected override void OnParametersSet()
{
- _userId = e.Value?.ToString();
- _ownerConfirmed = false; // Reset confirmation when user changes
+ _posPath = LinkGenerator.GetPathByAction(
+ action: nameof(UIPointOfSaleController.ViewPointOfSale),
+ controller: "UIPointOfSale",
+ values: new { appId = AppId },
+ pathBase: RequestBaseUrl.FromUrl(BaseUrl).PathBase);
+ _posUrlAbsolute = LinkGenerator.GetUriByAction(
+ action: nameof(UIPointOfSaleController.ViewPointOfSale),
+ controller: "UIPointOfSale",
+ values: new { appId = AppId },
+ RequestBaseUrl.FromUrl(BaseUrl)
+ );
+
+ _qr = _posUrlAbsolute;
}
- private bool IsSelectedUserOwner()
+ private void OnUserChanged(ChangeEventArgs e)
{
- if (string.IsNullOrEmpty(_userId) || Users == null)
- return false;
-
- if (Users.TryGetValue(_userId, out var userInfo))
+ if (string.IsNullOrWhiteSpace(e.Value as string))
{
- return userInfo.Contains("Owner", StringComparison.OrdinalIgnoreCase);
+ _qr = _posUrlAbsolute;
+ }
+ else
+ {
+ var loginUrl = LinkGenerator.GetUriByAction(
+ action: nameof(UIAccountController.Login),
+ controller: "UIAccount",
+ values: new { returnUrl = _posPath, email = (string)e.Value },
+ RequestBaseUrl.FromUrl(BaseUrl)
+ );
+ _qr = loginUrl;
}
-
- return false;
- }
-
- private void ConfirmOwnerDisplay()
- {
- _ownerConfirmed = true;
}
-
}
diff --git a/BTCPayServer/Blazor/QrCode.razor b/BTCPayServer/Blazor/QrCode.razor
index ac94fe0..527fd99 100644
--- a/BTCPayServer/Blazor/QrCode.razor
+++ b/BTCPayServer/Blazor/QrCode.razor
@@ -2,13 +2,27 @@
@if (!string.IsNullOrEmpty(Data))
{
- <img @attributes="Attrs" style="image-rendering:pixelated;image-rendering:-moz-crisp-edges;min-width:@(Size)px;min-height:@(Size)px" src="data:image/png;base64,@(GetBase64(Data))" class="@CssClass" alt="@Data" />
+ if (CanCopy)
+ {
+ <div class="qr-container clipboard-button" data-clipboard="@Data">
+ <img @attributes="Attrs" style="image-rendering:pixelated;image-rendering:-moz-crisp-edges;min-width:@(Size)px;min-height:@(Size)px"
+ src="data:image/png;base64,@(GetBase64(Data))" class="@CssClass" alt="@Data" />
+ </div>
+ }
+ else
+ {
+ <img @attributes="Attrs" style="image-rendering:pixelated;image-rendering:-moz-crisp-edges;min-width:@(Size)px;min-height:@(Size)px"
+ src="data:image/png;base64,@(GetBase64(Data))" class="@CssClass" alt="@Data" />
+ }
}
@code {
[Parameter, EditorRequired]
public string Data { get; set; }
+ [Parameter]
+ public bool CanCopy { get; set; }
+
[Parameter]
public int Size { get; set; } = 256;
diff --git a/BTCPayServer/Blazor/UserLoginCode.razor b/BTCPayServer/Blazor/UserLoginCode.razor
index 1522868..b0819f6 100644
--- a/BTCPayServer/Blazor/UserLoginCode.razor
+++ b/BTCPayServer/Blazor/UserLoginCode.razor
@@ -28,9 +28,6 @@
}
@code {
- [Parameter]
- public string UserId { get; set; }
-
[Parameter]
public string RedirectUrl { get; set; }
@@ -48,8 +45,8 @@
protected override async Task OnParametersSetAsync()
{
- UserId ??= await GetUserId();
- if (!string.IsNullOrEmpty(UserId)) _user = await UserManager.FindByIdAsync(UserId);
+ var userId = await GetUserId();
+ if (!string.IsNullOrEmpty(userId)) _user = await UserManager.FindByIdAsync(userId);
if (_user == null) return;
GenerateCodeAndStartTimer();
diff --git a/BTCPayServer/Controllers/UIAccountController.cs b/BTCPayServer/Controllers/UIAccountController.cs
index 86ccaba..efefaf6 100644
--- a/BTCPayServer/Controllers/UIAccountController.cs
+++ b/BTCPayServer/Controllers/UIAccountController.cs
@@ -2,6 +2,7 @@ using System;
using System.Globalization;
using System.Linq;
using System.Net.Http;
+using System.Security.Claims;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
@@ -87,8 +88,12 @@ namespace BTCPayServer.Controllers
[AllowAnonymous]
public async Task<IActionResult> Login(string returnUrl = null, string email = null, bool allowLimitedLogin = false)
{
- if (User.Identity?.IsAuthenticated is true && string.IsNullOrEmpty(returnUrl))
- return RedirectToLocal();
+ var allowRedirect =
+ (email is null && User.Identity?.IsAuthenticated is true) ||
+ (email is not null && User.FindFirst(ClaimTypes.Email)?.Value == email);
+
+ if (allowRedirect)
+ return RedirectToLocal(returnUrl);
// Clear the existing external cookie to ensure a clean login process
await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);
@@ -224,6 +229,7 @@ namespace BTCPayServer.Controllers
return View(model);
}
+
var result = await signInManager.PasswordSignInAsync(model.Email, model.Password, model.RememberMe, lockoutOnFailure: true);
if (result.Succeeded)
{
diff --git a/BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs b/BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs
index 02759ca..bd6a60d 100644
--- a/BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs
+++ b/BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs
@@ -738,19 +738,13 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
return currency.Trim().ToUpperInvariant();
}
- private StoreData GetCurrentStore() => HttpContext.GetStoreData();
-
private AppData GetCurrentApp() => HttpContext.GetAppDataOrNull();
private async Task FillUsers(UpdatePointOfSaleViewModel vm)
{
- var users = await _storeRepository.GetStoreUsers(GetCurrentStore().Id);
-
- if (!User.IsInRole(Roles.ServerAdmin))
- users = users.Where(u => u.Id == User.GetId()).ToArray();
-
- vm.StoreUsers = users.Select(u => (u.Id, u.Email, u.StoreRole.Role))
- .ToDictionary(u => u.Id, u => $"{u.Email} ({u.Role})");
+ var users = await _storeRepository.GetStoreUsers(HttpContext.GetStoreData().Id);
+ vm.StoreUserEmails = users.Select(u => (u.Email, u.StoreRole.Role))
+ .ToDictionary(u => u.Email, u => $"{u.Email} ({u.Role})");
}
}
}
diff --git a/BTCPayServer/Plugins/PointOfSale/Models/UpdatePointOfSaleViewModel.cs b/BTCPayServer/Plugins/PointOfSale/Models/UpdatePointOfSaleViewModel.cs
index eda6c49..927b706 100644
--- a/BTCPayServer/Plugins/PointOfSale/Models/UpdatePointOfSaleViewModel.cs
+++ b/BTCPayServer/Plugins/PointOfSale/Models/UpdatePointOfSaleViewModel.cs
@@ -74,7 +74,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Models
public string CustomTipPercentages { get; set; }
public string Id { get; set; }
- public Dictionary<string, string> StoreUsers { get; set; }
+ public Dictionary<string, string> StoreUserEmails { get; set; }
[Display(Name = "Redirect invoice to redirect url automatically after paid")]
public string RedirectAutomatically { get; set; } = string.Empty;
diff --git a/BTCPayServer/Plugins/PointOfSale/Views/UpdatePointOfSale.cshtml b/BTCPayServer/Plugins/PointOfSale/Views/UpdatePointOfSale.cshtml
index 167d1d0..816c77e 100644
--- a/BTCPayServer/Plugins/PointOfSale/Views/UpdatePointOfSale.cshtml
+++ b/BTCPayServer/Plugins/PointOfSale/Views/UpdatePointOfSale.cshtml
@@ -9,8 +9,7 @@
ViewData.SetLayoutModel(new($"{nameof(PointOfSalePlugin)}-{Model.Id}",StringLocalizer["Update Point of Sale"]));
Csp.UnsafeEval();
var checkoutFormOptions = await FormDataService.GetSelect(Model.StoreId, Model.FormId);
- var posPath = Url.Action("ViewPointOfSale", "UIPointOfSale", new { appId = Model.Id });
- var posUrl = Url.ActionAbsolute(this.Context.Request, "ViewPointOfSale", "UIPointOfSale", new { appId = Model.Id }).AbsoluteUri;
+ var baseUrl = this.Context.Request.GetRequestBaseUrl().ToString();
}
@section PageHeadContent {
@@ -364,9 +363,9 @@ Please insert valid HTML here. Only meta tags accepted.'>
</div>
<div class="modal-body pt-0">
<component type="typeof(BTCPayServer.Blazor.PosLoginCode)" render-mode="ServerPrerendered"
- param-Users="@Model.StoreUsers"
- param-PosPath="@posPath"
- param-PosUrl="@posUrl"/>
+ param-UserEmails="@Model.StoreUserEmails"
+ param-AppId="@Model.Id"
+ param-BaseUrl="@baseUrl"/>
</div>
</div>
</div>
diff --git a/BTCPayServer/Views/UIManage/LoginCodes.cshtml b/BTCPayServer/Views/UIManage/LoginCodes.cshtml
index be74977..a58f755 100644
--- a/BTCPayServer/Views/UIManage/LoginCodes.cshtml
+++ b/BTCPayServer/Views/UIManage/LoginCodes.cshtml
@@ -8,4 +8,4 @@
</div>
<partial name="_StatusMessage" />
<p text-translate="true">Easily log into BTCPay Server on another device using a simple login code from an already authenticated device.</p>
-<component type="typeof(BTCPayServer.Blazor.UserLoginCode)" render-mode="ServerPrerendered" param-UserId="@User.GetId()" param-id="@("LoginCode")"/>
+<component type="typeof(BTCPayServer.Blazor.UserLoginCode)" render-mode="ServerPrerendered" param-id="@("LoginCode")"/>
Why this scored 66/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.