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:
Andrew Camilleri
2021-12-24 09:27:00 +01:00
committed by GitHub
parent 82b4debcac
commit 48ac996d77
17 changed files with 239 additions and 36 deletions

View File

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

View File

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

View File

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

View File

@@ -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)
{

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

View File

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

View 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;
}
}
}

View File

@@ -108,6 +108,7 @@ namespace BTCPayServer.Hosting
};
});
services.AddScoped<Fido2Service>();
services.AddSingleton<UserLoginCodeService>();
var mvcBuilder= services.AddMvc(o =>
{

View File

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

View File

@@ -11,5 +11,7 @@ namespace BTCPayServer.Models.ManageViewModels
public bool Is2faEnabled { get; set; }
public List<Fido2Credential> Credentials { get; set; }
public string LoginCode { get; set; }
}
}

View File

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

View File

@@ -0,0 +1,3 @@
@using BTCPayServer.Views.Wallets
@using BTCPayServer.Models.WalletViewModels
@addTagHelper *, BundlerMinifier.TagHelpers

View 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>
}

View File

@@ -2,6 +2,6 @@ namespace BTCPayServer.Views.Manage
{
public enum ManageNavPages
{
Index, ChangePassword, TwoFactorAuthentication, APIKeys, Notifications, Fido2
Index, ChangePassword, TwoFactorAuthentication, APIKeys, Notifications, Fido2, LoginCodes
}
}

View File

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

View File

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