diff --git a/BTCPayServer.Tests/SeleniumTests.cs b/BTCPayServer.Tests/SeleniumTests.cs
index 82728b102..53c008d52 100644
--- a/BTCPayServer.Tests/SeleniumTests.cs
+++ b/BTCPayServer.Tests/SeleniumTests.cs
@@ -3426,11 +3426,14 @@ namespace BTCPayServer.Tests
var user = s.RegisterNewUser();
s.GoToHome();
s.GoToProfile(ManageNavPages.LoginCodes);
- var code = s.Driver.FindElement(By.Id("logincode")).GetAttribute("value");
- s.ClickPagePrimary();
- Assert.NotEqual(code, s.Driver.FindElement(By.Id("logincode")).GetAttribute("value"));
- code = s.Driver.FindElement(By.Id("logincode")).GetAttribute("value");
+ string code = null;
+ TestUtils.Eventually(() => { code = s.Driver.FindElement(By.CssSelector("#LoginCode .qr-code")).GetAttribute("alt"); });
+ string prevCode = code;
+ await s.Driver.Navigate().RefreshAsync();
+ TestUtils.Eventually(() => { code = s.Driver.FindElement(By.CssSelector("#LoginCode .qr-code")).GetAttribute("alt"); });
+ Assert.NotEqual(prevCode, code);
+ TestUtils.Eventually(() => { code = s.Driver.FindElement(By.CssSelector("#LoginCode .qr-code")).GetAttribute("alt"); });
s.Logout();
s.GoToLogin();
s.Driver.SetAttribute("LoginCode", "value", "bad code");
diff --git a/BTCPayServer/Blazor/Icon.razor b/BTCPayServer/Blazor/Icon.razor
index 3fca30bb4..584b36e38 100644
--- a/BTCPayServer/Blazor/Icon.razor
+++ b/BTCPayServer/Blazor/Icon.razor
@@ -7,12 +7,13 @@
@code {
- public string GetPathTo(string symbol)
+ [Parameter, EditorRequired]
+ public string Symbol { get; set; }
+
+ private string GetPathTo(string symbol)
{
var versioned = FileVersionProvider.AddFileVersionToPath(default, "img/icon-sprite.svg");
var rootPath = (BTCPayServerOptions.RootPath ?? "/").WithTrailingSlash();
- return $"{rootPath}{versioned}#{Symbol}";
+ return $"{rootPath}{versioned}#{symbol}";
}
- [Parameter]
- public string Symbol { get; set; }
}
diff --git a/BTCPayServer/Blazor/PosLoginCode.razor b/BTCPayServer/Blazor/PosLoginCode.razor
new file mode 100644
index 000000000..42319562b
--- /dev/null
+++ b/BTCPayServer/Blazor/PosLoginCode.razor
@@ -0,0 +1,42 @@
+@using Microsoft.AspNetCore.Http
+
+@inject IHttpContextAccessor HttpContextAccessor;
+
+@if (Users?.Any() is true)
+{
+
+
+
+
+}
+
+@if (string.IsNullOrEmpty(_userId))
+{
+
+}
+else
+{
+
+}
+
+@code {
+ [Parameter, EditorRequired]
+ public string PosPath { get; set; }
+
+ [Parameter]
+ public Dictionary Users { get; set; }
+
+ [Parameter(CaptureUnmatchedValues = true)]
+ public Dictionary Attrs { get; set; }
+
+ private string _userId;
+ private string PosUrl => Request.GetAbsoluteRoot() + PosPath;
+ private HttpRequest Request => HttpContextAccessor.HttpContext?.Request;
+ private string CssClass => $"form-group {(Attrs?.ContainsKey("class") is true ? Attrs["class"] : "")}".Trim();
+}
diff --git a/BTCPayServer/Blazor/QrCode.razor b/BTCPayServer/Blazor/QrCode.razor
new file mode 100644
index 000000000..ac94fe0dc
--- /dev/null
+++ b/BTCPayServer/Blazor/QrCode.razor
@@ -0,0 +1,29 @@
+@using QRCoder
+
+@if (!string.IsNullOrEmpty(Data))
+{
+
+}
+
+@code {
+ [Parameter, EditorRequired]
+ public string Data { get; set; }
+
+ [Parameter]
+ public int Size { get; set; } = 256;
+
+ [Parameter(CaptureUnmatchedValues = true)]
+ public Dictionary Attrs { get; set; }
+
+ private static readonly QRCodeGenerator QrGenerator = new();
+
+ private string GetBase64(string data)
+ {
+ var qrCodeData = QrGenerator.CreateQrCode(data, QRCodeGenerator.ECCLevel.Q);
+ var qrCode = new PngByteQRCode(qrCodeData);
+ var bytes = qrCode.GetGraphic(5, [0, 0, 0, 255], [0xf5, 0xf5, 0xf7, 255]);
+ return Convert.ToBase64String(bytes);
+ }
+
+ private string CssClass => $"qr-code {(Attrs?.ContainsKey("class") is true ? Attrs["class"] : "")}".Trim();
+}
diff --git a/BTCPayServer/Blazor/UserLoginCode.razor b/BTCPayServer/Blazor/UserLoginCode.razor
new file mode 100644
index 000000000..c3fa74ecc
--- /dev/null
+++ b/BTCPayServer/Blazor/UserLoginCode.razor
@@ -0,0 +1,100 @@
+@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
+@inject AuthenticationStateProvider AuthenticationStateProvider
+@inject UserManager UserManager;
+@inject UserLoginCodeService UserLoginCodeService;
+@inject LinkGenerator LinkGenerator;
+@inject IHttpContextAccessor HttpContextAccessor;
+@implements IDisposable
+
+@if (!string.IsNullOrEmpty(_data))
+{
+
+
+
+
+
Valid for @_seconds seconds
+
+
+}
+
+@code {
+ [Parameter]
+ public string UserId { get; set; }
+
+ [Parameter]
+ public string RedirectUrl { get; set; }
+
+ [Parameter]
+ public int Size { get; set; } = 256;
+
+ [Parameter(CaptureUnmatchedValues = true)]
+ public Dictionary 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()
+ {
+ 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 GetUserId()
+ {
+ var state = await AuthenticationStateProvider.GetAuthenticationStateAsync();
+ return state.User.Identity?.IsAuthenticated is true
+ ? UserManager.GetUserId(state.User)
+ : 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/Controllers/UIAccountController.cs b/BTCPayServer/Controllers/UIAccountController.cs
index 7971ded66..f068594a4 100644
--- a/BTCPayServer/Controllers/UIAccountController.cs
+++ b/BTCPayServer/Controllers/UIAccountController.cs
@@ -123,15 +123,30 @@ namespace BTCPayServer.Controllers
return View(nameof(Login), new LoginViewModel { Email = email });
}
+ // GET is for signin via the POS backend
+ [HttpGet("/login/code")]
+ [AllowAnonymous]
+ [RateLimitsFilter(ZoneLimits.Login, Scope = RateLimitsScope.RemoteAddress)]
+ public async Task 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 LoginWithCode(string loginCode, string returnUrl = null)
+ {
+ return await LoginCodeResult(loginCode, returnUrl);
+ }
+
+ private async Task LoginCodeResult(string loginCode, string returnUrl)
{
if (!string.IsNullOrEmpty(loginCode))
{
- var userId = _userLoginCodeService.Verify(loginCode);
+ var code = loginCode.Split(';').First();
+ var userId = _userLoginCodeService.Verify(code);
if (userId is null)
{
TempData[WellKnownTempData.ErrorMessage] = "Login code was invalid";
diff --git a/BTCPayServer/Controllers/UIManageController.LoginCodes.cs b/BTCPayServer/Controllers/UIManageController.LoginCodes.cs
index 655085a81..39a9b3b7f 100644
--- a/BTCPayServer/Controllers/UIManageController.LoginCodes.cs
+++ b/BTCPayServer/Controllers/UIManageController.LoginCodes.cs
@@ -1,21 +1,12 @@
-using System;
-using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
-namespace BTCPayServer.Controllers
-{
- public partial class UIManageController
- {
- [HttpGet]
- public async Task LoginCodes()
- {
- var user = await _userManager.GetUserAsync(User);
- if (user == null)
- {
- throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
- }
+namespace BTCPayServer.Controllers;
- return View(nameof(LoginCodes), _userLoginCodeService.GetOrGenerate(user.Id));
- }
+public partial class UIManageController
+{
+ [HttpGet]
+ public ActionResult LoginCodes()
+ {
+ return View();
}
}
diff --git a/BTCPayServer/Controllers/UIManageController.cs b/BTCPayServer/Controllers/UIManageController.cs
index b111b5551..fe1bce45b 100644
--- a/BTCPayServer/Controllers/UIManageController.cs
+++ b/BTCPayServer/Controllers/UIManageController.cs
@@ -40,7 +40,6 @@ namespace BTCPayServer.Controllers
private readonly IAuthorizationService _authorizationService;
private readonly Fido2Service _fido2Service;
private readonly LinkGenerator _linkGenerator;
- private readonly UserLoginCodeService _userLoginCodeService;
private readonly IHtmlHelper Html;
private readonly UserService _userService;
private readonly UriResolver _uriResolver;
@@ -62,7 +61,6 @@ namespace BTCPayServer.Controllers
UserService userService,
UriResolver uriResolver,
IFileService fileService,
- UserLoginCodeService userLoginCodeService,
IHtmlHelper htmlHelper
)
{
@@ -76,7 +74,6 @@ namespace BTCPayServer.Controllers
_authorizationService = authorizationService;
_fido2Service = fido2Service;
_linkGenerator = linkGenerator;
- _userLoginCodeService = userLoginCodeService;
Html = htmlHelper;
_userService = userService;
_uriResolver = uriResolver;
diff --git a/BTCPayServer/Extensions/UrlHelperExtensions.cs b/BTCPayServer/Extensions/UrlHelperExtensions.cs
index 19d0eb713..16e2a0aa5 100644
--- a/BTCPayServer/Extensions/UrlHelperExtensions.cs
+++ b/BTCPayServer/Extensions/UrlHelperExtensions.cs
@@ -3,7 +3,6 @@ using System;
using BTCPayServer;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers;
-using BTCPayServer.Services.Apps;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
@@ -52,6 +51,12 @@ namespace Microsoft.AspNetCore.Mvc
{
return urlHelper.GetUriByAction(nameof(UIAccountController.Login), "UIAccount", null , scheme, host, pathbase);
}
+
+ 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);
+ }
+
public static string ResetPasswordLink(this LinkGenerator urlHelper, string userId, string code, string scheme, HostString host, string pathbase)
{
return urlHelper.GetUriByAction(
@@ -109,5 +114,14 @@ namespace Microsoft.AspNetCore.Mvc
values: new { storeId = wallet?.StoreId ?? walletIdOrStoreId, pullPaymentId, payoutState },
scheme, host, pathbase);
}
+
+ public static string IndexLink(this LinkGenerator urlHelper, string scheme, HostString host, string pathbase)
+ {
+ return urlHelper.GetUriByAction(
+ action: nameof(UIHomeController.Index),
+ controller: "UIHome",
+ values: null,
+ scheme, host, pathbase);
+ }
}
}
diff --git a/BTCPayServer/Fido2/UserLoginCodeService.cs b/BTCPayServer/Fido2/UserLoginCodeService.cs
index e84522884..09c78b26b 100644
--- a/BTCPayServer/Fido2/UserLoginCodeService.cs
+++ b/BTCPayServer/Fido2/UserLoginCodeService.cs
@@ -8,6 +8,7 @@ namespace BTCPayServer.Fido2
public class UserLoginCodeService
{
private readonly IMemoryCache _memoryCache;
+ public static readonly TimeSpan ExpirationTime = TimeSpan.FromSeconds(60);
public UserLoginCodeService(IMemoryCache memoryCache)
{
@@ -29,10 +30,10 @@ namespace BTCPayServer.Fido2
}
return _memoryCache.GetOrCreate(GetCacheKey(userId), entry =>
{
- entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(1);
+ entry.AbsoluteExpirationRelativeToNow = ExpirationTime;
var code = Encoders.Hex.EncodeData(RandomUtils.GetBytes(20));
using var newEntry = _memoryCache.CreateEntry(code);
- newEntry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(1);
+ newEntry.AbsoluteExpirationRelativeToNow = ExpirationTime;
newEntry.Value = userId;
return code;
diff --git a/BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs b/BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs
index 7f5cae7f3..54ef0d6c8 100644
--- a/BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs
+++ b/BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs
@@ -627,6 +627,8 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
}
vm.ExampleCallback = "{\n \"id\":\"SkdsDghkdP3D3qkj7bLq3\",\n \"url\":\"https://btcpay.example.com/invoice?id=SkdsDghkdP3D3qkj7bLq3\",\n \"status\":\"paid\",\n \"price\":10,\n \"currency\":\"EUR\",\n \"invoiceTime\":1520373130312,\n \"expirationTime\":1520374030312,\n \"currentTime\":1520373179327,\n \"exceptionStatus\":false,\n \"buyerFields\":{\n \"buyerEmail\":\"customer@example.com\",\n \"buyerNotify\":false\n },\n \"paymentSubtotals\": {\n \"BTC\":114700\n },\n \"paymentTotals\": {\n \"BTC\":118400\n },\n \"transactionCurrency\": \"BTC\",\n \"amountPaid\": \"1025900\",\n \"exchangeRates\": {\n \"BTC\": {\n \"EUR\": 8721.690715789999,\n \"USD\": 10817.99\n }\n }\n}";
+
+ await FillUsers(vm);
return View("PointOfSale/UpdatePointOfSale", vm);
}
@@ -655,6 +657,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
}
if (!ModelState.IsValid)
{
+ await FillUsers(vm);
return View("PointOfSale/UpdatePointOfSale", vm);
}
@@ -715,5 +718,11 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
private StoreData GetCurrentStore() => HttpContext.GetStoreData();
private AppData GetCurrentApp() => HttpContext.GetAppData();
+
+ private async Task FillUsers(UpdatePointOfSaleViewModel vm)
+ {
+ var users = await _storeRepository.GetStoreUsers(GetCurrentStore().Id);
+ vm.StoreUsers = users.Select(u => (u.Id, u.Email, u.StoreRole.Role)).ToDictionary(u => u.Id, u => $"{u.Email} ({u.Role})");
+ }
}
}
diff --git a/BTCPayServer/Plugins/PointOfSale/Models/UpdatePointOfSaleViewModel.cs b/BTCPayServer/Plugins/PointOfSale/Models/UpdatePointOfSaleViewModel.cs
index 2930a55df..7d79ff246 100644
--- a/BTCPayServer/Plugins/PointOfSale/Models/UpdatePointOfSaleViewModel.cs
+++ b/BTCPayServer/Plugins/PointOfSale/Models/UpdatePointOfSaleViewModel.cs
@@ -68,6 +68,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Models
public string CustomTipPercentages { get; set; }
public string Id { get; set; }
+ public Dictionary StoreUsers { get; set; }
[Display(Name = "Redirect invoice to redirect url automatically after paid")]
public string RedirectAutomatically { get; set; } = string.Empty;
diff --git a/BTCPayServer/Views/Shared/PointOfSale/UpdatePointOfSale.cshtml b/BTCPayServer/Views/Shared/PointOfSale/UpdatePointOfSale.cshtml
index 16c5958b8..39447208f 100644
--- a/BTCPayServer/Views/Shared/PointOfSale/UpdatePointOfSale.cshtml
+++ b/BTCPayServer/Views/Shared/PointOfSale/UpdatePointOfSale.cshtml
@@ -12,6 +12,7 @@
ViewData.SetActivePage(AppsNavPages.Update, "Update Point of Sale", Model.Id);
Csp.UnsafeEval();
var checkoutFormOptions = await FormDataService.GetSelect(Model.StoreId, Model.FormId);
+ var posPath = Url.Action("ViewPointOfSale", "UIPointOfSale", new { appId = Model.Id });
}
@section PageHeadContent {
@@ -23,14 +24,7 @@
@section PageFootContent {
-
-
-
}