Refactor: Add btcpay.impersonation.canimpersonate permission (#7327)
What changed, and why it matters
This commit refactors an existing "login code" feature into a new optional plugin called Impersonation. The feature lets an already-logged-in user generate a short-lived QR code/login code that can be used on another device to sign in as the same user. The commit adds a new permission, btcpay.impersonation.canimpersonate, and rules that try to limit impersonation: a user can impersonate themselves, and server admins can impersonate non-admin users but not other admins. The code is a refactor rather than a reported vulnerability fix, but it moves a sensitive authentication capability behind a plugin and a permission check, which is a security-relevant design change.
Review the new ImpersonationPermissionHandler logic for bypasses, especially around role checks and the scope parameter. Ensure the plugin is not enabled by default on production deployments and that server admins understand it grants the ability to sign in as non-admin users. Verify that login-code URLs cannot be leaked or replayed, and that the [AllowAnonymous] /login/code endpoint correctly rejects already-authenticated users attempting self-impersonation. Consider whether a 1-day persistent cookie is appropriate for impersonation sessions.
Security signals we found
New permission policy btcpay.impersonation.canimpersonate added
Impersonation capability moved from core into a plugin
Authorization check added before generating login codes
Rule added: admins cannot impersonate other admins
Rule added: non-admins cannot impersonate other users
Login code endpoint remains [AllowAnonymous] and rate-limited by remote address
Login code is single-use, 60-second expiration, 20 random bytes hex
Persistent 1-day cookie issued after successful login-code sign-in
Evidence from the diff
The change extracts UserLoginCodeService, the /login/code endpoints, and the LoginCodes UI from core BTCPay Server into a new Impersonation plugin. A new policy handler (ImpersonationPermissionHandler) enforces: self-impersonation allowed; non-admins denied; admins allowed only if the target user is not a ServerAdmin. The login-code generation UI now authorizes via IAuthorizationService against ImpersonationPlugin.CanImpersonateUser before producing a code. The controller rejects codes whose userId equals the already-authenticated user’s id (returning to login), and signs in the target with a one-day persistent cookie. The feature is also exposed in API key permission documentation as btcpay.impersonation.canimpersonate.
Changed components
BTCPayServer.Plugins.ImpersonationUIImpersonationControllerImpersonationPermissionHandlerUserLoginCodeServiceUserLoginCode.razorBTCPayServer.Security permission systemUIAccountController (login code endpoints removed)UIManageController.LoginCodes (removed)Login.cshtml form target changed to impersonation areaInspect captured patch +434 / −294
diff --git a/BTCPayServer.Tests/ImpersonationTests.cs b/BTCPayServer.Tests/ImpersonationTests.cs
new file mode 100644
index 0000000..16bf904
--- /dev/null
+++ b/BTCPayServer.Tests/ImpersonationTests.cs
@@ -0,0 +1,48 @@
+using System.Threading.Tasks;
+using BTCPayServer.Views.Manage;
+using BTCPayServer.Views.Server;
+using BTCPayServer.Views.Stores;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace BTCPayServer.Tests;
+
+public class ImpersonationTests(ITestOutputHelper helper) : UnitTestBase(helper)
+{
+ [Fact]
+ [Trait("Playwright", "Playwright")]
+ public async Task CanSigninWithLoginCode()
+ {
+ await using var s = CreatePlaywrightTester();
+ await s.StartAsync();
+ var user = await s.RegisterNewUser();
+ await s.GoToHome();
+ await s.GoToProfile(ManageNavPages.LoginCodes);
+
+ await s.Page.WaitForSelectorAsync("#LoginCode .qr-code");
+ var code = await s.Page.Locator("#LoginCode .qr-code").GetAttributeAsync("alt");
+ string prevCode = code;
+ await s.Page.ReloadAsync();
+ await s.Page.WaitForSelectorAsync("#LoginCode .qr-code");
+ code = await s.Page.Locator("#LoginCode .qr-code").GetAttributeAsync("alt");
+ Assert.NotEqual(prevCode, code);
+ await s.Logout();
+ await s.GoToLogin();
+ await s.Page.EvaluateAsync("document.getElementById('LoginCode').value = 'bad code'");
+ await s.Page.EvaluateAsync("document.getElementById('logincode-form').submit()");
+ await s.Page.WaitForLoadStateAsync();
+
+ await s.GoToLogin();
+ await s.Page.EvaluateAsync($"document.getElementById('LoginCode').value = '{code}'");
+ await s.Page.EvaluateAsync("document.getElementById('logincode-form').submit()");
+ await s.Page.WaitForLoadStateAsync();
+ await s.Page.WaitForLoadStateAsync();
+
+ await s.CreateNewStore();
+ await s.GoToHome();
+ await s.Page.WaitForLoadStateAsync();
+ await s.Page.WaitForLoadStateAsync();
+ var content = await s.Page.ContentAsync();
+ Assert.Contains(user, content);
+ }
+}
diff --git a/BTCPayServer.Tests/PMO/UsersPMO.cs b/BTCPayServer.Tests/PMO/UsersPMO.cs
index adde545..d95a18d 100644
--- a/BTCPayServer.Tests/PMO/UsersPMO.cs
+++ b/BTCPayServer.Tests/PMO/UsersPMO.cs
@@ -6,8 +6,10 @@ public class UsersPMO(PlaywrightTester s)
{
public async Task DeleteUser(string email)
{
- await s.Page.ClickAsync($"tr[data-email=\"{email}\"] .delete-user");
+ await s.Page.ClickAsync($"{Row(email)} .delete-user");
await s.Page.ClickAsync(".modal-confirm");
await s.FindAlertMessage();
}
+
+ private static string Row(string email) => $"tr[data-email=\"{email}\"]";
}
diff --git a/BTCPayServer.Tests/PlaywrightTests.cs b/BTCPayServer.Tests/PlaywrightTests.cs
index 9e52655..72d4d67 100644
--- a/BTCPayServer.Tests/PlaywrightTests.cs
+++ b/BTCPayServer.Tests/PlaywrightTests.cs
@@ -2222,45 +2222,6 @@ namespace BTCPayServer.Tests
}
}
- [Fact]
- [Trait("Playwright", "Playwright")]
- public async Task CanSigninWithLoginCode()
- {
- await using var s = CreatePlaywrightTester();
- await s.StartAsync();
- var user = await s.RegisterNewUser();
- await s.GoToHome();
- await s.GoToProfile(ManageNavPages.LoginCodes);
-
- await s.Page.WaitForSelectorAsync("#LoginCode .qr-code");
- var code = await s.Page.Locator("#LoginCode .qr-code").GetAttributeAsync("alt");
- string prevCode = code;
- await s.Page.ReloadAsync();
- await s.Page.WaitForSelectorAsync("#LoginCode .qr-code");
- code = await s.Page.Locator("#LoginCode .qr-code").GetAttributeAsync("alt");
- Assert.NotEqual(prevCode, code);
- await s.Page.WaitForSelectorAsync("#LoginCode .qr-code");
- code = await s.Page.Locator("#LoginCode .qr-code").GetAttributeAsync("alt");
- await s.Logout();
- await s.GoToLogin();
- await s.Page.EvaluateAsync("document.getElementById('LoginCode').value = 'bad code'");
- await s.Page.EvaluateAsync("document.getElementById('logincode-form').submit()");
- await s.Page.WaitForLoadStateAsync();
-
- await s.GoToLogin();
- await s.Page.EvaluateAsync($"document.getElementById('LoginCode').value = '{code}'");
- await s.Page.EvaluateAsync("document.getElementById('logincode-form').submit()");
- await s.Page.WaitForLoadStateAsync();
- await s.Page.WaitForLoadStateAsync();
-
- await s.CreateNewStore();
- await s.GoToHome();
- await s.Page.WaitForLoadStateAsync();
- await s.Page.WaitForLoadStateAsync();
- var content = await s.Page.ContentAsync();
- Assert.Contains(user, content);
- }
-
[Fact]
public async Task CanUseInvoiceReceipts()
{
diff --git a/BTCPayServer/Blazor/UserLoginCode.razor b/BTCPayServer/Blazor/UserLoginCode.razor
deleted file mode 100644
index b0819f6..0000000
--- a/BTCPayServer/Blazor/UserLoginCode.razor
+++ /dev/null
@@ -1,99 +0,0 @@
-@using System.Timers
-@using BTCPayServer.Data
-@using BTCPayServer.Fido2
-@using Microsoft.AspNetCore.Http
-@using Microsoft.AspNetCore.Identity
-@using Microsoft.AspNetCore.Mvc
-@using Microsoft.AspNetCore.Routing
-@using Microsoft.Extensions.Localization
-@inject AuthenticationStateProvider AuthenticationStateProvider
-@inject UserManager<ApplicationUser> UserManager
-@inject UserLoginCodeService UserLoginCodeService
-@inject LinkGenerator LinkGenerator
-@inject IHttpContextAccessor HttpContextAccessor
-@inject IStringLocalizer StringLocalizer
-@implements IDisposable
-
-@if (!string.IsNullOrEmpty(_data))
-{
- <div @attributes="Attrs" class="@CssClass" style="width:@(Size)px">
- <div class="qr-container mb-2">
- <QrCode Data="@_data" Size="Size"/>
- </div>
- <p class="text-center text-muted mb-1" id="progress">@StringLocalizer["Valid for {0} seconds", _seconds]</p>
- <div class="progress only-for-js" data-bs-toggle="tooltip" data-bs-placement="top">
- <div class="progress-bar progress-bar-striped progress-bar-animated @(Percent < 15 ? "bg-warning" : null)" role="progressbar" style="width:@Percent%" id="progressbar"></div>
- </div>
- </div>
-}
-
-@code {
- [Parameter]
- public string RedirectUrl { get; set; }
-
- [Parameter]
- public int Size { get; set; } = 256;
-
- [Parameter(CaptureUnmatchedValues = true)]
- public Dictionary<string, object> Attrs { get; set; }
-
- private static readonly double Seconds = UserLoginCodeService.ExpirationTime.TotalSeconds;
- private double _seconds = Seconds;
- private string _data;
- private ApplicationUser _user;
- private Timer _timer;
-
- protected override async Task OnParametersSetAsync()
- {
- var userId = await GetUserId();
- if (!string.IsNullOrEmpty(userId)) _user = await UserManager.FindByIdAsync(userId);
- if (_user == null) return;
-
- GenerateCodeAndStartTimer();
- }
-
- public void Dispose()
- {
- _timer?.Dispose();
- }
-
- private void GenerateCodeAndStartTimer()
- {
- var loginCode = UserLoginCodeService.GetOrGenerate(_user.Id);
- _data = GetData(loginCode);
- _seconds = Seconds;
- _timer?.Dispose();
- _timer = new Timer(1000);
- _timer.Elapsed += CountDownTimer;
- _timer.Enabled = true;
- }
-
- private void CountDownTimer(object source, ElapsedEventArgs e)
- {
- if (_seconds > 0)
- _seconds -= 1;
- else
- GenerateCodeAndStartTimer();
- InvokeAsync(StateHasChanged);
- }
-
- private async Task<string> GetUserId()
- {
- var state = await AuthenticationStateProvider.GetAuthenticationStateAsync();
- return state.User.Identity?.IsAuthenticated is true
- ? state.User.GetIdOrNull()
- : null;
- }
-
- private string GetData(string loginCode)
- {
- var req = HttpContextAccessor.HttpContext?.Request;
- if (req == null) return loginCode;
- return !string.IsNullOrEmpty(RedirectUrl)
- ? LinkGenerator.LoginCodeLink(loginCode, RedirectUrl, req.Scheme, req.Host, req.PathBase)
- : $"{loginCode};{LinkGenerator.IndexLink(req.Scheme, req.Host, req.PathBase)};{_user.Email}";
- }
-
- private double Percent => Math.Round(_seconds / Seconds * 100);
- private string CssClass => $"user-login-code d-inline-flex flex-column {(Attrs?.ContainsKey("class") is true ? Attrs["class"] : "")}".Trim();
-}
diff --git a/BTCPayServer/Components/MainNav/Default.cshtml b/BTCPayServer/Components/MainNav/Default.cshtml
index f116205..865da4b 100644
--- a/BTCPayServer/Components/MainNav/Default.cshtml
+++ b/BTCPayServer/Components/MainNav/Default.cshtml
@@ -428,9 +428,6 @@
<li class="nav-item nav-item-sub">
<a layout-menu-item="@nameof(ManageNavPages.Notifications)" asp-controller="UIManage" asp-action="NotificationSettings" text-translate="true">Notifications</a>
</li>
- <li class="nav-item nav-item-sub">
- <a layout-menu-item="@nameof(ManageNavPages.LoginCodes)" asp-controller="UIManage" asp-action="LoginCodes" text-translate="true">Login Codes</a>
- </li>
<vc:ui-extension-point location="user-nav" model="@Model" />
}
@if (!string.IsNullOrWhiteSpace(Model.ContactUrl))
diff --git a/BTCPayServer/Components/UiExtensionPoint/UIExtensionPoint.cs b/BTCPayServer/Components/UiExtensionPoint/UIExtensionPoint.cs
index 3efa11e..5b274cb 100644
--- a/BTCPayServer/Components/UiExtensionPoint/UIExtensionPoint.cs
+++ b/BTCPayServer/Components/UiExtensionPoint/UIExtensionPoint.cs
@@ -6,7 +6,7 @@ namespace BTCPayServer.Components.UIExtensionPoint
{
public class UiExtensionPoint(UIExtensionsRegistry uiExtensions) : ViewComponent
{
- public IViewComponentResult Invoke(string location, object model)
+ public IViewComponentResult Invoke(string location, object model = null)
{
return View(new UiExtensionPointViewModel()
{
diff --git a/BTCPayServer/Controllers/UIAccountController.cs b/BTCPayServer/Controllers/UIAccountController.cs
index 0cc3c95..bf58886 100644
--- a/BTCPayServer/Controllers/UIAccountController.cs
+++ b/BTCPayServer/Controllers/UIAccountController.cs
@@ -42,7 +42,6 @@ namespace BTCPayServer.Controllers
BTCPayServerEnvironment btcPayServerEnvironment,
EventAggregator eventAggregator,
Fido2Service fido2Service,
- UserLoginCodeService userLoginCodeService,
LnurlAuthService lnurlAuthService,
EmailSenderFactory emailSenderFactory,
CallbackGenerator callbackGenerator,
@@ -107,59 +106,6 @@ namespace BTCPayServer.Controllers
return View(nameof(Login), new LoginViewModel { Email = email, AllowLimitedLogin = allowLimitedLogin });
}
- // GET is for signin via the POS backend
- [HttpGet("/login/code")]
- [AllowAnonymous]
- [RateLimitsFilter(ZoneLimits.Login, Scope = RateLimitsScope.RemoteAddress)]
- public async Task<IActionResult> LoginUsingCode(string loginCode, string returnUrl = null)
- {
- return await LoginCodeResult(loginCode, returnUrl);
- }
-
- [HttpPost("/login/code")]
- [AllowAnonymous]
- [ValidateAntiForgeryToken]
- [RateLimitsFilter(ZoneLimits.Login, Scope = RateLimitsScope.RemoteAddress)]
- public async Task<IActionResult> LoginWithCode(string loginCode, string returnUrl = null)
- {
- return await LoginCodeResult(loginCode, returnUrl);
- }
-
- private async Task<IActionResult> LoginCodeResult(string loginCode, string returnUrl)
- {
- if (!string.IsNullOrEmpty(loginCode))
- {
- var code = loginCode.Split(';').First();
- var userId = userLoginCodeService.Verify(code);
- if (userId is null)
- {
- TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["Login code was invalid"].Value;
- return await Login(returnUrl);
- }
-
- var user = await userManager.FindByIdAsync(userId);
- var loginContext = CreateLoginContext(user);
- if (!await userService.CanLogin(loginContext))
- {
- TempData.SetStatusLoginResult(loginContext);
- return await Login(returnUrl);
- }
-
- _logger.LogInformation("User {Email} logged in with a login code", user!.Email);
- var now = DateTimeOffset.UtcNow;
- var authProperties = new AuthenticationProperties
- {
- IssuedUtc = now,
- AllowRefresh = false,
- IsPersistent = true,
- ExpiresUtc = now.AddDays(1)
- };
- await signInManager.SignInAsync(user, authProperties, "LoginCode");
- return RedirectToLocal(returnUrl);
- }
- return await Login(returnUrl);
- }
-
private UserService.CanLoginContext CreateLoginContext(ApplicationUser user)
=> new(user, StringLocalizer, viewLocalizer, this.HttpContext.Request.GetRequestBaseUrl());
diff --git a/BTCPayServer/Controllers/UIManageController.LoginCodes.cs b/BTCPayServer/Controllers/UIManageController.LoginCodes.cs
deleted file mode 100644
index 39a9b3b..0000000
--- a/BTCPayServer/Controllers/UIManageController.LoginCodes.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-using Microsoft.AspNetCore.Mvc;
-
-namespace BTCPayServer.Controllers;
-
-public partial class UIManageController
-{
- [HttpGet]
- public ActionResult LoginCodes()
- {
- return View();
- }
-}
diff --git a/BTCPayServer/Controllers/UIServerController.cs b/BTCPayServer/Controllers/UIServerController.cs
index 44e1c89..84cd9ca 100644
--- a/BTCPayServer/Controllers/UIServerController.cs
+++ b/BTCPayServer/Controllers/UIServerController.cs
@@ -14,6 +14,7 @@ using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Configuration;
using BTCPayServer.Data;
+using BTCPayServer.Fido2;
using BTCPayServer.HostedServices;
using BTCPayServer.Logging;
using BTCPayServer.Models.ServerViewModels;
diff --git a/BTCPayServer/Extensions/UrlHelperExtensions.cs b/BTCPayServer/Extensions/UrlHelperExtensions.cs
index 073f48d..513de99 100644
--- a/BTCPayServer/Extensions/UrlHelperExtensions.cs
+++ b/BTCPayServer/Extensions/UrlHelperExtensions.cs
@@ -32,13 +32,8 @@ namespace Microsoft.AspNetCore.Mvc
return url;
return null;
}
-#nullable restore
-
- public static string LoginCodeLink(this LinkGenerator urlHelper, string loginCode, string returnUrl, string scheme, HostString host, string pathbase)
- {
- return urlHelper.GetUriByAction(nameof(UIAccountController.LoginUsingCode), "UIAccount", new { loginCode, returnUrl }, scheme, host, pathbase);
- }
+#nullable restore
public static string PaymentRequestLink(this LinkGenerator urlHelper, string paymentRequestId, RequestBaseUrl baseUrl)
=> PaymentRequestLink(urlHelper, paymentRequestId, baseUrl.Scheme, baseUrl.Host, baseUrl.PathBase);
public static string PaymentRequestLink(this LinkGenerator urlHelper, string paymentRequestId, string scheme, HostString host, string pathbase)
diff --git a/BTCPayServer/Fido2/UserLoginCodeService.cs b/BTCPayServer/Fido2/UserLoginCodeService.cs
deleted file mode 100644
index 09c78b2..0000000
--- a/BTCPayServer/Fido2/UserLoginCodeService.cs
+++ /dev/null
@@ -1,52 +0,0 @@
-using System;
-using Microsoft.Extensions.Caching.Memory;
-using NBitcoin;
-using NBitcoin.DataEncoders;
-
-namespace BTCPayServer.Fido2
-{
- public class UserLoginCodeService
- {
- private readonly IMemoryCache _memoryCache;
- public static readonly TimeSpan ExpirationTime = TimeSpan.FromSeconds(60);
-
- public UserLoginCodeService(IMemoryCache memoryCache)
- {
- _memoryCache = memoryCache;
- }
-
- private string GetCacheKey(string userId)
- {
- return $"{nameof(UserLoginCodeService)}_{userId.ToLowerInvariant()}";
- }
-
- public string GetOrGenerate(string userId)
- {
- var key = GetCacheKey(userId);
- if (_memoryCache.TryGetValue(key, out var code))
- {
- _memoryCache.Remove(code);
- _memoryCache.Remove(key);
- }
- return _memoryCache.GetOrCreate(GetCacheKey(userId), entry =>
- {
- entry.AbsoluteExpirationRelativeToNow = ExpirationTime;
- var code = Encoders.Hex.EncodeData(RandomUtils.GetBytes(20));
- using var newEntry = _memoryCache.CreateEntry(code);
- newEntry.AbsoluteExpirationRelativeToNow = ExpirationTime;
- newEntry.Value = userId;
-
- return code;
- });
- }
- public string Verify(string code)
- {
- if (!_memoryCache.TryGetValue(code, out var userId))
- return null;
- _memoryCache.Remove(GetCacheKey((string)userId));
- _memoryCache.Remove(code);
- return (string)userId;
-
- }
- }
-}
diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs
index e41664e..21786af 100644
--- a/BTCPayServer/Hosting/BTCPayServerServices.cs
+++ b/BTCPayServer/Hosting/BTCPayServerServices.cs
@@ -670,7 +670,7 @@ namespace BTCPayServer.Hosting
new PolicyDefinition(
Policies.CanViewPayouts,
new PermissionDisplay("View payouts", "Allows viewing payouts on all your stores."),
- new PermissionDisplay("View payouts in selected stores", "Allows viewing payouts on the selected stores.")),
+ new PermissionDisplay("View payouts in selected stores", "Allows viewing payouts on the selected stores."))
});
return services;
diff --git a/BTCPayServer/Hosting/Startup.cs b/BTCPayServer/Hosting/Startup.cs
index 47bda34..14c2745 100644
--- a/BTCPayServer/Hosting/Startup.cs
+++ b/BTCPayServer/Hosting/Startup.cs
@@ -149,7 +149,6 @@ namespace BTCPayServer.Hosting
};
});
services.AddScoped<Fido2Service>();
- services.AddSingleton<UserLoginCodeService>();
services.AddSingleton<LnurlAuthService>();
services.AddSingleton<LightningAddressService>();
var mvcBuilder = services.AddMvc(o =>
diff --git a/BTCPayServer/Models/ServerViewModels/UsersViewModel.cs b/BTCPayServer/Models/ServerViewModels/UsersViewModel.cs
index b32f463..9bf6cf6 100644
--- a/BTCPayServer/Models/ServerViewModels/UsersViewModel.cs
+++ b/BTCPayServer/Models/ServerViewModels/UsersViewModel.cs
@@ -12,36 +12,36 @@ namespace BTCPayServer.Models.ServerViewModels
public class UserViewModel
{
public string Id { get; set; }
-
+
[Display(Name = "Email")]
public string Email { get; set; }
-
+
[Display(Name = "Name")]
public string Name { get; set; }
-
+
[Display(Name = "Invitation URL")]
public string InvitationUrl { get; set; }
-
+
[Display(Name = "Image")]
public IFormFile ImageFile { get; set; }
-
+
public string ImageUrl { get; set; }
-
+
[Display(Name = "Email Confirmed")]
public bool? EmailConfirmed { get; set; }
-
+
[Display(Name = "Approved")]
public bool? Approved { get; set; }
-
+
[Display(Name = "Disabled")]
public bool Disabled { get; set; }
-
+
[Display(Name = "Is Admin")]
public bool IsAdmin { get; set; }
-
+
[Display(Name = "Created")]
public DateTimeOffset? Created { get; set; }
-
+
public IEnumerable<string> Roles { get; set; }
public IEnumerable<UserStore> Stores { get; set; }
}
diff --git a/BTCPayServer/Plugins/Impersonation/ImpersonationPermissionHandler.cs b/BTCPayServer/Plugins/Impersonation/ImpersonationPermissionHandler.cs
new file mode 100644
index 0000000..891514d
--- /dev/null
+++ b/BTCPayServer/Plugins/Impersonation/ImpersonationPermissionHandler.cs
@@ -0,0 +1,48 @@
+using System.Threading.Tasks;
+using BTCPayServer.Data;
+using BTCPayServer.Security;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace BTCPayServer.Plugins.Impersonation;
+
+public class ImpersonationPermissionHandler(IServiceScopeFactory services) : IPermissionHandler
+{
+ public async Task HandleAsync(AuthorizationHandlerContext authContext, PermissionAuthorizationContext permContext)
+ {
+ if (permContext.Permission.Policy == ImpersonationPlugin.CanImpersonateUser)
+ {
+ var isAdmin = authContext.User.IsInRole(Roles.ServerAdmin);
+ if (await CanImpersonateUser(permContext, isAdmin))
+ authContext.Succeed(permContext.Requirement);
+ }
+ }
+ /// <summary>
+ /// Determines if the current user can impersonate the target user.
+ /// Rules:
+ /// - User can always impersonate themselves
+ /// - Non-admin users cannot impersonate others
+ /// - ServerAdmins users can impersonate any user except other ServerAdmins
+ /// - Returns false if the target user does not exist
+ /// </summary>
+ /// <param name="permContext"></param>
+ /// <param name="isAdmin"></param>
+ /// <returns></returns>
+ private async Task<bool> CanImpersonateUser(PermissionAuthorizationContext permContext, bool isAdmin)
+ {
+ if (permContext.Permission.Scope is not string impersonatedUserId)
+ return false;
+ if (permContext.UserId == impersonatedUserId)
+ return true;
+ if (!isAdmin)
+ return false;
+
+ await using var scope = services.CreateAsyncScope();
+ var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
+ var impersonatedUser = await userManager.FindByIdAsync(impersonatedUserId);
+ if (impersonatedUser is null)
+ return false;
+ return !await userManager.IsInRoleAsync(impersonatedUser, Roles.ServerAdmin);
+ }
+}
diff --git a/BTCPayServer/Plugins/Impersonation/ImpersonationPlugin.cs b/BTCPayServer/Plugins/Impersonation/ImpersonationPlugin.cs
new file mode 100644
index 0000000..2b9ed84
--- /dev/null
+++ b/BTCPayServer/Plugins/Impersonation/ImpersonationPlugin.cs
@@ -0,0 +1,30 @@
+using BTCPayServer.Abstractions.Models;
+using BTCPayServer.Client;
+using BTCPayServer.Security;
+using BTCPayServer.Services;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace BTCPayServer.Plugins.Impersonation;
+
+public class ImpersonationPlugin : BaseBTCPayServerPlugin
+{
+ public const string CanImpersonateUser = "btcpay.impersonation.canimpersonate";
+ public const string Area = "Impersonation";
+ public override string Identifier => "BTCPayServer.Plugins.Impersonation";
+ public override string Name => "Impersonation";
+ public override string Description => "Allow user impersonation";
+
+ public override void Execute(IServiceCollection services)
+ {
+ services.AddUIExtension("user-nav", "/Plugins/Impersonation/Views/UserNav.cshtml");
+ services.AddSingleton<UserLoginCodeService>();
+ services.AddPolicyDefinitions(new[]
+ {
+ new PolicyDefinition(
+ CanImpersonateUser,
+ new PermissionDisplay("Can impersonate users", "Allows user impersonation."),
+ new PermissionDisplay("Can impersonate the selected users", "Allows impersonation of the selected users."))
+ });
+ services.AddSingleton<IPermissionHandler, ImpersonationPermissionHandler>();
+ }
+}
diff --git a/BTCPayServer/Plugins/Impersonation/LinkGenerator.Impersonation.cs b/BTCPayServer/Plugins/Impersonation/LinkGenerator.Impersonation.cs
new file mode 100644
index 0000000..cd8a617
--- /dev/null
+++ b/BTCPayServer/Plugins/Impersonation/LinkGenerator.Impersonation.cs
@@ -0,0 +1,14 @@
+#nullable enable
+using BTCPayServer.Abstractions;
+using BTCPayServer.Plugins.Impersonation;
+using Microsoft.AspNetCore.Routing;
+
+namespace Microsoft.AspNetCore.Mvc;
+
+public static class ImpersonationUrlHelperExtensions
+{
+ public static string LoginCodeLink(this LinkGenerator urlHelper, string loginCode, string? returnUrl, RequestBaseUrl requestBaseUrl)
+ {
+ return urlHelper.GetUriByAction(nameof(UIImpersonationController.LoginUsingCode), "UIImpersonation", new { area = ImpersonationPlugin.Area, loginCode, returnUrl }, requestBaseUrl);
+ }
+}
diff --git a/BTCPayServer/Plugins/Impersonation/UIImpersonationController.cs b/BTCPayServer/Plugins/Impersonation/UIImpersonationController.cs
new file mode 100644
index 0000000..35ede47
--- /dev/null
+++ b/BTCPayServer/Plugins/Impersonation/UIImpersonationController.cs
@@ -0,0 +1,115 @@
+using System;
+using System.Security.Claims;
+using System.Threading.Tasks;
+using BTCPayServer.Abstractions.Constants;
+using BTCPayServer.Abstractions.Extensions;
+using BTCPayServer.Client;
+using BTCPayServer.Controllers;
+using BTCPayServer.Data;
+using BTCPayServer.Services;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Localization;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.Localization;
+using NicolasDorier.RateLimits;
+
+namespace BTCPayServer.Plugins.Impersonation;
+
+[Area(ImpersonationPlugin.Area)]
+public class UIImpersonationController(
+ UserLoginCodeService userLoginCodeService,
+ IStringLocalizer stringLocalizer,
+ UserManager<ApplicationUser> userManager,
+ SignInManager<ApplicationUser> signInManager,
+ ViewLocalizer viewLocalizer,
+ UserService userService) : Controller
+{
+ public IStringLocalizer StringLocalizer { get; } = stringLocalizer;
+ private UserService.CanLoginContext CreateLoginContext(ApplicationUser user)
+ => new(user, StringLocalizer, viewLocalizer, this.HttpContext.Request.GetRequestBaseUrl());
+
+ [HttpGet("/account/login-codes")]
+ [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanViewProfile)]
+ public ActionResult LoginCodes()
+ {
+ return View();
+ }
+
+ // GET is for signin via the POS backend
+ [HttpGet("/login/code")]
+ [AllowAnonymous]
+ [RateLimitsFilter(ZoneLimits.Login, Scope = RateLimitsScope.RemoteAddress)]
+ public async Task<IActionResult> LoginUsingCode(string loginCode, string returnUrl = null)
+ {
+ return await LoginCodeResult(loginCode, returnUrl);
+ }
+
+ [HttpPost("/login/code")]
+ [AllowAnonymous]
+ [ValidateAntiForgeryToken]
+ [RateLimitsFilter(ZoneLimits.Login, Scope = RateLimitsScope.RemoteAddress)]
+ public async Task<IActionResult> LoginWithCode(string loginCode, string returnUrl = null)
+ {
+ return await LoginCodeResult(loginCode, returnUrl);
+ }
+
+ private async Task<IActionResult> LoginCodeResult(string loginCode, string returnUrl)
+ {
+ if (!string.IsNullOrEmpty(loginCode))
+ {
+ // loginCode might be url: https://btcpay.example.com/login/code?loginCode=***&returnUrl=***
+ // if that's the case, we need to extract the loginCode and the returnUrl from the query string.
+ if (Uri.TryCreate(loginCode, UriKind.Absolute, out var uri))
+ {
+ var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query);
+ if (query.TryGetValue("loginCode", out var code))
+ {
+ loginCode = code;
+ }
+ if (query.TryGetValue("returnUrl", out var url) && string.IsNullOrEmpty(returnUrl))
+ {
+ returnUrl = url;
+ }
+ }
+
+ var userId = userLoginCodeService.Verify(loginCode);
+ if (userId is null)
+ {
+ TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["Login code was invalid"].Value;
+ return Login(returnUrl);
+ }
+ if (userId == User.GetIdOrNull())
+ return Login(returnUrl);
+
+ var user = await userManager.FindByIdAsync(userId);
+ var loginContext = CreateLoginContext(user);
+ if (!await userService.CanLogin(loginContext))
+ {
+ TempData.SetStatusLoginResult(loginContext);
+ return Login(returnUrl);
+ }
+
+ var now = DateTimeOffset.UtcNow;
+ var authProperties = new AuthenticationProperties
+ {
+ IssuedUtc = now,
+ AllowRefresh = false,
+ IsPersistent = true,
+ ExpiresUtc = now.AddDays(1)
+ };
+
+ await signInManager.SignInAsync(user, authProperties, AuthenticationSchemes.Cookie);
+ }
+
+ return Login(returnUrl);
+ }
+
+ private IActionResult Login(string returnUrl = null, string email = null)
+ {
+ email ??= User.FindFirst(ClaimTypes.Email)?.Value;
+ return RedirectToAction(nameof(UIAccountController.Login), "UIAccount", new { area = "", email, returnUrl });
+ }
+}
diff --git a/BTCPayServer/Plugins/Impersonation/UserLoginCode.razor b/BTCPayServer/Plugins/Impersonation/UserLoginCode.razor
new file mode 100644
index 0000000..c00c683
--- /dev/null
+++ b/BTCPayServer/Plugins/Impersonation/UserLoginCode.razor
@@ -0,0 +1,103 @@
+@using System.Timers
+@using BTCPayServer.Abstractions.Extensions
+@using BTCPayServer.Blazor
+@using BTCPayServer.Client
+@using BTCPayServer.Data
+@using BTCPayServer.Security
+@using Microsoft.AspNetCore.Authorization
+@using Microsoft.AspNetCore.Components.Authorization
+@using Microsoft.AspNetCore.Http
+@using Microsoft.AspNetCore.Identity
+@using Microsoft.AspNetCore.Mvc
+@using Microsoft.AspNetCore.Routing
+@using Microsoft.Extensions.Localization
+@inject AuthenticationStateProvider AuthenticationStateProvider
+@inject IAuthorizationService AuthorizationService
+@inject UserManager<ApplicationUser> UserManager
+@inject UserLoginCodeService UserLoginCodeService
+@inject LinkGenerator LinkGenerator
+@inject IHttpContextAccessor HttpContextAccessor
+@inject IStringLocalizer StringLocalizer
+@implements IDisposable
+
+@if (!string.IsNullOrEmpty(_data))
+{
+ <div @attributes="Attrs" class="@CssClass">
+ <div class="qr-container mb-2">
+ <QrCode Data="@_data" Size="Size"/>
+ </div>
+ <p class="text-center text-muted mb-1" id="progress">@StringLocalizer["Valid for {0} seconds", _seconds]</p>
+ <div class="progress only-for-js" data-bs-toggle="tooltip" data-bs-placement="top">
+ <div class="progress-bar progress-bar-striped progress-bar-animated @(Percent < 15 ? "bg-warning" : null)" role="progressbar" style="width:@Percent%" id="progressbar"></div>
+ </div>
+ </div>
+}
+
+@code {
+ [Parameter]
+ public string RedirectUrl { get; set; }
+
+ [Parameter]
+ public int Size { get; set; } = 256;
+ [Parameter]
+ public string ImpersonatedUserId { get; set; }
+
+ [Parameter(CaptureUnmatchedValues = true)]
+ public Dictionary<string, object> Attrs { get; set; }
+
+ private static readonly double Seconds = UserLoginCodeService.ExpirationTime.TotalSeconds;
+ private double _seconds = Seconds;
+ private string _data;
+ private ApplicationUser _user;
+ private Timer _timer;
+
+ protected override async Task OnParametersSetAsync()
+ {
+ var auth = await AuthenticationStateProvider.GetAuthenticationStateAsync();
+ var userId = auth.User.Identity?.IsAuthenticated is true
+ ? auth.User.GetIdOrNull()
+ : null;
+ if (userId is null) return;
+ var loggedInUser = await UserManager.FindByIdAsync(ImpersonatedUserId ?? userId);
+ if (loggedInUser is null) return;
+ if (await AuthorizationService.AuthorizeAsync(auth.User, loggedInUser.Id, [new PolicyRequirement(ImpersonationPlugin.CanImpersonateUser)]) is not { Succeeded: true })
+ return;
+ _user = loggedInUser;
+ GenerateCodeAndStartTimer();
+ }
+
+ public void Dispose()
+ {
+ _timer?.Dispose();
+ }
+
+ private void GenerateCodeAndStartTimer()
+ {
+ var loginCode = UserLoginCodeService.Generate(_user.Id);
+ _data = GetData(loginCode);
+ _seconds = Seconds;
+ _timer?.Dispose();
+ _timer = new Timer(1000);
+ _timer.Elapsed += CountDownTimer;
+ _timer.Enabled = true;
+ }
+
+ private void CountDownTimer(object source, ElapsedEventArgs e)
+ {
+ if (_seconds > 0)
+ _seconds -= 1;
+ else
+ GenerateCodeAndStartTimer();
+ InvokeAsync(StateHasChanged);
+ }
+
+ private string GetData(string loginCode)
+ {
+ var req = HttpContextAccessor.HttpContext?.Request;
+ if (req == null) return "";
+ return LinkGenerator.LoginCodeLink(loginCode, RedirectUrl, req.GetRequestBaseUrl());
+ }
+
+ private double Percent => Math.Round(_seconds / Seconds * 100);
+ private string CssClass => $"user-login-code d-inline-flex flex-column {(Attrs?.ContainsKey("class") is true ? Attrs["class"] : "")}".Trim();
+}
diff --git a/BTCPayServer/Plugins/Impersonation/UserLoginCodeService.cs b/BTCPayServer/Plugins/Impersonation/UserLoginCodeService.cs
new file mode 100644
index 0000000..23475c1
--- /dev/null
+++ b/BTCPayServer/Plugins/Impersonation/UserLoginCodeService.cs
@@ -0,0 +1,30 @@
+#nullable enable
+using System;
+using Microsoft.Extensions.Caching.Memory;
+using NBitcoin;
+using NBitcoin.DataEncoders;
+
+namespace BTCPayServer.Plugins.Impersonation;
+
+public class UserLoginCodeService(IMemoryCache memoryCache)
+{
+ public static readonly TimeSpan ExpirationTime = TimeSpan.FromSeconds(60);
+
+ private string CacheKey(string code) => $"{nameof(UserLoginCodeService)}_{code.ToLowerInvariant()}";
+
+ public string Generate(string userId)
+ {
+ var code = Encoders.Hex.EncodeData(RandomUtils.GetBytes(20));
+ memoryCache.Set(CacheKey(code), userId, ExpirationTime);
+ return code;
+ }
+
+ public string? Verify(string code)
+ {
+ var key = CacheKey(code);
+ if (!memoryCache.TryGetValue(key, out var o) || o is not string userId)
+ return null;
+ memoryCache.Remove(key);
+ return userId;
+ }
+}
diff --git a/BTCPayServer/Plugins/Impersonation/Views/LoginCodes.cshtml b/BTCPayServer/Plugins/Impersonation/Views/LoginCodes.cshtml
new file mode 100644
index 0000000..b7b1333
--- /dev/null
+++ b/BTCPayServer/Plugins/Impersonation/Views/LoginCodes.cshtml
@@ -0,0 +1,15 @@
+@using BTCPayServer.Plugins.Impersonation
+@using BTCPayServer.Views.Manage
+@{
+ ViewData.SetLayoutModel(new LayoutModel(nameof(ManageNavPages.LoginCodes), StringLocalizer["Login Codes"])
+ .SetCategory(nameof(ManageNavPages)));
+
+ Layout = "_Layout";
+}
+
+<div class="sticky-header">
+ <h2>@ViewData["Title"]</h2>
+</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(UserLoginCode)" render-mode="ServerPrerendered" param-id="@("LoginCode")"/>
diff --git a/BTCPayServer/Plugins/Impersonation/Views/UserNav.cshtml b/BTCPayServer/Plugins/Impersonation/Views/UserNav.cshtml
new file mode 100644
index 0000000..b6aa0d6
--- /dev/null
+++ b/BTCPayServer/Plugins/Impersonation/Views/UserNav.cshtml
@@ -0,0 +1,4 @@
+@using BTCPayServer.Plugins.Impersonation
+<li class="nav-item nav-item-sub">
+ <a layout-menu-item="@nameof(UIImpersonationController.LoginCodes)" asp-area="@ImpersonationPlugin.Area" asp-controller="UIImpersonation" asp-action="LoginCodes" text-translate="true">Login Codes</a>
+</li>
diff --git a/BTCPayServer/Security/BuiltInPermissionHandler.cs b/BTCPayServer/Security/BuiltInPermissionHandler.cs
index 86fdac7..82a0f87 100644
--- a/BTCPayServer/Security/BuiltInPermissionHandler.cs
+++ b/BTCPayServer/Security/BuiltInPermissionHandler.cs
@@ -1,6 +1,7 @@
#nullable enable
using System.Collections.Generic;
using System.Linq;
+using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Client;
using BTCPayServer.Data;
diff --git a/BTCPayServer/Views/Shared/_Layout.cshtml b/BTCPayServer/Views/Shared/_Layout.cshtml
index f3c435d..4b77a00 100644
--- a/BTCPayServer/Views/Shared/_Layout.cshtml
+++ b/BTCPayServer/Views/Shared/_Layout.cshtml
@@ -31,13 +31,15 @@
<main id="mainContent">
@if (!_env.IsSecure(_context.HttpContext))
{
- <div id="insecureEnv" class="alert alert-danger alert-dismissible" style="position:absolute; top:75px;" role="alert">
+ <div id="insecureEnv" class="alert alert-danger alert-dismissible" role="alert">
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="@StringLocalizer["Close"]">
<vc:icon symbol="close"/>
</button>
<span text-translate="true">Your access to BTCPay Server is over an unsecured network. If you are using the docker deployment method with NGINX and HTTPS is not available, you probably did not configure your DNS settings correctly. We disabled the register and login link so you don't leak your credentials.</span>
</div>
}
+ <vc:ui-extension-point location="layout-banner" />
+
<section>
@RenderBody()
</section>
diff --git a/BTCPayServer/Views/UIAccount/Login.cshtml b/BTCPayServer/Views/UIAccount/Login.cshtml
index 28f1f3d..5c3198c 100644
--- a/BTCPayServer/Views/UIAccount/Login.cshtml
+++ b/BTCPayServer/Views/UIAccount/Login.cshtml
@@ -1,3 +1,4 @@
+@using BTCPayServer.Plugins.Impersonation
@model LoginViewModel
@inject BTCPayServer.Security.ContentSecurityPolicies Csp
@inject BTCPayServer.Services.PoliciesSettings PoliciesSettings
@@ -44,7 +45,7 @@
</div>
</fieldset>
</form>
-<form asp-action="LoginWithCode" asp-route-returnurl="@ViewData["ReturnUrl"]" method="post" id="logincode-form">
+<form asp-area="@ImpersonationPlugin.Area" asp-controller="UIImpersonation" asp-action="LoginWithCode" asp-route-returnurl="@ViewData["ReturnUrl"]" method="post" id="logincode-form">
<input asp-for="LoginCode" type="hidden" class="form-control"/>
</form>
@if (!PoliciesSettings.LockSubscription)
diff --git a/BTCPayServer/Views/UIManage/LoginCodes.cshtml b/BTCPayServer/Views/UIManage/LoginCodes.cshtml
deleted file mode 100644
index a58f755..0000000
--- a/BTCPayServer/Views/UIManage/LoginCodes.cshtml
+++ /dev/null
@@ -1,11 +0,0 @@
-@{
- ViewData.SetLayoutModel(new LayoutModel(nameof(ManageNavPages.LoginCodes), StringLocalizer["Login Codes"])
- .SetCategory(nameof(ManageNavPages)));
-}
-
-<div class="sticky-header">
- <h2>@ViewData["Title"]</h2>
-</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-id="@("LoginCode")"/>
diff --git a/BTCPayServer/Views/UIServer/ListUsers.cshtml b/BTCPayServer/Views/UIServer/ListUsers.cshtml
index 1e29d94..a748f96 100644
--- a/BTCPayServer/Views/UIServer/ListUsers.cshtml
+++ b/BTCPayServer/Views/UIServer/ListUsers.cshtml
@@ -1,4 +1,5 @@
@inject BTCPayServer.Security.ContentSecurityPolicies Csp
+@using BTCPayServer.Plugins.Impersonation
@model UsersViewModel
@{
ViewData.SetLayoutModel(new LayoutModel(nameof(ServerNavPages.Users), StringLocalizer["Users"])
@@ -13,6 +14,7 @@
Csp.UnsafeEval();
const string sortByDesc = "Sort by email descending...";
const string sortByAsc = "Sort by email ascending...";
+ var currentUserId = User.GetIdOrNull();
}
@section PageFootContent {
diff --git a/BTCPayServer/wwwroot/swagger/v1/swagger.template.json b/BTCPayServer/wwwroot/swagger/v1/swagger.template.json
index 3b30680..9241f4a 100644
--- a/BTCPayServer/wwwroot/swagger/v1/swagger.template.json
+++ b/BTCPayServer/wwwroot/swagger/v1/swagger.template.json
@@ -216,7 +216,7 @@
"securitySchemes": {
"API_Key": {
"type": "apiKey",
- "description": "BTCPay Server supports authenticating and authorizing users through an API Key that is generated by them. Send the API Key as a header value to Authorization with the format: `token {token}`. For a smoother experience, you can generate a url that redirects users to an API key creation screen.\n\n The following permissions are available to the context of the user creating the API Key:\n\n* `btcpay.user.candeleteuser`: Delete user\n* `btcpay.user.canmanagenotificationsforuser`: Manage your notifications\n* `btcpay.user.canmodifyprofile`: Manage your profile\n* `btcpay.user.canviewnotificationsforuser`: View your notifications\n* `btcpay.user.canviewprofile`: View your profile\n* `unrestricted`: Unrestricted access\n\nThe following permissions are available if the user is an administrator:\n\n* `btcpay.server.cancreatelightninginvoiceinternalnode`: Create invoices with internal lightning node\n* `btcpay.server.cancreateuser`: Create new users\n* `btcpay.server.canmanageusers`: Manage users\n* `btcpay.server.canmodifyserversettings`: Manage your server\n* `btcpay.server.canuseinternallightningnode`: Use the internal lightning node\n* `btcpay.server.canviewlightninginvoiceinternalnode`: View invoices from internal lightning node\n* `btcpay.server.canviewusers`: View users\n\nThe following permissions applies to all stores of the user, you can limit to a specific store with the following format: `btcpay.store.cancreateinvoice:6HSHAEU4iYWtjxtyRs9KyPjM9GAQp8kw2T9VWbGG1FnZ`:\n\n* `btcpay.store.canarchivepullpayments`: Archive your pull payments\n* `btcpay.store.cancreateinvoice`: Create an invoice\n* `btcpay.store.cancreatelightninginvoice`: Create invoices from the lightning nodes associated with your stores\n* `btcpay.store.cancreatenonapprovedpullpayments`: Create non-approved pull payments\n* `btcpay.store.cancreatepullpayments`: Create pull payments\n* `btcpay.store.cancreditsubscribers`: Credit your subscribers\n* `btcpay.store.canmanagepayouts`: Manage payouts\n* `btcpay.store.canmanagepullpayments`: Manage your pull payments\n* `btcpay.store.canmanagesubscribers`: Manage your subscribers\n* `btcpay.store.canmodifyinvoices`: Modify invoices\n* `btcpay.store.canmodifyofferings`: Modify your offerings\n* `btcpay.store.canmodifypaymentrequests`: Modify your payment requests\n* `btcpay.store.canmodifystoresettings`: Modify your stores\n* `btcpay.store.canuselightningnode`: Use the lightning nodes associated with your stores\n* `btcpay.store.canviewinvoices`: View invoices\n* `btcpay.store.canviewlightninginvoice`: View the lightning invoices associated with your stores\n* `btcpay.store.canviewofferings`: View your offerings\n* `btcpay.store.canviewpaymentrequests`: View your payment requests\n* `btcpay.store.canviewpayouts`: View payouts\n* `btcpay.store.canviewpullpayments`: View your pull payments\n* `btcpay.store.canviewreports`: View your reports\n* `btcpay.store.canviewstoresettings`: View your stores\n* `btcpay.store.webhooks.canmodifywebhooks`: Modify stores webhooks\n\nNote that API Keys only limits permission of a user and can never expand it. If an API Key has the permission `btcpay.server.canmodifyserversettings` but that the user account creating this API Key is not administrator, the API Key will not be able to modify the server settings.\nSome permissions may include other permissions, see [this operation](#operation/permissionsMetadata).\n",
+ "description": "BTCPay Server supports authenticating and authorizing users through an API Key that is generated by them. Send the API Key as a header value to Authorization with the format: `token {token}`. For a smoother experience, you can generate a url that redirects users to an API key creation screen.\n\n The following permissions are available to the context of the user creating the API Key:\n\n* `btcpay.impersonation.canimpersonate`: Can impersonate users\n* `btcpay.user.candeleteuser`: Delete user\n* `btcpay.user.canmanagenotificationsforuser`: Manage your notifications\n* `btcpay.user.canmodifyprofile`: Manage your profile\n* `btcpay.user.canviewnotificationsforuser`: View your notifications\n* `btcpay.user.canviewprofile`: View your profile\n* `unrestricted`: Unrestricted access\n\nThe following permissions are available if the user is an administrator:\n\n* `btcpay.server.cancreatelightninginvoiceinternalnode`: Create invoices with internal lightning node\n* `btcpay.server.cancreateuser`: Create new users\n* `btcpay.server.canmanageusers`: Manage users\n* `btcpay.server.canmodifyserversettings`: Manage your server\n* `btcpay.server.canuseinternallightningnode`: Use the internal lightning node\n* `btcpay.server.canviewlightninginvoiceinternalnode`: View invoices from internal lightning node\n* `btcpay.server.canviewusers`: View users\n\nThe following permissions applies to all stores of the user, you can limit to a specific store with the following format: `btcpay.store.cancreateinvoice:6HSHAEU4iYWtjxtyRs9KyPjM9GAQp8kw2T9VWbGG1FnZ`:\n\n* `btcpay.store.canarchivepullpayments`: Archive your pull payments\n* `btcpay.store.cancreateinvoice`: Create an invoice\n* `btcpay.store.cancreatelightninginvoice`: Create invoices from the lightning nodes associated with your stores\n* `btcpay.store.cancreatenonapprovedpullpayments`: Create non-approved pull payments\n* `btcpay.store.cancreatepullpayments`: Create pull payments\n* `btcpay.store.cancreditsubscribers`: Credit your subscribers\n* `btcpay.store.canmanagepayouts`: Manage payouts\n* `btcpay.store.canmanagepullpayments`: Manage your pull payments\n* `btcpay.store.canmanagesubscribers`: Manage your subscribers\n* `btcpay.store.canmodifyinvoices`: Modify invoices\n* `btcpay.store.canmodifyofferings`: Modify your offerings\n* `btcpay.store.canmodifypaymentrequests`: Modify your payment requests\n* `btcpay.store.canmodifystoresettings`: Modify your stores\n* `btcpay.store.canuselightningnode`: Use the lightning nodes associated with your stores\n* `btcpay.store.canviewinvoices`: View invoices\n* `btcpay.store.canviewlightninginvoice`: View the lightning invoices associated with your stores\n* `btcpay.store.canviewofferings`: View your offerings\n* `btcpay.store.canviewpaymentrequests`: View your payment requests\n* `btcpay.store.canviewpayouts`: View payouts\n* `btcpay.store.canviewpullpayments`: View your pull payments\n* `btcpay.store.canviewreports`: View your reports\n* `btcpay.store.canviewstoresettings`: View your stores\n* `btcpay.store.webhooks.canmodifywebhooks`: Modify stores webhooks\n\nNote that API Keys only limits permission of a user and can never expand it. If an API Key has the permission `btcpay.server.canmodifyserversettings` but that the user account creating this API Key is not administrator, the API Key will not be able to modify the server settings.\nSome permissions may include other permissions, see [this operation](#operation/permissionsMetadata).\n",
"name": "Authorization",
"in": "header"
},
Why this scored 49/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.