POS: Option for user sign in via the QR code (#6231)

* Login Code: Turn into Blazor component and extend with data for the app

* POS: Add login code for POS frontend

* Improve components, fix test
This commit is contained in:
d11n 2024-09-26 12:10:14 +02:00 committed by GitHub
parent b5590a38fe
commit 272cc3d3c9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 253 additions and 75 deletions

View file

@ -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");

View file

@ -7,12 +7,13 @@
<use href="@GetPathTo(Symbol)"></use>
</svg>
@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; }
}

View file

@ -0,0 +1,42 @@
@using Microsoft.AspNetCore.Http
@inject IHttpContextAccessor HttpContextAccessor;
@if (Users?.Any() is true)
{
<div @attributes="Attrs" class="@CssClass">
<label for="SignedInUser" class="form-label">Signed in user</label>
<select id="SignedInUser" class="form-select" value="@_userId" @onchange="@(e => _userId = e.Value?.ToString())">
<option value="">None, just open the URL</option>
@foreach (var u in Users)
{
<option value="@u.Key">@u.Value</option>
}
</select>
</div>
}
@if (string.IsNullOrEmpty(_userId))
{
<QrCode Data="@PosUrl" />
}
else
{
<UserLoginCode UserId="@_userId" RedirectUrl="@PosPath" />
}
@code {
[Parameter, EditorRequired]
public string PosPath { get; set; }
[Parameter]
public Dictionary<string,string> Users { get; set; }
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object> 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();
}

View file

@ -0,0 +1,29 @@
@using QRCoder
@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" />
}
@code {
[Parameter, EditorRequired]
public string Data { get; set; }
[Parameter]
public int Size { get; set; } = 256;
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object> 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();
}

View file

@ -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<ApplicationUser> UserManager;
@inject UserLoginCodeService UserLoginCodeService;
@inject LinkGenerator LinkGenerator;
@inject IHttpContextAccessor HttpContextAccessor;
@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">Valid for @_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 UserId { get; set; }
[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()
{
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
? 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();
}

View file

@ -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<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 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";

View file

@ -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<IActionResult> 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();
}
}

View file

@ -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;

View file

@ -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);
}
}
}

View file

@ -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;

View file

@ -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})");
}
}
}

View file

@ -68,6 +68,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Models
public string CustomTipPercentages { get; set; }
public string Id { get; set; }
public Dictionary<string, string> StoreUsers { get; set; }
[Display(Name = "Redirect invoice to redirect url automatically after paid")]
public string RedirectAutomatically { get; set; } = string.Empty;

View file

@ -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 {
<partial name="_ValidationScriptsPartial" />
<script src="~/vendor/summernote/summernote-bs5.js" asp-append-version="true"></script>
<script src="~/vendor/vuejs/vue.min.js" asp-append-version="true"></script>
<script src="~/vendor/vue-qrcode/vue-qrcode.min.js" asp-append-version="true"></script>
<script src="~/pos/admin.js" asp-append-version="true"></script>
<script>
const qrApp = initQRShow({ title: "Scan to view the Point of Sale" });
const posViewUrl = @Safe.Json($"{Context.Request.GetAbsoluteRoot()}{Url.Action("ViewPointOfSale", "UIPointOfSale", new { appId = Model.Id })}");
delegate('click', '#displayQRCode', () => { qrApp.showData(posViewUrl); });
</script>
}
<form method="post" permissioned="@Policies.CanModifyStoreSettings">
@ -46,7 +40,7 @@
{
<div class="btn-group" role="group" aria-label="View Point of Sale">
<a class="btn btn-secondary" asp-controller="UIPointOfSale" asp-action="ViewPointOfSale" asp-route-appId="@Model.Id" id="ViewApp" target="_blank">View</a>
<button type="button" class="btn btn-secondary px-3 d-inline-flex align-items-center" id="displayQRCode">
<button type="button" class="btn btn-secondary px-3 d-inline-flex align-items-center" data-bs-toggle="modal" data-bs-target="#OpenPosModal">
<vc:icon symbol="qr-code" />
</button>
</div>
@ -316,6 +310,19 @@
</div>
<partial name="_Confirm" model="@(new ConfirmModel("Delete app", "This app will be removed from this store.", "Delete"))" permission="@Policies.CanModifyStoreSettings" />
<partial name="ShowQR" />
<div class="modal fade" id="OpenPosModal" tabindex="-1" aria-labelledby="ConfirmTitle" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Scan the QR code to open the Point of Sale</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close">
<vc:icon symbol="close"/>
</button>
</div>
<div class="modal-body pt-0">
<component type="typeof(BTCPayServer.Blazor.PosLoginCode)" render-mode="ServerPrerendered" param-Users="@Model.StoreUsers" param-PosPath="@posPath" />
</div>
</div>
</div>
</div>

View file

@ -1,43 +1,11 @@
@model string
@inject UserManager<ApplicationUser> UserManager;
@{
ViewData.SetActivePage(ManageNavPages.LoginCodes, "Login Codes");
}
<div class="sticky-header">
<h2 text-translate="true">@ViewData["Title"]</h2>
<a id="page-primary" class="btn btn-primary" asp-action="LoginCodes">Regenerate code</a>
</div>
<partial name="_StatusMessage" />
<p>Easily log into BTCPay Server on another device using a simple login code from an already authenticated device.</p>
<div class="d-inline-flex flex-column" style="width:256px">
<div class="qr-container mb-2">
<vc:qr-code data="@Model"/>
</div>
<input type="hidden" value="@Model" id="logincode">
<p class="text-center text-muted mb-1" id="progress">Valid for 60 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" role="progressbar" style="width:100%" id="progressbar"></div>
</div>
</div>
@section PageFootContent
{
<link href="~/main/qrcode.css" rel="stylesheet" asp-append-version="true"/>
<script src="~/js/copy-to-clipboard.js"></script>
<script>
const SECONDS = 60
const progress = document.getElementById('progress')
const progressbar = document.getElementById('progressbar')
let remaining = SECONDS
const update = () => {
remaining--
const percent = Math.round(remaining/SECONDS * 100)
progress.innerText = `Valid for ${remaining} seconds`
progressbar.style.width = `${percent}%`
if (percent < 15) progressbar.classList.add('bg-warning')
if (percent < 1) document.getElementById('regeneratecode').click()
}
setInterval(update, 1000)
update()
</script>
}
<component type="typeof(BTCPayServer.Blazor.UserLoginCode)" render-mode="ServerPrerendered" param-UserId="@UserManager.GetUserId(User)" param-id="@("LoginCode")"/>