mirror of
https://github.com/aljazceru/btcpayserver.git
synced 2025-12-17 22:14:26 +01:00
Sign in with other device (quick mobile login) (#2504)
Co-authored-by: Dennis Reimann <mail@dennisreimann.de> Co-authored-by: Nicolas Dorier <nicolas.dorier@gmail.com>
This commit is contained in:
@@ -102,6 +102,17 @@ namespace BTCPayServer.Tests
|
||||
driver.ExecuteJavaScript($"document.getElementById('{collapseId}').classList.add('show')");
|
||||
}
|
||||
|
||||
|
||||
|
||||
public static void SetAttribute(this IWebDriver driver, string element, string attribute, string value)
|
||||
{
|
||||
driver.ExecuteJavaScript($"document.getElementById('{element}').setAttribute('{attribute}', '{value}')");
|
||||
}
|
||||
public static void InvokeJSFunction(this IWebDriver driver, string element, string funcName)
|
||||
{
|
||||
driver.ExecuteJavaScript($"document.getElementById('{element}').{funcName}()");
|
||||
}
|
||||
|
||||
public static IWebElement WaitForElement(this IWebDriver driver, By selector)
|
||||
{
|
||||
var wait = new WebDriverWait(driver, SeleniumTester.ImplicitWait);
|
||||
|
||||
@@ -16,6 +16,7 @@ using BTCPayServer.Payments;
|
||||
using BTCPayServer.Services;
|
||||
using BTCPayServer.Services.Invoices;
|
||||
using BTCPayServer.Services.Wallets;
|
||||
using BTCPayServer.Views.Manage;
|
||||
using BTCPayServer.Views.Server;
|
||||
using BTCPayServer.Views.Stores;
|
||||
using BTCPayServer.Views.Wallets;
|
||||
@@ -1555,6 +1556,31 @@ namespace BTCPayServer.Tests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Selenium", "Selenium")]
|
||||
public async Task CanSigninWithLoginCode()
|
||||
{
|
||||
using var s = CreateSeleniumTester();
|
||||
await s.StartAsync();
|
||||
var user = s.RegisterNewUser();
|
||||
s.GoToProfile(ManageNavPages.LoginCodes);
|
||||
var code = s.Driver.FindElement(By.Id("logincode")).GetAttribute("value");
|
||||
s.Driver.FindElement(By.Id("regeneratecode")).Click();
|
||||
Assert.NotEqual(code, s.Driver.FindElement(By.Id("logincode")).GetAttribute("value"));
|
||||
|
||||
code = s.Driver.FindElement(By.Id("logincode")).GetAttribute("value");
|
||||
s.Logout();
|
||||
s.GoToLogin();
|
||||
s.Driver.SetAttribute("LoginCode", "value", "bad code");
|
||||
s.Driver.InvokeJSFunction("logincode-form", "submit");
|
||||
|
||||
|
||||
s.Driver.SetAttribute("LoginCode", "value", code);
|
||||
s.Driver.InvokeJSFunction("logincode-form", "submit");
|
||||
s.GoToProfile();
|
||||
Assert.Contains(user, s.Driver.PageSource);
|
||||
}
|
||||
|
||||
|
||||
// For god know why, selenium have problems clicking on the save button, resulting in ultimate hacks
|
||||
// to make it works.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Import Project="../Build/Version.csproj" Condition="Exists('../Build/Version.csproj')" />
|
||||
<Import Project="../Build/Common.csproj" />
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<Content Update="Views\StorePullPayments\PullPayments.cshtml">
|
||||
<Pack>false</Pack>
|
||||
</Content>
|
||||
<Content Update="Views\StorePullPayments\Payouts.cshtml">
|
||||
<Content Update="Views\Account\_ViewImports.cshtml">
|
||||
<Pack>false</Pack>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Security.Claims;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Abstractions.Constants;
|
||||
using BTCPayServer.Abstractions.Extensions;
|
||||
@@ -33,6 +34,7 @@ namespace BTCPayServer.Controllers
|
||||
readonly Configuration.BTCPayServerOptions _Options;
|
||||
private readonly BTCPayServerEnvironment _btcPayServerEnvironment;
|
||||
private readonly Fido2Service _fido2Service;
|
||||
private readonly UserLoginCodeService _userLoginCodeService;
|
||||
private readonly EventAggregator _eventAggregator;
|
||||
readonly ILogger _logger;
|
||||
|
||||
@@ -47,6 +49,7 @@ namespace BTCPayServer.Controllers
|
||||
BTCPayServerEnvironment btcPayServerEnvironment,
|
||||
EventAggregator eventAggregator,
|
||||
Fido2Service fido2Service,
|
||||
UserLoginCodeService userLoginCodeService,
|
||||
Logs logs)
|
||||
{
|
||||
_userManager = userManager;
|
||||
@@ -56,6 +59,7 @@ namespace BTCPayServer.Controllers
|
||||
_Options = options;
|
||||
_btcPayServerEnvironment = btcPayServerEnvironment;
|
||||
_fido2Service = fido2Service;
|
||||
_userLoginCodeService = userLoginCodeService;
|
||||
_eventAggregator = eventAggregator;
|
||||
_logger = logs.PayServer;
|
||||
Logs = logs;
|
||||
@@ -73,7 +77,6 @@ namespace BTCPayServer.Controllers
|
||||
[Route("~/Account/Login", Order = 2)]
|
||||
public async Task<IActionResult> Login(string returnUrl = null, string email = null)
|
||||
{
|
||||
|
||||
if (User.Identity.IsAuthenticated && string.IsNullOrEmpty(returnUrl))
|
||||
return RedirectToLocal();
|
||||
// Clear the existing external cookie to ensure a clean login process
|
||||
@@ -85,13 +88,36 @@ namespace BTCPayServer.Controllers
|
||||
}
|
||||
|
||||
ViewData["ReturnUrl"] = returnUrl;
|
||||
return View(new LoginViewModel()
|
||||
{
|
||||
Email = email
|
||||
});
|
||||
return View(nameof(Login), new LoginViewModel() { Email = email });
|
||||
}
|
||||
|
||||
|
||||
[HttpPost]
|
||||
[AllowAnonymous]
|
||||
[Route("~/login/code", Order = 1)]
|
||||
[ValidateAntiForgeryToken]
|
||||
[RateLimitsFilter(ZoneLimits.Login, Scope = RateLimitsScope.RemoteAddress)]
|
||||
|
||||
public async Task<IActionResult> LoginWithCode(string loginCode, string returnUrl = null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(loginCode))
|
||||
{
|
||||
var userId = _userLoginCodeService.Verify(loginCode);
|
||||
if (userId is null)
|
||||
{
|
||||
ModelState.AddModelError(string.Empty,
|
||||
"Login code was invalid");
|
||||
return await Login(returnUrl, null);
|
||||
}
|
||||
var user = await _userManager.FindByIdAsync(userId);
|
||||
|
||||
_logger.LogInformation("User with ID {UserId} logged in with a login code.", user.Id);
|
||||
await _signInManager.SignInAsync(user, false, "LoginCode");
|
||||
return RedirectToLocal(returnUrl);
|
||||
}
|
||||
return await Login(returnUrl, null);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[AllowAnonymous]
|
||||
[Route("~/login", Order = 1)]
|
||||
@@ -104,6 +130,7 @@ namespace BTCPayServer.Controllers
|
||||
{
|
||||
return RedirectToAction("Login");
|
||||
}
|
||||
|
||||
ViewData["ReturnUrl"] = returnUrl;
|
||||
if (ModelState.IsValid)
|
||||
{
|
||||
|
||||
21
BTCPayServer/Controllers/ManageController.LoginCodes.cs
Normal file
21
BTCPayServer/Controllers/ManageController.LoginCodes.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace BTCPayServer.Controllers
|
||||
{
|
||||
public partial class ManageController
|
||||
{
|
||||
[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)}'.");
|
||||
}
|
||||
|
||||
return View(nameof(LoginCodes), _userLoginCodeService.GetOrGenerate(user.Id));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ namespace BTCPayServer.Controllers
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
private readonly Fido2Service _fido2Service;
|
||||
private readonly LinkGenerator _linkGenerator;
|
||||
private readonly UserLoginCodeService _userLoginCodeService;
|
||||
private readonly UserService _userService;
|
||||
readonly StoreRepository _StoreRepository;
|
||||
|
||||
@@ -50,7 +51,8 @@ namespace BTCPayServer.Controllers
|
||||
IAuthorizationService authorizationService,
|
||||
Fido2Service fido2Service,
|
||||
LinkGenerator linkGenerator,
|
||||
UserService userService
|
||||
UserService userService,
|
||||
UserLoginCodeService userLoginCodeService
|
||||
)
|
||||
{
|
||||
_userManager = userManager;
|
||||
@@ -63,6 +65,7 @@ namespace BTCPayServer.Controllers
|
||||
_authorizationService = authorizationService;
|
||||
_fido2Service = fido2Service;
|
||||
_linkGenerator = linkGenerator;
|
||||
_userLoginCodeService = userLoginCodeService;
|
||||
_userService = userService;
|
||||
_StoreRepository = storeRepository;
|
||||
}
|
||||
|
||||
50
BTCPayServer/Fido2/UserLoginCodeService.cs
Normal file
50
BTCPayServer/Fido2/UserLoginCodeService.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using NBitcoin;
|
||||
using NBitcoin.DataEncoders;
|
||||
|
||||
namespace BTCPayServer.Fido2
|
||||
{
|
||||
public class UserLoginCodeService
|
||||
{
|
||||
private readonly IMemoryCache _memoryCache;
|
||||
|
||||
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 = TimeSpan.FromMinutes(1);
|
||||
var code = Encoders.Hex.EncodeData(RandomUtils.GetBytes(20));
|
||||
using var newEntry = _memoryCache.CreateEntry(code);
|
||||
newEntry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(1);
|
||||
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;
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,7 @@ namespace BTCPayServer.Hosting
|
||||
};
|
||||
});
|
||||
services.AddScoped<Fido2Service>();
|
||||
services.AddSingleton<UserLoginCodeService>();
|
||||
|
||||
var mvcBuilder= services.AddMvc(o =>
|
||||
{
|
||||
|
||||
@@ -12,6 +12,7 @@ namespace BTCPayServer.Models.AccountViewModels
|
||||
[Required]
|
||||
[DataType(DataType.Password)]
|
||||
public string Password { get; set; }
|
||||
public string LoginCode { get; set; }
|
||||
|
||||
[Display(Name = "Remember me?")]
|
||||
public bool RememberMe { get; set; }
|
||||
|
||||
@@ -11,5 +11,7 @@ namespace BTCPayServer.Models.ManageViewModels
|
||||
public bool Is2faEnabled { get; set; }
|
||||
|
||||
public List<Fido2Credential> Credentials { get; set; }
|
||||
|
||||
public string LoginCode { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,6 @@
|
||||
Layout = "_LayoutSimple";
|
||||
}
|
||||
|
||||
@section PageFootContent {
|
||||
<partial name="_ValidationScriptsPartial" />
|
||||
}
|
||||
|
||||
<div class="row justify-content-center mb-2">
|
||||
<div class="col text-center">
|
||||
<a asp-controller="Home" asp-action="Index" tabindex="-1">
|
||||
@@ -30,7 +26,7 @@
|
||||
<h4 class="modal-title">Sign In</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form asp-route-returnurl="@ViewData["ReturnUrl"]" method="post">
|
||||
<form asp-route-returnurl="@ViewData["ReturnUrl"]" method="post" id="login-form" asp-action="Login">
|
||||
<fieldset disabled="@(ViewData.ContainsKey("disabled") ? "disabled" : null)">
|
||||
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
|
||||
<div class="form-group">
|
||||
@@ -43,14 +39,22 @@
|
||||
<label asp-for="Password" class="form-label"></label>
|
||||
<a asp-action="ForgotPassword" tabindex="5">Forgot password?</a>
|
||||
</div>
|
||||
<div class="input-group d-flex">
|
||||
<input asp-for="Password" class="form-control" required tabindex="2"/>
|
||||
</div>
|
||||
<span asp-validation-for="Password" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="form-group mt-4">
|
||||
<button type="submit" class="btn btn-primary btn-lg w-100" id="LoginButton" tabindex="3">Sign in</button>
|
||||
<div class="btn-group w-100">
|
||||
<button type="submit" class="btn btn-primary btn-lg w-100" id="LoginButton" tabindex="3"><span class="ps-3">Sign in</span></button>
|
||||
<button type="button" class="btn btn-outline-primary btn-lg w-auto only-for-js" data-bs-toggle="modal" data-bs-target="#scanModal" title="Scan Login code with camera" tabindex="4"><i class="fa fa-camera"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
<form asp-action="LoginWithCode" asp-route-returnurl="@ViewData["ReturnUrl"]" method="post" id="logincode-form">
|
||||
<input asp-for="LoginCode" type="hidden" class="form-control"/>
|
||||
</form>
|
||||
@if (!(await _settingsRepository.GetPolicies()).LockSubscription)
|
||||
{
|
||||
<p class="text-center mt-2 mb-0">
|
||||
@@ -67,3 +71,20 @@
|
||||
<partial name="_BTCPaySupporters"/>
|
||||
</div>
|
||||
</div>
|
||||
<partial name="CameraScanner"/>
|
||||
|
||||
@section PageFootContent {
|
||||
<partial name="_ValidationScriptsPartial"/>
|
||||
|
||||
<bundle name="wwwroot/bundles/camera-bundle.min.js"></bundle>
|
||||
<link href="~/vendor/vue-qrcode-reader/vue-qrcode-reader.css" rel="stylesheet" asp-append-version="true"/>
|
||||
|
||||
<script type="text/javascript">
|
||||
window.addEventListener("load", async () => {
|
||||
initCameraScanningApp("Scan login code", data => {
|
||||
document.getElementById("LoginCode").value = data;
|
||||
document.getElementById("logincode-form").submit();
|
||||
}, "scanModal");
|
||||
});
|
||||
</script>
|
||||
}
|
||||
|
||||
3
BTCPayServer/Views/Account/_ViewImports.cshtml
Normal file
3
BTCPayServer/Views/Account/_ViewImports.cshtml
Normal file
@@ -0,0 +1,3 @@
|
||||
@using BTCPayServer.Views.Wallets
|
||||
@using BTCPayServer.Models.WalletViewModels
|
||||
@addTagHelper *, BundlerMinifier.TagHelpers
|
||||
37
BTCPayServer/Views/Manage/LoginCodes.cshtml
Normal file
37
BTCPayServer/Views/Manage/LoginCodes.cshtml
Normal file
@@ -0,0 +1,37 @@
|
||||
@model string
|
||||
@{
|
||||
ViewData.SetActivePageAndTitle(ManageNavPages.LoginCodes, "Login codes");
|
||||
}
|
||||
<h2 class="mb-4">@ViewData["Title"]</h2>
|
||||
<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 qr-container" style="width:256px">
|
||||
<vc:qr-code data="@Model" />
|
||||
<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>
|
||||
<a class="btn btn-secondary mt-3" id="regeneratecode" asp-action="LoginCodes">Regenerate code</a>
|
||||
</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>
|
||||
}
|
||||
@@ -2,6 +2,6 @@ namespace BTCPayServer.Views.Manage
|
||||
{
|
||||
public enum ManageNavPages
|
||||
{
|
||||
Index, ChangePassword, TwoFactorAuthentication, APIKeys, Notifications, Fido2
|
||||
Index, ChangePassword, TwoFactorAuthentication, APIKeys, Notifications, Fido2, LoginCodes
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,5 +5,6 @@
|
||||
<a id="SectionNav-@ManageNavPages.TwoFactorAuthentication.ToString()" class="nav-link @ViewData.IsActivePage(ManageNavPages.TwoFactorAuthentication)" asp-controller="Manage" asp-action="TwoFactorAuthentication">Two-Factor Authentication</a>
|
||||
<a id="SectionNav-@ManageNavPages.APIKeys.ToString()" class="nav-link @ViewData.IsActivePage(ManageNavPages.APIKeys)" asp-controller="Manage" asp-action="APIKeys">API Keys</a>
|
||||
<a id="SectionNav-@ManageNavPages.Notifications.ToString()" class="nav-link @ViewData.IsActivePage(ManageNavPages.Notifications)" asp-controller="Manage" asp-action="NotificationSettings">Notifications</a>
|
||||
<a id="SectionNav-@ManageNavPages.LoginCodes.ToString()" class="nav-link @ViewData.IsActivePage(ManageNavPages.LoginCodes)" asp-controller="Manage" asp-action="LoginCodes">Login Codes</a>
|
||||
<vc:ui-extension-point location="user-nav" model="@Model"/>
|
||||
</nav>
|
||||
|
||||
@@ -39,8 +39,8 @@
|
||||
<qrcode-capture v-if="noStreamApiSupport" v-on:decode="onDecode" :camera="cameraOff? 'off': cameras[camera]" :device-id="cameras[camera]"/>
|
||||
|
||||
</div>
|
||||
<div v-if="isLoaded && requestInput && cameras.length >2" class="d-flex justify-content-center align-items-center">
|
||||
<button class="btn btn-link text-center" v-on:click="nextCamera">Switch camera</button>
|
||||
<div v-if="isLoaded && requestInput && cameras.length > 2" class="d-flex justify-content-center align-items-center mt-3">
|
||||
<button class="btn btn-secondary text-center" v-on:click="nextCamera">Switch camera</button>
|
||||
</div>
|
||||
<div v-else-if="qrData || errorMessage">
|
||||
<div v-if="errorMessage" class="alert alert-danger" role="alert">
|
||||
@@ -107,11 +107,10 @@ function initCameraScanningApp(title, onDataSubmit, modalId) {
|
||||
},
|
||||
methods: {
|
||||
nextCamera: function (){
|
||||
if (this.camera == 0){
|
||||
if (this.camera === 0){
|
||||
this.camera++;
|
||||
} else if (this.camera == this.cameras.length -1) {
|
||||
|
||||
this.camera = 1;
|
||||
this.camera = 0;
|
||||
} else {
|
||||
this.camera++;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user