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)) +{ + @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 { - - - }
@@ -46,7 +40,7 @@ {
View -
@@ -316,6 +310,19 @@ - - + diff --git a/BTCPayServer/Views/UIManage/LoginCodes.cshtml b/BTCPayServer/Views/UIManage/LoginCodes.cshtml index 478dc12de..1429499d8 100644 --- a/BTCPayServer/Views/UIManage/LoginCodes.cshtml +++ b/BTCPayServer/Views/UIManage/LoginCodes.cshtml @@ -1,43 +1,11 @@ -@model string +@inject UserManager UserManager; @{ ViewData.SetActivePage(ManageNavPages.LoginCodes, "Login Codes"); }

Easily log into BTCPay Server on another device using a simple login code from an already authenticated device.

-
-
- -
- -

Valid for 60 seconds

-
-
-
-
- -@section PageFootContent -{ - - - -} +