Add loginless and passwordless passkey authentication
What changed, and why it matters
This commit adds passkey (passwordless) and login-code login support to BTCPay Server and rewrites much of the existing two-factor/FIDO2/LNURL-auth login flow. It is a large feature patch, not a documented security fix. The changes introduce several security-relevant design choices: passkeys can bypass the password entirely, session state now carries the login method and return URL, and the migration forces TwoFactorEnabled=true for all users while adding a separate AuthenticatorEnabled flag. Because the patch is broad and partially refactored, there is a non-trivial risk of authentication bugs (e.g., bypasses, session confusion, or incorrect 2FA enforcement), but the supplied diff does not show an obvious exploitable vulnerability.
Treat this as a high-risk authentication refactor requiring focused review and regression testing before release. Verify that: (1) passkey login cannot be replayed or used to authenticate as a different user; (2) LoginSession session data is protected from tampering and does not leak across users/sessions; (3) the migration's forced TwoFactorEnabled=true does not break users who previously had no 2FA configured; (4) recovery-code parsing still accepts all valid formats; (5) rate limiting and antiforgery tokens cover the new passkey and login-code endpoints; and (6) the removal of client-supplied UserId in FIDO2/LNURL callbacks fully prevents user-ID substitution. No CVE or advisory is present in the supplied materials.
Security signals we found
New passwordless authentication path (passkey) added to login controller
Login session state moved into ASP.NET session with custom serialized LoginSession class
Migration forces TwoFactorEnabled=true for all users and separates AuthenticatorEnabled flag
FIDO2/LNURL-auth login endpoints no longer accept a UserId from the client; they use signInManager.GetTwoFactorAuthenticationUserAsync
Recovery-code parsing changed from whitespace-stripped single string to Trim().Split(' ').FirstOrDefault()
Limited-login cookie renamed and redirect logic removed
New custom token providers disable email 2FA and gate authenticator/FIDO2 2FA
Evidence from the diff
The commit refactors UIAccountController login logic into a multi-method flow (Password, Passkey, LoginCode). Passkey login uses WebAuthn assertion options stored in ASP.NET session and resolves the user from the assertion’s user handle, allowing passwordless login. A new LoginSession class is serialized into session and used to preserve RememberMe/ReturnUrl/AuthenticationMethod across the primary and secondary login steps. FIDO2/LNURL-auth now rely on Identity’s two-factor user scheme rather than embedding UserId in view models. The migration sets TwoFactorEnabled=true for every user and backfills AuthenticatorEnabled only for users with an AuthenticatorKey token. New token providers disable email 2FA and gate authenticator/FIDO2 2FA on explicit flags. The change is large (+1302/-890 across 52 files) and touches authentication-critical code, so correctness depends on details not fully visible in the diff (e.g., session configuration, antiforgery, rate limiting, callback validation).
Changed components
BTCPayServer/Controllers/UIAccountController.csBTCPayServer/Fido2/Fido2Service.csBTCPayServer/Fido2/UIFido2Controller.csBTCPayServer/Fido2/Fido2TokenProvider.csBTCPayServer/Controllers/UILNURLAuthController.csBTCPayServer/Controllers/UIManageController.Authenticator.csBTCPayServer/Controllers/UIManageController.csBTCPayServer/Security/BTCPayAuthenticatorTokenProvider.csBTCPayServer/Security/DisabledEmailTokenProvider.csBTCPayServer/Hosting/Startup.csBTCPayServer.Data/Migrations/20260525115757_passkey.csInspect captured patch +1302 / −890
diff --git a/BTCPayServer.Abstractions/Constants/AuthenticationSchemes.cs b/BTCPayServer.Abstractions/Constants/AuthenticationSchemes.cs
index 39caf7b..fb6cd57 100644
--- a/BTCPayServer.Abstractions/Constants/AuthenticationSchemes.cs
+++ b/BTCPayServer.Abstractions/Constants/AuthenticationSchemes.cs
@@ -4,7 +4,7 @@ namespace BTCPayServer.Abstractions.Constants
{
public const string Cookie = "Identity.Application";
/// <summary>
- /// The user could use his password; however, some policies prevented him access to BTCPay Server.
+ /// The user could log in; however, some policies prevented him access to BTCPay Server.
/// </summary>
public const string LimitedLogin = "LimitedLogin";
diff --git a/BTCPayServer.Data/Data/ApplicationUser.cs b/BTCPayServer.Data/Data/ApplicationUser.cs
index a065364..90b12a7 100644
--- a/BTCPayServer.Data/Data/ApplicationUser.cs
+++ b/BTCPayServer.Data/Data/ApplicationUser.cs
@@ -8,8 +8,12 @@ using Microsoft.EntityFrameworkCore.Infrastructure;
namespace BTCPayServer.Data
{
// Add profile data for application users by adding properties to the ApplicationUser class
- public class ApplicationUser : IdentityUser, IHasBlob<UserBlob>
+ public sealed class ApplicationUser : IdentityUser, IHasBlob<UserBlob>
{
+ public ApplicationUser()
+ {
+ TwoFactorEnabled = true;
+ }
public bool RequiresEmailConfirmation { get; set; }
public bool RequiresApproval { get; set; }
public bool Approved { get; set; }
@@ -20,6 +24,7 @@ namespace BTCPayServer.Data
public DateTimeOffset? Created { get; set; }
public string DisabledNotifications { get; set; }
public bool BypassMonetization { get; set; }
+ public bool AuthenticatorEnabled { get; set; }
public List<NotificationData> Notifications { get; set; }
public List<UserStore> UserStores { get; set; }
public List<Fido2Credential> Fido2Credentials { get; set; }
@@ -34,6 +39,9 @@ namespace BTCPayServer.Data
public bool IsDisabled =>
this is { LockoutEnabled: true, LockoutEnd: { } lockoutEnd } &&
DateTimeOffset.UtcNow < lockoutEnd.UtcDateTime;
+ [NotMapped]
+ public bool IsDisabledTemporarily =>
+ IsDisabled && DateTimeOffset.MaxValue - LockoutEnd!.Value >= TimeSpan.FromSeconds(1);
public static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
{
diff --git a/BTCPayServer.Data/Data/Fido2Credential.cs b/BTCPayServer.Data/Data/Fido2Credential.cs
index 420c0f3..e0b599d 100644
--- a/BTCPayServer.Data/Data/Fido2Credential.cs
+++ b/BTCPayServer.Data/Data/Fido2Credential.cs
@@ -1,3 +1,4 @@
+using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
@@ -16,12 +17,15 @@ namespace BTCPayServer.Data
public byte[] Blob { get; set; }
public string Blob2 { get; set; }
public CredentialType Type { get; set; }
+ public DateTimeOffset? LastUsedAt { get; set; }
public enum CredentialType
{
[Display(Name = "Security device (FIDO2)")]
FIDO2,
[Display(Name = "Lightning node (LNURL Auth)")]
- LNURLAuth
+ LNURLAuth,
+ [Display(Name = "Passkey (Sign in with Passkey)")]
+ Passkey
}
public static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
{
diff --git a/BTCPayServer.Data/Migrations/20260525115757_passkey.cs b/BTCPayServer.Data/Migrations/20260525115757_passkey.cs
new file mode 100644
index 0000000..df4678c
--- /dev/null
+++ b/BTCPayServer.Data/Migrations/20260525115757_passkey.cs
@@ -0,0 +1,48 @@
+using System;
+using BTCPayServer.Data;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace BTCPayServer.Migrations
+{
+ [DbContext(typeof(ApplicationDbContext))]
+ [Migration("20260525115757_passkey")]
+ public partial class passkey : Migration
+ {
+ /// <inheritdoc />
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn<DateTimeOffset>(
+ name: "LastUsedAt",
+ table: "Fido2Credentials",
+ type: "timestamp with time zone",
+ nullable: true);
+ migrationBuilder.AddColumn<bool>(
+ name: "AuthenticatorEnabled",
+ table: "AspNetUsers",
+ type: "boolean",
+ nullable: false,
+ defaultValue: false);
+
+ migrationBuilder.Sql("""
+ UPDATE "AspNetUsers" u
+ SET "AuthenticatorEnabled" = true
+ FROM "AspNetUserTokens" t
+ WHERE u."Id" = t."UserId"
+ AND u."TwoFactorEnabled" = true
+ AND t."Name" = 'AuthenticatorKey';
+
+ UPDATE "AspNetUsers"
+ SET "TwoFactorEnabled" = true
+ WHERE "TwoFactorEnabled" = false;
+ """);
+ }
+
+ /// <inheritdoc />
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ }
+ }
+}
diff --git a/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs b/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs
index 2214691..3053603 100644
--- a/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs
+++ b/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs
@@ -142,6 +142,9 @@ namespace BTCPayServer.Migrations
b.Property<bool>("Approved")
.HasColumnType("boolean");
+ b.Property<bool>("AuthenticatorEnabled")
+ .HasColumnType("boolean");
+
b.Property<byte[]>("Blob")
.HasColumnType("bytea");
@@ -384,6 +387,9 @@ namespace BTCPayServer.Migrations
b.Property<string>("Blob2")
.HasColumnType("JSONB");
+ b.Property<DateTimeOffset?>("LastUsedAt")
+ .HasColumnType("timestamp with time zone");
+
b.Property<string>("Name")
.HasColumnType("text");
diff --git a/BTCPayServer.Tests/BTCPayServerTester.cs b/BTCPayServer.Tests/BTCPayServerTester.cs
index 34e0efe..a552969 100644
--- a/BTCPayServer.Tests/BTCPayServerTester.cs
+++ b/BTCPayServer.Tests/BTCPayServerTester.cs
@@ -27,6 +27,7 @@ using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ApplicationParts;
+using Microsoft.AspNetCore.Session;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
@@ -106,6 +107,8 @@ namespace BTCPayServer.Tests
public bool DisableRegistration { get; set; } = false;
public async Task StartAsync()
{
+ if (_Host is not null)
+ return;
if (!Directory.Exists(_Directory))
Directory.CreateDirectory(_Directory);
string chain = NBXplorerDefaultSettings.GetFolderName(ChainName.Regtest);
@@ -411,6 +414,7 @@ namespace BTCPayServer.Tests
var httpAccessor = provider.GetRequiredService<IHttpContextAccessor>();
httpAccessor.HttpContext = context;
+ context.Session = provider.GetRequiredService<ISessionStore>().Create("Dummy", TimeSpan.MaxValue, TimeSpan.MaxValue, () => true, true);
var controller = (T)ActivatorUtilities.CreateInstance(provider, typeof(T));
diff --git a/BTCPayServer.Tests/ImpersonationTests.cs b/BTCPayServer.Tests/ImpersonationTests.cs
index 16bf904..64f7754 100644
--- a/BTCPayServer.Tests/ImpersonationTests.cs
+++ b/BTCPayServer.Tests/ImpersonationTests.cs
@@ -28,13 +28,15 @@ public class ImpersonationTests(ITestOutputHelper helper) : UnitTestBase(helper)
Assert.NotEqual(prevCode, code);
await s.Logout();
await s.GoToLogin();
+ await s.Page.EvaluateAsync("document.querySelector('#login-password-fieldset').disabled = true");
await s.Page.EvaluateAsync("document.getElementById('LoginCode').value = 'bad code'");
- await s.Page.EvaluateAsync("document.getElementById('logincode-form').submit()");
+ await s.Page.EvaluateAsync("document.getElementById(\"LoginCodeButton\").click();");
await s.Page.WaitForLoadStateAsync();
await s.GoToLogin();
+ await s.Page.EvaluateAsync("document.querySelector('#login-password-fieldset').disabled = true");
await s.Page.EvaluateAsync($"document.getElementById('LoginCode').value = '{code}'");
- await s.Page.EvaluateAsync("document.getElementById('logincode-form').submit()");
+ await s.Page.EvaluateAsync("document.getElementById(\"LoginCodeButton\").click();");
await s.Page.WaitForLoadStateAsync();
await s.Page.WaitForLoadStateAsync();
diff --git a/BTCPayServer.Tests/PlaywrightTester.cs b/BTCPayServer.Tests/PlaywrightTester.cs
index 2ba5cb0..c200f19 100644
--- a/BTCPayServer.Tests/PlaywrightTester.cs
+++ b/BTCPayServer.Tests/PlaywrightTester.cs
@@ -57,7 +57,7 @@ namespace BTCPayServer.Tests
});
var context = await Browser.NewContextAsync();
Page = await context.NewPageAsync();
- ServerUri = Server.PayTester.ServerUri;
+ ServerUri ??= Server.PayTester.ServerUri;
TestLogs.LogInformation($"Playwright: Using {Page.GetType()}");
TestLogs.LogInformation($"Playwright: Browsing to {ServerUri}");
await GoToRegister();
@@ -982,5 +982,10 @@ namespace BTCPayServer.Tests
=> Expect(Page.Locator(selector)).ToHaveCountAsync(0);
public GlobalSearchPMO GlobalSearch => new GlobalSearchPMO(this);
+
+ public async Task WaitLoggedIn()
+ {
+ await Page.WaitForURLAsync(ServerUri.AbsoluteUri + $"stores/{StoreId}");
+ }
}
}
diff --git a/BTCPayServer.Tests/PlaywrightTests.cs b/BTCPayServer.Tests/PlaywrightTests.cs
index 2a6d129..7f7239c 100644
--- a/BTCPayServer.Tests/PlaywrightTests.cs
+++ b/BTCPayServer.Tests/PlaywrightTests.cs
@@ -1794,8 +1794,8 @@ namespace BTCPayServer.Tests
var user = await s.RegisterNewUser(true);
await s.SkipWizard();
await s.GoToProfile(ManageNavPages.TwoFactorAuthentication);
- await s.Page.FillAsync("[name='Name']", "ln wallet");
- await s.Page.SelectOptionAsync("[name='type']", $"{(int)Fido2Credential.CredentialType.LNURLAuth}");
+ await s.Page.FillAsync("#security-device-form [name='Name']", "ln wallet");
+ await s.Page.SelectOptionAsync("select[name='type']", "LNURLAuth");
await s.Page.ClickAsync("#btn-add");
var linkElements = await s.Page.Locator(".tab-content a").AllAsync();
var links = new List<string>();
@@ -1841,11 +1841,7 @@ namespace BTCPayServer.Tests
}
request = Assert.IsType<LNAuthRequest>(await LNURL.LNURL.FetchInformation(prevEndpoint, null));
_ = await request.SendChallenge(linkingKey, new HttpClient());
- await TestUtils.EventuallyAsync(() =>
- {
- Assert.StartsWith(s.ServerUri.ToString(), s.Page.Url);
- return Task.CompletedTask;
- });
+ await s.WaitLoggedIn();
}
[Fact]
diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs
index dc2764d..b845d09 100644
--- a/BTCPayServer.Tests/UnitTest1.cs
+++ b/BTCPayServer.Tests/UnitTest1.cs
@@ -7,6 +7,7 @@ using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text;
+using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
@@ -69,6 +70,7 @@ using RatesViewModel = BTCPayServer.Models.StoreViewModels.RatesViewModel;
using Microsoft.Extensions.Caching.Memory;
using PosViewType = BTCPayServer.Client.Models.PosViewType;
using BTCPayServer.Services.Stores;
+using BTCPayServer.Views.Manage;
using BTCPayServer.Views.Stores;
using Microsoft.Playwright;
using NBXplorer.DerivationStrategy;
@@ -1856,76 +1858,95 @@ namespace BTCPayServer.Tests
}
[Fact(Timeout = LongRunningTestTimeout)]
- [Trait("Integration", "Integration")]
+ [Trait("Playwright", "Playwright")]
public async Task CanLoginWithNoSecondaryAuthSystemsOrRequestItWhenAdded()
{
- using var tester = CreateServerTester();
- await tester.StartAsync();
- var user = tester.NewAccount();
- user.GrantAccess();
+ await using var s = CreatePlaywrightTester();
+ await s.Server.StartAsync();
+ // We need this for WebAuth to be mocked by playwright properly.
+ s.ServerUri = new Uri(s.Server.PayTester.ServerUriWithIP.AbsoluteUri.Replace("127.0.0.1", "localhost"));
+ await s.StartAsync();
+ await s.RegisterNewUser();
+ await s.CreateNewStore();
- var accountController = tester.PayTester.GetController<UIAccountController>();
+ await s.Logout();
//no 2fa or fido2 enabled, login should work
- Assert.Equal(nameof(UIHomeController.Index),
- Assert.IsType<RedirectToActionResult>(await accountController.Login(new LoginViewModel()
- {
- Email = user.RegisterDetails.Email,
- Password = user.RegisterDetails.Password
- })).ActionName);
-
- var listController = user.GetController<UIManageController>();
- var manageController = user.GetController<UIFido2Controller>();
-
- //by default no fido2 devices available
- Assert.Empty(Assert
- .IsType<TwoFactorAuthenticationViewModel>(Assert
- .IsType<ViewResult>(await listController.TwoFactorAuthentication()).Model).Credentials);
- Assert.IsType<CredentialCreateOptions>(Assert
- .IsType<ViewResult>(await manageController.Create(new AddFido2CredentialViewModel
- {
- Name = "label"
- })).Model);
+ await s.LogIn(s.CreatedUser);
+ await s.GoToProfile(ManageNavPages.TwoFactorAuthentication);
- //sending an invalid response model back to server, should error out
- Assert.IsType<RedirectToActionResult>(await manageController.CreateResponse("sdsdsa", "sds"));
- var statusModel = manageController.TempData.GetStatusMessageModel();
- Assert.Equal(StatusMessageModel.StatusSeverity.Error, statusModel.Severity);
+ var cdp = await s.Page.Context.NewCDPSessionAsync(s.Page);
+ await cdp.SendAsync("WebAuthn.enable");
- var contextFactory = tester.PayTester.GetService<ApplicationDbContextFactory>();
+ var authenticatorId = await AddFIDO2(cdp, false);
- //add a fake fido2 device in db directly since emulating a fido2 device is hard and annoying
- using (var context = contextFactory.CreateContext())
- {
- var newDevice = new Fido2Credential()
- {
- Id = Guid.NewGuid().ToString(),
- Name = "fake",
- Type = Fido2Credential.CredentialType.FIDO2,
- ApplicationUserId = user.UserId
- };
- newDevice.SetBlob(new Fido2CredentialBlob() { });
- await context.Fido2Credentials.AddAsync(newDevice);
- await context.SaveChangesAsync();
-
- Assert.NotNull(newDevice.Id);
- Assert.NotEmpty(Assert
- .IsType<TwoFactorAuthenticationViewModel>(Assert
- .IsType<ViewResult>(await listController.TwoFactorAuthentication()).Model).Credentials);
- }
+ await s.Page.FillAsync("#security-device-form input[name='Name']", "TestDevice");
+ await s.Page.ClickAsync("#security-device-form button[type='submit']");
+ await s.FindAlertMessage();
- //check if we are showing the fido2 login screen now
- var secondLoginResult = Assert.IsType<ViewResult>(await accountController.Login(new LoginViewModel()
- {
- Email = user.RegisterDetails.Email,
- Password = user.RegisterDetails.Password
- }));
+ await s.Logout();
+ await s.LogIn(s.CreatedUser);
+ await s.WaitLoggedIn();
+
+ await RemoveFIDO2(cdp, authenticatorId);
+ await AddFIDO2(cdp, true);
+
+ await s.GoToProfile(ManageNavPages.Passkeys);
+
+ await s.Page.FillAsync("#passkey-form input[name='Name']", "PasskeyTest");
+ await s.Page.ClickAsync("#passkey-form button[type='submit']");
+ await s.FindAlertMessage();
+
+ await s.Logout();
+
+ // No need of password
+ await s.Page.ClickAsync("#passkey-login-btn");
+ await s.WaitLoggedIn();
- Assert.Equal("SecondaryLogin", secondLoginResult.ViewName);
- var vm = Assert.IsType<SecondaryLoginViewModel>(secondLoginResult.Model);
- //2fa was never enabled for user so this should be empty
- Assert.Null(vm.LoginWith2FaViewModel);
- Assert.NotNull(vm.LoginWithFido2ViewModel);
+ await s.GoToProfile(ManageNavPages.TwoFactorAuthentication);
+
+ // Let's remove both FIDO2 and Passkey
+ await s.Page.Locator("a:text('Remove')").First.ClickAsync();
+ await s.ConfirmDeleteModal();
+ await s.FindAlertMessage();
+
+ await s.GoToProfile(ManageNavPages.Passkeys);
+
+ await s.Page.Locator("a:text('Remove')").First.ClickAsync();
+ await s.ConfirmDeleteModal();
+ await s.FindAlertMessage();
+ await s.Logout();
+
+ await s.LogIn(s.CreatedUser);
+ await s.WaitLoggedIn();
+ }
+
+ private static async Task RemoveFIDO2(ICDPSession cdp, string authenticatorId)
+ {
+ await cdp.SendAsync(
+ "WebAuthn.removeVirtualAuthenticator",
+ new Dictionary<string, object>
+ {
+ ["authenticatorId"] = authenticatorId
+ });
+ }
+
+ private static async Task<string> AddFIDO2(ICDPSession cdp, bool passkey)
+ {
+ var result = await cdp.SendAsync("WebAuthn.addVirtualAuthenticator",
+ new Dictionary<string, object>
+ {
+ ["options"] = new Dictionary<string, object>
+ {
+ ["protocol"] = "ctap2",
+ ["transport"] = "internal",
+ ["hasResidentKey"] = passkey,
+ ["hasUserVerification"] = passkey,
+ ["isUserVerified"] = passkey,
+ ["automaticPresenceSimulation"] = true
+ }
+ });
+ return result!.Value.GetProperty("authenticatorId").GetString();
}
[Fact(Timeout = LongRunningTestTimeout)]
diff --git a/BTCPayServer/Components/GlobalNav/Default.cshtml b/BTCPayServer/Components/GlobalNav/Default.cshtml
index fe5b9d6..a9c6274 100644
--- a/BTCPayServer/Components/GlobalNav/Default.cshtml
+++ b/BTCPayServer/Components/GlobalNav/Default.cshtml
@@ -169,6 +169,9 @@
<li class="py-1 px-3">
<a layout-menu-item="@nameof(ManageNavPages.TwoFactorAuthentication)" asp-controller="UIManage" asp-action="TwoFactorAuthentication" text-translate="true">Two-Factor Authentication</a>
</li>
+ <li class="py-1 px-3">
+ <a layout-menu-item="@nameof(ManageNavPages.Passkeys)" asp-controller="UIManage" asp-action="Passkeys" text-translate="true">Passkeys</a>
+ </li>
<li class="py-1 px-3">
<a layout-menu-item="@nameof(ManageNavPages.APIKeys)" asp-controller="UIManage" asp-action="APIKeys" text-translate="true">API Keys</a>
</li>
diff --git a/BTCPayServer/Controllers/LnurlAuthService.cs b/BTCPayServer/Controllers/LnurlAuthService.cs
index 37c0a06..bd86bc0 100644
--- a/BTCPayServer/Controllers/LnurlAuthService.cs
+++ b/BTCPayServer/Controllers/LnurlAuthService.cs
@@ -13,9 +13,7 @@ namespace BTCPayServer
{
public class LoginWithLNURLAuthViewModel
{
- public string UserId { get; set; }
public Uri LNURLEndpoint { get; set; }
- public bool RememberMe { get; set; }
}
public class LnurlAuthService
@@ -68,6 +66,7 @@ namespace BTCPayServer
}
var newCredential = new Fido2Credential() { Name = name, ApplicationUserId = userId, Type = Fido2Credential.CredentialType.LNURLAuth, Blob = pubkeyBytes };
+ user.TwoFactorEnabled = true;
await dbContext.Fido2Credentials.AddAsync(newCredential);
await dbContext.SaveChangesAsync();
CreationStore.Remove(userId, out _);
@@ -133,18 +132,15 @@ namespace BTCPayServer
{
return false;
}
+
+ credential.LastUsedAt = DateTimeOffset.UtcNow;
+ await dbContext.SaveChangesAsync();
LoginStore.Remove(userId, out _);
FinalLoginStore.AddOrReplace(userId, k1);
// 7. return OK to client
return true;
}
-
- public async Task<bool> HasCredentials(string userId)
- {
- await using var context = _contextFactory.CreateContext();
- return await context.Fido2Credentials.Where(fDevice => fDevice.ApplicationUserId == userId && fDevice.Type == Fido2Credential.CredentialType.LNURLAuth).AnyAsync();
- }
}
public class LightningAddressQuery
diff --git a/BTCPayServer/Controllers/UIAccountController.cs b/BTCPayServer/Controllers/UIAccountController.cs
index bf58886..5f1ece1 100644
--- a/BTCPayServer/Controllers/UIAccountController.cs
+++ b/BTCPayServer/Controllers/UIAccountController.cs
@@ -1,12 +1,15 @@
using System;
+using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Security.Claims;
using System.Threading.Tasks;
+using BTCPayServer;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
+using BTCPayServer.Controllers;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Fido2;
@@ -17,22 +20,27 @@ using BTCPayServer.Models;
using BTCPayServer.Models.AccountViewModels;
using BTCPayServer.Services;
using BTCPayServer.Plugins.Emails.Services;
+using BTCPayServer.Plugins.Impersonation;
using BTCPayServer.Security;
using Fido2NetLib;
+using JetBrains.Annotations;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Localization;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Logging;
using NBitcoin.DataEncoders;
+using Newtonsoft.Json;
using NicolasDorier.RateLimits;
namespace BTCPayServer.Controllers
{
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public class UIAccountController(
+ UserLoginCodeService userLoginCodeService,
UserManager<ApplicationUser> userManager,
RoleManager<IdentityRole> roleManager,
SignInManager<ApplicationUser> signInManager,
@@ -61,13 +69,15 @@ namespace BTCPayServer.Controllers
[TempData]
public string ErrorMessage
{
- get; set;
+ get;
+ set;
}
[HttpGet("/cheat/permissions")]
[HttpGet("/cheat/permissions/stores/{storeId}")]
[CheatModeRoute]
- public async Task<IActionResult> CheatPermissions([FromServices] IAuthorizationService authorizationService, [FromServices] PermissionService permissionService, string storeId = null)
+ public async Task<IActionResult> CheatPermissions([FromServices] IAuthorizationService authorizationService,
+ [FromServices] PermissionService permissionService, string storeId = null)
{
var vm = new CheatPermissionsViewModel();
vm.StoreId = storeId;
@@ -77,6 +87,7 @@ namespace BTCPayServer.Controllers
results.Add((p.Policy, authorizationService.AuthorizeAsync(User, storeId, new PolicyRequirement(p.Policy))));
results.Add((p.Policy + ":", authorizationService.AuthorizeAsync(User, storeId, new PolicyRequirement(p.Policy, requireUnscoped: true))));
}
+
await Task.WhenAll(results.Select(r => r.Item2));
results = results.OrderBy(r => r.Item1).ToList();
vm.Permissions = results.Select(r => (r.Item1, r.Item2.Result)).ToArray();
@@ -85,7 +96,7 @@ namespace BTCPayServer.Controllers
[HttpGet("/login")]
[AllowAnonymous]
- public async Task<IActionResult> Login(string returnUrl = null, string email = null, bool allowLimitedLogin = false)
+ public async Task<IActionResult> Login(string returnUrl = null, string email = null, string loginCode = null)
{
var allowRedirect =
(email is null && User.Identity?.IsAuthenticated is true) ||
@@ -102,8 +113,20 @@ namespace BTCPayServer.Controllers
SetInsecureFlags();
}
- ViewData["ReturnUrl"] = returnUrl;
- return View(nameof(Login), new LoginViewModel { Email = email, AllowLimitedLogin = allowLimitedLogin });
+ var vm = new LoginViewModel { Email = email, LoginCode = loginCode };
+ if (loginCode != null)
+ {
+ returnUrl = ParseLoginCodeUrl(vm, returnUrl);
+ ViewData["ReturnUrl"] = returnUrl;
+ var userId = userLoginCodeService.Verify(loginCode, false);
+ vm.Email = (await userManager.FindByIdAsync(userId ?? ""))?.Email;
+ return View("LoginWithLoginCode", vm);
+ }
+ else
+ {
+ ViewData["ReturnUrl"] = returnUrl;
+ return View(nameof(Login), vm);
+ }
}
private UserService.CanLoginContext CreateLoginContext(ApplicationUser user)
@@ -121,163 +144,212 @@ namespace BTCPayServer.Controllers
}
ViewData["ReturnUrl"] = returnUrl;
- if (ModelState.IsValid)
+ // Require the user to pass basic checks (approval, confirmed email, not disabled) before they can log on
+ ApplicationUser user = null;
+ bool bypass2fa = false;
+ bool success = false;
+ var session = new LoginSession()
+ {
+ RememberMe = model.RememberMe,
+ AuthenticationMethod = model.Method,
+ ReturnUrl = returnUrl
+ };
+
+ if (model.Method == "Passkey" && model.PasskeyResponse is not null && GetAssertionOptions("PASSKEY") is {} assertionOptions)
{
- // Require the user to pass basic checks (approval, confirmed email, not disabled) before they can log on
- var user = await userManager.FindByEmailAsync(model.Email);
- var errorMessage = StringLocalizer["Invalid login attempt."].Value;
- var loginContext = CreateLoginContext(user);
- if (!await userService.CanLogin(loginContext))
+ var passKeyResult = await fido2Service.CompleteLogin(null, model.PasskeyResponse, assertionOptions, true);
+ if (passKeyResult is Fido2Service.LoginResult.Success { User: { } u })
{
- if (user is null || !await userManager.CheckPasswordAsync(user, model.Password))
- {
- if (user is not null)
- await userManager.AccessFailedAsync(user);
- ModelState.AddModelError(string.Empty, errorMessage!);
- return View(model);
- }
- // Only show the real reason if the user has input the right password...
- else
- {
- var principal = await signInManager.CreateUserPrincipalAsync(user);
- await HttpContext.SignInAsync(AuthenticationSchemes.LimitedLogin, principal);
- if (model.AllowLimitedLogin && returnUrl != null)
- return RedirectToLocal(returnUrl);
-
- if (loginContext.FailedRedirectUrl is { } url)
- return Redirect(url);
- else
- TempData.SetStatusLoginResult(loginContext);
- return RedirectToAction(nameof(Login));
- }
+ user = u;
+ ModelState.Remove(nameof(model.Email));
+ model.Email = user.Email;
+ HttpContext.Session.Remove("PASSKEY");
+ bypass2fa = true;
+ success = true;
}
-
- var fido2Devices = await fido2Service.HasCredentials(user!.Id);
- var lnurlAuthCredentials = await lnurlAuthService.HasCredentials(user.Id);
- if (fido2Devices || lnurlAuthCredentials)
+ else if (passKeyResult is Fido2Service.LoginResult.Failed e)
{
- if (await userManager.CheckPasswordAsync(user, model.Password))
- {
- LoginWith2faViewModel twoFModel = null;
-
- if (user.TwoFactorEnabled)
- {
- // we need to do an actual sign in attempt so that 2fa can function in next step
- await signInManager.PasswordSignInAsync(model.Email, model.Password, model.RememberMe, lockoutOnFailure: true);
- twoFModel = new LoginWith2faViewModel
- {
- RememberMe = model.RememberMe
- };
- }
-
- return View("SecondaryLogin", new SecondaryLoginViewModel
- {
- LoginWith2FaViewModel = twoFModel,
- LoginWithFido2ViewModel = fido2Devices ? await BuildFido2ViewModel(model.RememberMe, user) : null,
- LoginWithLNURLAuthViewModel = lnurlAuthCredentials ? await BuildLNURLAuthViewModel(model.RememberMe, user) : null,
- });
- }
+ ModelState.AddModelError(string.Empty, e.Reason);
+ return View(model);
+ }
+ }
+ if (model.Method == "Password")
+ {
+ user = await userManager.FindByEmailAsync(model.Email ?? "");
+ success = user is not null && await userManager.CheckPasswordAsync(user, model.Password ?? "");
+ if (!success && user is not null && !user.IsDisabledTemporarily)
await userManager.AccessFailedAsync(user);
- ModelState.AddModelError(string.Empty, errorMessage!);
- return View(model);
+ }
+
+ if (model.Method == "LoginCode" && model.LoginCode is not null)
+ {
+ session.ReturnUrl = ParseLoginCodeUrl(model, session.ReturnUrl);
+ session.ExpiresUtc = DateTimeOffset.UtcNow.AddDays(1.0);
+
+ var userId = userLoginCodeService.Verify(model.LoginCode);
+ user = await userManager.FindByIdAsync(userId ?? "");
+ if (user is not null)
+ {
+ success = true;
+ bypass2fa = true;
}
+ }
+ if (user?.IsDisabledTemporarily is true)
+ return LockoutView(user);
- var result = await signInManager.PasswordSignInAsync(model.Email, model.Password, model.RememberMe, lockoutOnFailure: true);
- if (result.Succeeded)
+ var errorMessage = StringLocalizer["Invalid login attempt."].Value;
+ if (!success || user is null)
+ {
+ ModelState.AddModelError(string.Empty, errorMessage!);
+ return View(model);
+ }
+
+ if (bypass2fa)
+ {
+ return await RedirectLoginSuccess(user, session);
+ }
+ else if (model.Method == "Password")
+ {
+ // The password has already been checked.
+ var hasTwofactor = await signInManager.IsTwoFactorEnabledAsync(user);
+ if (hasTwofactor)
{
- _logger.LogInformation("User {Email} logged in", user.Email);
- return RedirectToLocal(returnUrl);
+ session.Store(HttpContext.Session);
+ await signInManager.TwoFactorSignInAsync(user);
+ return RedirectToSecondaryLogin();
}
- if (result.RequiresTwoFactor)
+ else
{
- return View("SecondaryLogin", new SecondaryLoginViewModel
- {
- LoginWith2FaViewModel = new LoginWith2faViewModel
- {
- RememberMe = model.RememberMe
- }
- });
+ return await RedirectLoginSuccess(user, session);
}
- if (result.IsLockedOut)
+ }
+ return View(model);
+ }
+
+ private string ParseLoginCodeUrl(LoginViewModel model, string returnUrl)
+ {
+ // loginCode might be url: https://btcpay.example.com/login/code?loginCode=***&returnUrl=***
+ // if that's the case, we need to extract the loginCode and the returnUrl from the query string.
+ if (Uri.TryCreate(model.LoginCode, UriKind.Absolute, out var uri))
+ {
+ var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query);
+ if (query.TryGetValue("loginCode", out var code))
{
- _logger.LogWarning("User {Email} tried to log in, but is locked out", user.Email);
- return RedirectToAction(nameof(Lockout), new { user.LockoutEnd });
+ model.LoginCode = code;
}
- ModelState.AddModelError(string.Empty, errorMessage);
- return View(model);
+ if (query.TryGetValue("returnUrl", out var url) && string.IsNullOrEmpty(returnUrl))
+ {
+ returnUrl = url;
+ ViewData["ReturnUrl"] = returnUrl;
+ }
}
- // If we got this far, something failed, redisplay form
- return View(model);
+ return returnUrl;
}
- private async Task<LoginWithFido2ViewModel> BuildFido2ViewModel(bool rememberMe, ApplicationUser user)
+ public class LoginSession
{
- if (!btcPayServerEnvironment.IsSecure(HttpContext))
- return null;
- var r = await fido2Service.RequestLogin(user.Id);
- if (r is null)
- return null;
- return new LoginWithFido2ViewModel
- {
- Data = r.ToJson(),
- UserId = user.Id,
- RememberMe = rememberMe
+ public void Store(ISession session)
+ {
+ var str = JsonConvert.SerializeObject(this, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore });
+ session.SetString("LoginSession", str);
+ }
+ public static LoginSession Load(ISession session)
+ {
+ var str = session.GetString("LoginSession");
+ if (str == null)
+ return new();
+ return JsonConvert.DeserializeObject<LoginSession>(str);
+ }
+
+ public string AuthenticationMethod { get; set; } = AuthenticationSchemes.LimitedLogin;
+ public bool RememberMe { get; set; }
+ public string ReturnUrl { get; set; }
+ [JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
+ public DateTimeOffset? ExpiresUtc { get; set; }
+
+ public void Clear(ISession session)
+ {
+ session.Remove("LoginSession");
+ }
+
+ public AuthenticationProperties ToAuthenticationProperties(bool limitedLogin)
+ => new()
+ {
+ ExpiresUtc = limitedLogin ? DateTimeOffset.UtcNow.AddMinutes(15) : ExpiresUtc,
+ AllowRefresh = !limitedLogin,
+ IsPersistent = RememberMe
};
}
- private async Task<LoginWithLNURLAuthViewModel> BuildLNURLAuthViewModel(bool rememberMe, ApplicationUser user)
+ private RedirectToActionResult RedirectToSecondaryLogin()
+ => RedirectToAction(nameof(SecondaryLogin));
+
+ [HttpGet("login/second-login")]
+ [AllowAnonymous]
+ public async Task<IActionResult> SecondaryLogin()
{
- if (btcPayServerEnvironment.IsSecure(HttpContext))
+ var user = await signInManager.GetTwoFactorAuthenticationUserAsync();
+ if (user is null)
+ return Forbid();
+
+ var vm = new SecondaryLoginViewModel();
+ if (!btcPayServerEnvironment.IsSecure(HttpContext))
+ return View("SecondaryLogin", vm);
+
+
+ var fidoOptions = await fido2Service.RequestLogin(user.Id);
+ if (fidoOptions is not null)
{
- var r = await lnurlAuthService.RequestLogin(user.Id);
- if (r is null)
+ vm.LoginWithFido2ViewModel = new LoginWithFido2ViewModel()
{
- return null;
- }
- return new LoginWithLNURLAuthViewModel
+ Data = fidoOptions.ToJson()
+ };
+ HttpContext.Session.SetString("FIDO", fidoOptions.ToJson());
+ }
+
+ var r = await lnurlAuthService.RequestLogin(user.Id);
+ if (r is not null)
+ {
+ vm.LoginWithLNURLAuthViewModel = new LoginWithLNURLAuthViewModel
{
- RememberMe = rememberMe,
- UserId = user.Id,
LNURLEndpoint = new Uri(callbackGenerator.ForLNUrlAuth(user, r))
};
}
- return null;
+
+ if (await userManager.IsAuthenticatorConfigured(user))
+ {
+ vm.LoginWithAuthenticator = new LoginWithAuthenticatorModel();
+ }
+ return View("SecondaryLogin", vm);
}
[HttpPost("/login/lnurlauth")]
[AllowAnonymous]
[ValidateAntiForgeryToken]
- public async Task<IActionResult> LoginWithLNURLAuth(LoginWithLNURLAuthViewModel viewModel, string returnUrl = null)
+ public async Task<IActionResult> LoginWithLNURLAuth(LoginWithLNURLAuthViewModel viewModel)
{
- if (!CanLoginOrRegister())
+ var user = await signInManager.GetTwoFactorAuthenticationUserAsync();
+ if (!CanLoginOrRegister() || user is null)
{
return RedirectToAction("Login");
}
+ if (user.IsDisabledTemporarily)
+ return LockoutView(user);
+ var session = LoginSession.Load(HttpContext.Session);
- ViewData["ReturnUrl"] = returnUrl;
var errorMessage = StringLocalizer["Invalid login attempt."].Value;
- var user = await userManager.FindByIdAsync(viewModel.UserId);
- var loggingContext = CreateLoginContext(user);
- if (!await userService.CanLogin(loggingContext))
- {
- TempData.SetStatusLoginResult(loggingContext);
- return RedirectToAction("Login");
- }
-
try
{
var k1 = Encoders.Hex.DecodeData(viewModel.LNURLEndpoint.ParseQueryString().Get("k1"));
- if (lnurlAuthService.FinalLoginStore.TryRemove(viewModel.UserId, out var storedk1) &&
+ if (lnurlAuthService.FinalLoginStore.TryRemove(user.Id, out var storedk1) &&
storedk1.SequenceEqual(k1))
{
- lnurlAuthService.FinalLoginStore.TryRemove(viewModel.UserId, out _);
- await signInManager.SignInAsync(user!, viewModel.RememberMe, "FIDO2");
- _logger.LogInformation("User logged in");
- return RedirectToLocal(returnUrl);
+ lnurlAuthService.FinalLoginStore.TryRemove(user.Id, out _);
+ return await RedirectLoginSuccess(user, session);
}
}
catch (Exception e)
@@ -287,105 +359,101 @@ namespace BTCPayServer.Controllers
if (!string.IsNullOrEmpty(errorMessage))
{
- ModelState.AddModelError(string.Empty, errorMessage);
+ TempData.SetStatusMessageModel(new StatusMessageModel
+ {
+ Severity = StatusMessageModel.StatusSeverity.Error,
+ Message = errorMessage
+ });
}
- return View("SecondaryLogin", new SecondaryLoginViewModel
- {
- LoginWithFido2ViewModel = await fido2Service.HasCredentials(user!.Id) ? await BuildFido2ViewModel(viewModel.RememberMe, user) : null,
- LoginWithLNURLAuthViewModel = viewModel,
- LoginWith2FaViewModel = !user.TwoFactorEnabled
- ? null
- : new LoginWith2faViewModel
- {
- RememberMe = viewModel.RememberMe
- }
- });
+ return RedirectToSecondaryLogin();
+ }
+
+ [HttpPost("/login/passkey/options")]
+ [AllowAnonymous]
+ public async Task<IActionResult> GetPasskeyOptions()
+ {
+ if (!CanLoginOrRegister())
+ return BadRequest("Insecure connection");
+ if (!btcPayServerEnvironment.IsSecure(HttpContext))
+ return BadRequest("WebAuthn requires a secure connection");
+ var o = await fido2Service.RequestLogin(null);
+ if (o is null)
+ return BadRequest("Passkey is not supported");
+ HttpContext.Session.SetString("PASSKEY", o.ToJson());
+ return Ok(o.ToJson());
+ }
+
+ AssertionOptions GetAssertionOptions(string key)
+ {
+ var result = HttpContext.Session.GetString(key);
+ return result is null ? null : AssertionOptions.FromJson(result);
}
[HttpPost("/login/fido2")]
[AllowAnonymous]
[ValidateAntiForgeryToken]
- public async Task<IActionResult> LoginWithFido2(LoginWithFido2ViewModel viewModel, string returnUrl = null)
+ public async Task<IActionResult> LoginWithFido2(LoginWithFido2ViewModel viewModel)
{
- if (!CanLoginOrRegister())
+ var user = await signInManager.GetTwoFactorAuthenticationUserAsync();
+ var assertionOptions = user is null ? null : GetAssertionOptions("FIDO");
+ if (!CanLoginOrRegister() || assertionOptions is null)
{
return RedirectToAction("Login");
}
+ if (user.IsDisabledTemporarily)
+ return LockoutView(user);
- ViewData["ReturnUrl"] = returnUrl;
- var errorMessage = "Invalid login attempt.";
- var user = await userManager.FindByIdAsync(viewModel.UserId);
- var loginContext = CreateLoginContext(user);
- if (!await userService.CanLogin(loginContext))
- {
- TempData.SetStatusLoginResult(loginContext);
- return RedirectToAction("Login");
- }
+ var session = LoginSession.Load(HttpContext.Session);
- try
+
+ var errorMessage = "Invalid login attempt.";
+ var loginResult = await fido2Service.CompleteLogin(user.Id, viewModel.Response, assertionOptions, false);
+ if (loginResult is Fido2Service.LoginResult.Success)
{
- if (await fido2Service.CompleteLogin(viewModel.UserId, System.Text.Json.JsonSerializer.Deserialize<AuthenticatorAssertionRawResponse>(viewModel.Response)))
- {
- await signInManager.SignInAsync(user!, viewModel.RememberMe, "FIDO2");
- _logger.LogInformation("User {Email} logged in with FIDO2", user.Email);
- return RedirectToLocal(returnUrl);
- }
+ HttpContext.Session.Remove("FIDO");
+ return await RedirectLoginSuccess(user, session);
}
- catch (Fido2VerificationException e)
+ else if (loginResult is Fido2Service.LoginResult.Failed e)
{
- errorMessage = e.Message;
+ errorMessage = e.Reason;
}
if (!string.IsNullOrEmpty(errorMessage))
{
- ModelState.AddModelError(string.Empty, errorMessage);
+ TempData.SetStatusMessageModel(new StatusMessageModel
+ {
+ Severity = StatusMessageModel.StatusSeverity.Error,
+ Message = errorMessage
+ });
}
+
viewModel.Response = null;
- return View("SecondaryLogin", new SecondaryLoginViewModel
- {
- LoginWithFido2ViewModel = viewModel,
- LoginWithLNURLAuthViewModel = await lnurlAuthService.HasCredentials(user!.Id) ? await BuildLNURLAuthViewModel(viewModel.RememberMe, user) : null,
- LoginWith2FaViewModel = !user.TwoFactorEnabled
- ? null
- : new LoginWith2faViewModel
- {
- RememberMe = viewModel.RememberMe
- }
- });
+ return RedirectToSecondaryLogin();
}
- [HttpGet("/login/2fa")]
+ [HttpGet("/login/authenticator")]
[AllowAnonymous]
- public async Task<IActionResult> LoginWith2fa(bool rememberMe, string returnUrl = null)
+ public async Task<IActionResult> LoginWithAuthenticator()
{
if (!CanLoginOrRegister())
{
return RedirectToAction("Login");
}
- // Ensure the user has gone through the username & password screen first
var user = await signInManager.GetTwoFactorAuthenticationUserAsync();
if (user == null)
- {
- throw new ApplicationException($"Unable to load two-factor authentication user.");
- }
-
- ViewData["ReturnUrl"] = returnUrl;
+ return Forbid();
- return View("SecondaryLogin", new SecondaryLoginViewModel
- {
- LoginWith2FaViewModel = new LoginWith2faViewModel { RememberMe = rememberMe },
- LoginWithFido2ViewModel = await fido2Service.HasCredentials(user.Id) ? await BuildFido2ViewModel(rememberMe, user) : null,
- LoginWithLNURLAuthViewModel = await lnurlAuthService.HasCredentials(user.Id) ? await BuildLNURLAuthViewModel(rememberMe, user) : null,
- });
+ return RedirectToSecondaryLogin();
}
- [HttpPost("/login/2fa")]
+ [HttpPost("/login/authenticator")]
[AllowAnonymous]
[ValidateAntiForgeryToken]
- public async Task<IActionResult> LoginWith2fa(LoginWith2faViewModel model, bool rememberMe, string returnUrl = null)
+ public async Task<IActionResult> LoginWithAuthenticator(LoginWithAuthenticatorModel model)
{
- if (!CanLoginOrRegister())
+ var user = await signInManager.GetTwoFactorAuthenticationUserAsync();
+ if (!CanLoginOrRegister() || user is null)
{
return RedirectToAction("Login");
}
@@ -394,60 +462,73 @@ namespace BTCPayServer.Controllers
{
return View(model);
}
+ if (user.IsDisabledTemporarily)
+ return LockoutView(user);
- var user = await signInManager.GetTwoFactorAuthenticationUserAsync();
- var loginContext = CreateLoginContext(user);
- if (!await userService.CanLogin(loginContext))
- {
- TempData.SetStatusLoginResult(loginContext);
- return View(model);
- }
+ var session = LoginSession.Load(HttpContext.Session);
- var authenticatorCode = model.TwoFactorCode.Replace(" ", string.Empty, StringComparison.InvariantCulture).Replace("-", string.Empty, StringComparison.InvariantCulture);
- var result = await signInManager.TwoFactorAuthenticatorSignInAsync(authenticatorCode, rememberMe, model.RememberMachine);
- if (result.Succeeded)
+ var authenticatorCode = model.TwoFactorCode.Replace(" ", string.Empty, StringComparison.InvariantCulture)
+ .Replace("-", string.Empty, StringComparison.InvariantCulture);
+ // TwoFactorAuthenticatorSignInAsync
+ //signInManager.TwoFactorAuthenticatorSignInAsync()
+ var success = await userManager.VerifyTwoFactorTokenAsync(user, signInManager.Options.Tokens.AuthenticatorTokenProvider, authenticatorCode);
+ if (success)
{
- _logger.LogInformation("User {Email} logged in with 2FA", user.Email);
- return RedirectToLocal(returnUrl);
+ return await RedirectLoginSuccess(user, session);
}
- _logger.LogWarning("User {Email} entered invalid authenticator code", user.Email);
- ModelState.AddModelError(string.Empty, "Invalid authenticator code.");
- return View("SecondaryLogin", new SecondaryLoginViewModel
+ await userManager.AccessFailedAsync(user);
+ TempData.SetStatusMessageModel(new StatusMessageModel
{
- LoginWith2FaViewModel = model,
- LoginWithFido2ViewModel = await fido2Service.HasCredentials(user.Id) ? await BuildFido2ViewModel(rememberMe, user) : null,
- LoginWithLNURLAuthViewModel = await lnurlAuthService.HasCredentials(user.Id) ? await BuildLNURLAuthViewModel(rememberMe, user) : null,
+ Severity = StatusMessageModel.StatusSeverity.Error,
+ Message = StringLocalizer["Invalid authenticator code."]
});
+
+ return RedirectToSecondaryLogin();
}
- [HttpGet("/login/recovery-code")]
- [AllowAnonymous]
- public async Task<IActionResult> LoginWithRecoveryCode(string returnUrl = null)
+ private async Task<IActionResult> RedirectLoginSuccess(ApplicationUser user, LoginSession session)
{
- if (!CanLoginOrRegister())
+ await this.HttpContext.SignOutAsync(IdentityConstants.TwoFactorUserIdScheme);
+ session.Clear(HttpContext.Session);
+
+ var loginContext = this.CreateLoginContext(user);
+ if (!await userService.CanLogin(loginContext))
{
- return RedirectToAction("Login");
- }
+ signInManager.AuthenticationScheme = AuthenticationSchemes.LimitedLogin;
+ await signInManager.SignInAsync(user, session.ToAuthenticationProperties(true), session.AuthenticationMethod);
- // Ensure the user has gone through the username & password screen first
- var user = await signInManager.GetTwoFactorAuthenticationUserAsync();
- if (user == null)
+ if (loginContext.FailedRedirectUrl is { } url)
+ return Redirect(url);
+
+ TempData.SetStatusLoginResult(loginContext);
+ return RedirectToAction(nameof(Login), new { returnUrl = session.ReturnUrl });
+ }
+ else
{
- throw new ApplicationException($"Unable to load two-factor authentication user.");
+ await signInManager.SignInAsync(user, session.ToAuthenticationProperties(false), session.AuthenticationMethod);
}
+ await userManager.ResetAccessFailedCountAsync(user);
+ return RedirectToLocal(session.ReturnUrl);
+ }
- ViewData["ReturnUrl"] = returnUrl;
-
+ [HttpGet("/login/recovery-code")]
+ [AllowAnonymous]
+ public IActionResult LoginWithRecoveryCode()
+ {
+ if (!CanLoginOrRegister())
+ return RedirectToAction("Login");
return View();
}
[HttpPost("/login/recovery-code")]
[AllowAnonymous]
[ValidateAntiForgeryToken]
- public async Task<IActionResult> LoginWithRecoveryCode(LoginWithRecoveryCodeViewModel model, string returnUrl = null)
+ public async Task<IActionResult> LoginWithRecoveryCode(LoginWithRecoveryCodeViewModel model)
{
- if (!CanLoginOrRegister())
+ var user = await signInManager.GetTwoFactorAuthenticationUserAsync();
+ var session = UIAccountController.LoginSession.Load(HttpContext.Session);
+ if (!CanLoginOrRegister() || user is null || session is null)
{
return RedirectToAction("Login");
}
@@ -457,39 +538,21 @@ namespace BTCPayServer.Controllers
return View(model);
}
- var user = await signInManager.GetTwoFactorAuthenticationUserAsync();
- var loginContext = CreateLoginContext(user);
- if (!await userService.CanLogin(loginContext))
- {
- TempData.SetStatusLoginResult(loginContext);
- return View(model);
- }
+ if (user.IsDisabledTemporarily)
+ return LockoutView(user);
- var recoveryCode = model.RecoveryCode.Replace(" ", string.Empty, StringComparison.InvariantCulture);
- var result = await signInManager.TwoFactorRecoveryCodeSignInAsync(recoveryCode);
+ var recoveryCode = model.RecoveryCode.Trim().Split(' ').FirstOrDefault() ?? "";
+ var result = await userManager.RedeemTwoFactorRecoveryCodeAsync(user, recoveryCode);
if (result.Succeeded)
{
- _logger.LogInformation("User {Email} logged in with a recovery code", user.Email);
- return RedirectToLocal(returnUrl);
- }
- if (result.IsLockedOut)
- {
- _logger.LogWarning("User {Email} account locked out", user.Email);
-
- return RedirectToAction(nameof(Lockout), new { user.LockoutEnd });
+ return await RedirectLoginSuccess(user, session);
}
-
- _logger.LogWarning("User {Email} entered invalid recovery code", user.Email);
- ModelState.AddModelError(string.Empty, "Invalid recovery code entered.");
- return View();
+ await userManager.AccessFailedAsync(user);
+ ModelState.AddModelError(nameof(model.RecoveryCode), "Invalid recovery code entered.");
+ return View(model);
}
- [HttpGet("/login/lockout")]
- [AllowAnonymous]
- public IActionResult Lockout(DateTimeOffset? lockoutEnd)
- {
- return View(lockoutEnd);
- }
+ private IActionResult LockoutView(ApplicationUser user) => View("Lockout", user);
[HttpGet("/register")]
[AllowAnonymous]
@@ -499,6 +562,7 @@ namespace BTCPayServer.Controllers
{
SetInsecureFlags();
}
+
if (PoliciesSettings.LockSubscription && !User.IsInRole(Roles.ServerAdmin))
return RedirectToAction(nameof(UIHomeController.Index), "UIHome");
@@ -514,7 +578,7 @@ namespace BTCPayServer.Controllers
[HttpPost("/register")]
[AllowAnonymous]
[ValidateAntiForgeryToken]
- public async Task<IActionResult> Register(RegisterViewModel model, string returnUrl = null, bool logon = true)
+ public async Task<IActionResult> Register(RegisterViewModel model, string returnUrl = null)
{
if (!CanLoginOrRegister())
return RedirectToAction(nameof(Register));
@@ -522,7 +586,6 @@ namespace BTCPayServer.Controllers
if (r is not ViewResult)
return r;
- ViewData["Logon"] = logon.ToString(CultureInfo.InvariantCulture).ToLowerInvariant();
var policies = await settingsRepository.GetSettingAsync<PoliciesSettings>() ?? new PoliciesSettings();
if (ModelState.IsValid)
{
@@ -556,26 +619,10 @@ namespace BTCPayServer.Controllers
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Account created."].Value;
- var ctx = CreateLoginContext(user);
- if (!await userService.CanLogin(ctx))
- {
- if (ctx.FailedRedirectUrl is { } url)
- {
- return Redirect(url);
- }
- else
- {
- TempData.SetStatusLoginResult(ctx);
- return RedirectToAction(nameof(Login));
- }
- }
-
- if (logon)
+ return await RedirectLoginSuccess(user, new UIAccountController.LoginSession()
{
- await signInManager.SignInAsync(user, isPersistent: false);
- _logger.LogInformation("User {Email} logged in", user.Email);
- return RedirectToLocal(returnUrl);
- }
+ ReturnUrl = returnUrl,
+ });
}
else
{
@@ -607,6 +654,7 @@ namespace BTCPayServer.Controllers
{
return RedirectToAction(nameof(UIHomeController.Index), "UIHome");
}
+
var user = await userManager.FindByIdAsync(userId);
if (user == null)
return NotFound();
@@ -662,6 +710,7 @@ namespace BTCPayServer.Controllers
{
return RedirectToAction(nameof(ForgotPasswordConfirmation));
}
+
var callbackUri = await callbackGenerator.ForPasswordReset(user);
eventAggregator.Publish(new UserEvent.PasswordResetRequested(user, callbackUri));
return RedirectToAction(nameof(ForgotPasswordConfirmation));
@@ -748,6 +797,7 @@ namespace BTCPayServer.Controllers
return RedirectToLocal(returnUrl);
}
}
+
return RedirectToAction(nameof(Login));
}
@@ -777,6 +827,7 @@ namespace BTCPayServer.Controllers
{
return await RedirectToConfirmEmail(user);
}
+
if (requiresSetPassword)
{
TempData.SetStatusMessageModel(new StatusMessageModel
diff --git a/BTCPayServer/Controllers/UILNURLAuthController.cs b/BTCPayServer/Controllers/UILNURLAuthController.cs
index c9891ab..52be33b 100644
--- a/BTCPayServer/Controllers/UILNURLAuthController.cs
+++ b/BTCPayServer/Controllers/UILNURLAuthController.cs
@@ -4,8 +4,10 @@ using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client;
+using BTCPayServer.Data;
using LNURL;
using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Localization;
@@ -21,13 +23,16 @@ namespace BTCPayServer
{
private readonly LnurlAuthService _lnurlAuthService;
private readonly LinkGenerator _linkGenerator;
+ private readonly SignInManager<ApplicationUser> _signInManager;
public IStringLocalizer StringLocalizer { get; }
public UILNURLAuthController(LnurlAuthService lnurlAuthService,
+ SignInManager<ApplicationUser> signInManager,
IStringLocalizer stringLocalizer, LinkGenerator linkGenerator)
{
_lnurlAuthService = lnurlAuthService;
_linkGenerator = linkGenerator;
+ _signInManager = signInManager;
StringLocalizer = stringLocalizer;
}
@@ -115,9 +120,10 @@ namespace BTCPayServer
[HttpGet("login-check")]
[AllowAnonymous]
- public Task<IActionResult> LoginCheck(string userId)
+ public async Task<IActionResult> LoginCheck()
{
- return _lnurlAuthService.LoginStore.ContainsKey(userId) ? Task.FromResult<IActionResult>(Ok()) : Task.FromResult<IActionResult>(NotFound());
+ var user = await _signInManager.GetTwoFactorAuthenticationUserAsync();
+ return _lnurlAuthService.LoginStore.ContainsKey(user?.Id ?? "") ? Ok() : NotFound();
}
[HttpGet("login-callback")]
diff --git a/BTCPayServer/Controllers/UIManageController.2FA.cs b/BTCPayServer/Controllers/UIManageController.2FA.cs
deleted file mode 100644
index 0ff5fe5..0000000
--- a/BTCPayServer/Controllers/UIManageController.2FA.cs
+++ /dev/null
@@ -1,197 +0,0 @@
-using System;
-using System.Globalization;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-using BTCPayServer.Abstractions.Models;
-using BTCPayServer.Data;
-using BTCPayServer.Models.ManageViewModels;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.Extensions.Logging;
-
-namespace BTCPayServer.Controllers
-{
- public partial class UIManageController
- {
- private const string RecoveryCodesKey = nameof(RecoveryCodesKey);
- private const string AuthenicatorUriFormat = "otpauth://totp/{0}:{1}?secret={2}&issuer={0}&digits=6";
-
- [HttpGet]
- public async Task<IActionResult> TwoFactorAuthentication()
- {
- var user = await _userManager.GetUserAsync(User);
- if (user == null)
- return NotFound();
-
- var model = new TwoFactorAuthenticationViewModel
- {
- Is2faEnabled = user.TwoFactorEnabled,
- RecoveryCodesLeft = await _userManager.CountRecoveryCodesAsync(user),
- Credentials = await _fido2Service.GetCredentials(User.GetId())
- };
-
- return View(model);
- }
-
- public async Task<IActionResult> Disable2fa()
- {
- var user = await _userManager.GetUserAsync(User);
- if (user == null)
- return NotFound();
-
- var disable2faResult = await _userManager.SetTwoFactorEnabledAsync(user, false);
- if (!disable2faResult.Succeeded)
- {
- throw new ApplicationException(
- $"Unexpected error occurred disabling 2FA for user with ID '{user.Id}'.");
- }
-
- _logger.LogInformation("User {Email} has disabled 2fa", user.Email);
- return RedirectToAction(nameof(TwoFactorAuthentication));
- }
-
- [HttpGet]
- public async Task<IActionResult> EnableAuthenticator()
- {
- var user = await _userManager.GetUserAsync(User);
- if (user == null)
- return NotFound();
-
- var model = new EnableAuthenticatorViewModel();
- await LoadSharedKeyAndQrCodeUriAsync(user, model);
-
- return View(model);
- }
-
- [HttpPost]
- [ValidateAntiForgeryToken]
- public async Task<IActionResult> EnableAuthenticator(EnableAuthenticatorViewModel model)
- {
- var user = await _userManager.GetUserAsync(User);
- if (user == null)
- return NotFound();
-
- if (!ModelState.IsValid)
- {
- await LoadSharedKeyAndQrCodeUriAsync(user, model);
- return View(model);
- }
-
- // Strip spaces and hypens
- var verificationCode = model.Code.Replace(" ", string.Empty, StringComparison.OrdinalIgnoreCase)
- .Replace("-", string.Empty, StringComparison.OrdinalIgnoreCase);
-
- var is2faTokenValid = await _userManager.VerifyTwoFactorTokenAsync(
- user, _userManager.Options.Tokens.AuthenticatorTokenProvider, verificationCode);
-
- if (!is2faTokenValid)
- {
- ModelState.AddModelError("Code", "Verification code is invalid.");
- await LoadSharedKeyAndQrCodeUriAsync(user, model);
- return View(model);
- }
-
- await _userManager.SetTwoFactorEnabledAsync(user, true);
- var recoveryCodes = await _userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10);
- TempData[RecoveryCodesKey] = recoveryCodes.ToArray();
-
- return RedirectToAction(nameof(GenerateRecoveryCodes));
- }
-
- [HttpPost]
- public async Task<IActionResult> ResetAuthenticator()
- {
- var user = await _userManager.GetUserAsync(User);
- if (user == null)
- return NotFound();
-
- await _userManager.SetTwoFactorEnabledAsync(user, false);
- await _userManager.ResetAuthenticatorKeyAsync(user);
- return RedirectToAction(nameof(EnableAuthenticator));
- }
-
- [HttpPost]
- [ActionName(nameof(GenerateRecoveryCodes))]
- public async Task<IActionResult> GenerateRecoveryCodesPost()
- {
- var user = await _userManager.GetUserAsync(User);
- if (user is null)
- return NotFound();
- var recoveryCodes = await _userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10);
- TempData[RecoveryCodesKey] = recoveryCodes.ToArray();
- return RedirectToAction(nameof(GenerateRecoveryCodes));
- }
-
- [HttpGet]
- public IActionResult GenerateRecoveryCodes()
- {
- if (TempData[RecoveryCodesKey] is string[] recoveryCodes)
- {
- var model = new GenerateRecoveryCodesViewModel { RecoveryCodes = recoveryCodes };
- return View(model);
- }
-
- return View("Confirm", new ConfirmModel(
- title: StringLocalizer["Generate new recovery codes"],
- desc: StringLocalizer["This action will generate new recovery codes for your account. Please confirm to proceed."],
- action: StringLocalizer["Generate"],
- buttonClass: "btn-primary"
- ));
- }
-
- private string GenerateQrCodeUri(string email, string unformattedKey)
- {
- return string.Format(CultureInfo.InvariantCulture,
- AuthenicatorUriFormat,
- _urlEncoder.Encode("BTCPayServer"),
- _urlEncoder.Encode(email),
- unformattedKey);
- }
-
- private string FormatKey(string unformattedKey)
- {
- var result = new StringBuilder();
- int currentPosition = 0;
- while (currentPosition + 4 < unformattedKey.Length)
- {
- result.Append(unformattedKey.Substring(currentPosition, 4)).Append(' ');
- currentPosition += 4;
- }
-
- if (currentPosition < unformattedKey.Length)
- {
- result.Append(unformattedKey.Substring(currentPosition));
- }
-
- return result.ToString().ToLowerInvariant();
- }
-
-
- private async Task LoadSharedKeyAndQrCodeUriAsync(ApplicationUser user, EnableAuthenticatorViewModel model)
- {
- var unformattedKey = await _userManager.GetAuthenticatorKeyAsync(user);
- if (string.IsNullOrEmpty(unformattedKey))
- {
- await _userManager.ResetAuthenticatorKeyAsync(user);
- unformattedKey = await _userManager.GetAuthenticatorKeyAsync(user);
- }
-
- model.SharedKey = FormatKey(unformattedKey);
- model.AuthenticatorUri = GenerateQrCodeUri(user.Email, unformattedKey);
- }
-
- [HttpPost]
- public IActionResult CreateCredential(string name, Fido2Credential.CredentialType type)
- {
- switch (type)
- {
- case Fido2Credential.CredentialType.FIDO2:
- return RedirectToAction("Create", "UIFido2", new { name });
- case Fido2Credential.CredentialType.LNURLAuth:
- return RedirectToAction("Create", "UILNURLAuth", new { name });
- default:
- throw new ArgumentOutOfRangeException(nameof(type), type, null);
- }
- }
- }
-}
diff --git a/BTCPayServer/Controllers/UIManageController.Authenticator.cs b/BTCPayServer/Controllers/UIManageController.Authenticator.cs
new file mode 100644
index 0000000..1968c88
--- /dev/null
+++ b/BTCPayServer/Controllers/UIManageController.Authenticator.cs
@@ -0,0 +1,168 @@
+using System;
+using System.Globalization;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using BTCPayServer.Abstractions.Extensions;
+using BTCPayServer.Abstractions.Models;
+using BTCPayServer.Data;
+using BTCPayServer.Models.ManageViewModels;
+using Microsoft.AspNetCore.Mvc;
+
+namespace BTCPayServer.Controllers
+{
+ public partial class UIManageController
+ {
+ private const string RecoveryCodesKey = nameof(RecoveryCodesKey);
+ private const string AuthenicatorUriFormat = "otpauth://totp/{0}:{1}?secret={2}&issuer={0}&digits=6";
+
+ [HttpGet]
+ public async Task<IActionResult> EnableAuthenticator()
+ {
+ var user = await _userManager.GetUserAsync(User);
+ if (user == null)
+ return NotFound();
+
+ var model = new EnableAuthenticatorViewModel();
+ await LoadSharedKeyAndQrCodeUriAsync(user, model);
+ return View(model);
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task<IActionResult> EnableAuthenticator(EnableAuthenticatorViewModel model)
+ {
+ var user = await _userManager.GetUserAsync(User);
+ if (user == null)
+ return NotFound();
+
+ if (!ModelState.IsValid)
+ {
+ await LoadSharedKeyAndQrCodeUriAsync(user, model);
+ return View(model);
+ }
+
+ // Strip spaces and hypens
+ var verificationCode = model.Code.Replace(" ", string.Empty, StringComparison.OrdinalIgnoreCase)
+ .Replace("-", string.Empty, StringComparison.OrdinalIgnoreCase);
+
+ var is2faTokenValid = await _userManager.VerifyTwoFactorTokenAsync(
+ user, _userManager.Options.Tokens.AuthenticatorTokenProvider, verificationCode);
+
+ if (!is2faTokenValid)
+ {
+ ModelState.AddModelError("Code", "Verification code is invalid.");
+ await LoadSharedKeyAndQrCodeUriAsync(user, model);
+ return View(model);
+ }
+
+ user.AuthenticatorEnabled = true;
+ await _userManager.UpdateAsync(user);
+
+ TempData.SetStatusSuccess(StringLocalizer["Authenticator enabled successfully."]);
+ var recoveryCodes = await _userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10);
+ TempData[RecoveryCodesKey] = recoveryCodes.ToArray();
+
+ return RedirectToAction(nameof(GenerateRecoveryCodes));
+ }
+
+ [HttpPost]
+ public async Task<IActionResult> ResetAuthenticator()
+ {
+ var user = await _userManager.GetUserAsync(User);
+ if (user == null)
+ return NotFound();
+ await _userManager.ResetAuthenticatorKeyAsync(user);
+ user.AuthenticatorEnabled = false;
+ await _userManager.UpdateAsync(user);
+ TempData.SetStatusSuccess(StringLocalizer["Authenticator disabled successfully."]);
+ return RedirectToAction(nameof(EnableAuthenticator));
+ }
+
+ [HttpPost]
+ [ActionName(nameof(GenerateRecoveryCodes))]
+ public async Task<IActionResult> GenerateRecoveryCodesPost()
+ {
+ var user = await _userManager.GetUserAsync(User);
+ if (user is null)
+ return NotFound();
+ var recoveryCodes = await _userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10);
+ TempData[RecoveryCodesKey] = recoveryCodes.ToArray();
+ return RedirectToAction(nameof(GenerateRecoveryCodes));
+ }
+
+ [HttpGet]
+ public IActionResult GenerateRecoveryCodes()
+ {
+ if (TempData[RecoveryCodesKey] is string[] recoveryCodes)
+ {
+ var model = new GenerateRecoveryCodesViewModel { RecoveryCodes = recoveryCodes };
+ return View(model);
+ }
+
+ return View("Confirm", new ConfirmModel(
+ title: StringLocalizer["Generate new recovery codes"],
+ desc: StringLocalizer["This action will generate new recovery codes for your account. Please confirm to proceed."],
+ action: StringLocalizer["Generate"],
+ buttonClass: "btn-primary"
+ ));
+ }
+
+ private string GenerateQrCodeUri(string email, string unformattedKey)
+ {
+ return string.Format(CultureInfo.InvariantCulture,
+ AuthenicatorUriFormat,
+ _urlEncoder.Encode("BTCPayServer"),
+ _urlEncoder.Encode(email),
+ unformattedKey);
+ }
+
+ private string FormatKey(string unformattedKey)
+ {
+ var result = new StringBuilder();
+ int currentPosition = 0;
+ while (currentPosition + 4 < unformattedKey.Length)
+ {
+ result.Append(unformattedKey.Substring(currentPosition, 4)).Append(' ');
+ currentPosition += 4;
+ }
+
+ if (currentPosition < unformattedKey.Length)
+ {
+ result.Append(unformattedKey.Substring(currentPosition));
+ }
+
+ return result.ToString().ToLowerInvariant();
+ }
+
+
+ private async Task LoadSharedKeyAndQrCodeUriAsync(ApplicationUser user, EnableAuthenticatorViewModel model)
+ {
+ var unformattedKey = await _userManager.GetAuthenticatorKeyAsync(user);
+ if (string.IsNullOrEmpty(unformattedKey))
+ {
+ await _userManager.ResetAuthenticatorKeyAsync(user);
+ unformattedKey = await _userManager.GetAuthenticatorKeyAsync(user);
+ }
+
+ model.SharedKey = FormatKey(unformattedKey);
+ model.AuthenticatorUri = GenerateQrCodeUri(user.Email, unformattedKey);
+ }
+
+ [HttpPost]
+ public IActionResult CreateCredential(string name, Fido2Credential.CredentialType type)
+ {
+ switch (type)
+ {
+ case Fido2Credential.CredentialType.FIDO2:
+ return RedirectToAction("Create", "UIFido2", new { name });
+ case Fido2Credential.CredentialType.Passkey:
+ return RedirectToAction("Create", "UIFido2", new { name, isPasskey = true });
+ case Fido2Credential.CredentialType.LNURLAuth:
+ return RedirectToAction("Create", "UILNURLAuth", new { name });
+ default:
+ throw new ArgumentOutOfRangeException(nameof(type), type, null);
+ }
+ }
+ }
+}
diff --git a/BTCPayServer/Controllers/UIManageController.cs b/BTCPayServer/Controllers/UIManageController.cs
index 6c0b255..4035fa0 100644
--- a/BTCPayServer/Controllers/UIManageController.cs
+++ b/BTCPayServer/Controllers/UIManageController.cs
@@ -1,4 +1,5 @@
using System;
+using System.Linq;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
@@ -99,6 +100,34 @@ namespace BTCPayServer.Controllers
return View(model);
}
+ [HttpGet]
+ public async Task<IActionResult> TwoFactorAuthentication()
+ {
+ var user = await _userManager.GetUserAsync(User);
+ if (user == null)
+ return NotFound();
+
+ var model = new TwoFactorAuthenticationViewModel
+ {
+ IsAuthenticatorEnabled = await _userManager.IsAuthenticatorConfigured(user),
+ RecoveryCodesLeft = await _userManager.CountRecoveryCodesAsync(user),
+ Credentials = await _fido2Service.GetCredentials(user.Id)
+ };
+
+ return View(model);
+ }
+
+ [HttpGet]
+ public async Task<IActionResult> Passkeys()
+ {
+ var user = await _userManager.GetUserAsync(User);
+ if (user == null)
+ return NotFound();
+
+ var credentials = await _fido2Service.GetCredentials(user.Id);
+ return View(credentials.Where(c => c.Type == Fido2Credential.CredentialType.Passkey).ToList());
+ }
+
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DisableShowInvoiceStatusChangeHint()
diff --git a/BTCPayServer/Fido2/Fido2Service.cs b/BTCPayServer/Fido2/Fido2Service.cs
index fafad5c..226d0c3 100644
--- a/BTCPayServer/Fido2/Fido2Service.cs
+++ b/BTCPayServer/Fido2/Fido2Service.cs
@@ -1,5 +1,5 @@
+#nullable enable
using System;
-using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Text;
@@ -11,16 +11,11 @@ using ExchangeSharp;
using Fido2NetLib;
using Fido2NetLib.Objects;
using Microsoft.EntityFrameworkCore;
-using NBitcoin;
namespace BTCPayServer.Fido2
{
public class Fido2Service
{
- private static readonly ConcurrentDictionary<string, CredentialCreateOptions> CreationStore =
- new ConcurrentDictionary<string, CredentialCreateOptions>();
- private static readonly ConcurrentDictionary<string, AssertionOptions> LoginStore =
- new ConcurrentDictionary<string, AssertionOptions>();
private readonly ApplicationDbContextFactory _contextFactory;
private readonly IFido2 _fido2;
private readonly Fido2Configuration _fido2Configuration;
@@ -32,7 +27,7 @@ namespace BTCPayServer.Fido2
_fido2Configuration = fido2Configuration;
}
- public async Task<CredentialCreateOptions> RequestCreation(string userId)
+ public async Task<CredentialCreateOptions?> RequestCreation(string userId, Fido2Credential.CredentialType credType)
{
await using var dbContext = _contextFactory.CreateContext();
var user = await dbContext.Users.Include(applicationUser => applicationUser.Fido2Credentials)
@@ -45,76 +40,70 @@ namespace BTCPayServer.Fido2
// 2. Get user existing keys by username
var existingKeys =
user.Fido2Credentials
- .Where(credential => credential.Type == Fido2Credential.CredentialType.FIDO2)
+ .Where(credential => credential.Type == credType)
.Select(c => c.GetFido2Blob().Descriptor).ToList();
// 3. Create options
var authenticatorSelection = new AuthenticatorSelection
{
- UserVerification = UserVerificationRequirement.Preferred,
- ResidentKey = ResidentKeyRequirement.Preferred
+ UserVerification = credType is Fido2Credential.CredentialType.Passkey
+ ? UserVerificationRequirement.Required
+ : UserVerificationRequirement.Discouraged,
+ ResidentKey = credType is Fido2Credential.CredentialType.Passkey ? ResidentKeyRequirement.Required : ResidentKeyRequirement.Discouraged
};
var exts = new AuthenticationExtensionsClientInputs()
{
Extensions = true,
- UserVerificationMethod = true
+ UserVerificationMethod = credType is Fido2Credential.CredentialType.Passkey,
};
var options = _fido2.RequestNewCredential(new()
- {
- User = new Fido2User() { DisplayName = user.UserName, Name = user.UserName, Id = user.Id.ToBytesUTF8() },
- ExcludeCredentials = existingKeys,
- AuthenticatorSelection = authenticatorSelection,
- AttestationPreference = AttestationConveyancePreference.None,
- Extensions = exts
- });
+ {
+ User = new Fido2User() { DisplayName = user.UserName, Name = user.UserName, Id = user.Id.ToBytesUTF8() },
+ ExcludeCredentials = existingKeys,
+ AuthenticatorSelection = authenticatorSelection,
+ AttestationPreference = AttestationConveyancePreference.None,
+ Extensions = exts
+ });
// options.Rp = new PublicKeyCredentialRpEntity(Request.Host.Host, options.Rp.Name, "");
- CreationStore.AddOrReplace(userId, options);
return options;
}
- public async Task<bool> CompleteCreation(string userId, string name, string data)
+ public async Task CompleteCreation(string userId, string name, string data, CredentialCreateOptions options, Fido2Credential.CredentialType credType)
{
- try
- {
-
- var attestationResponse = System.Text.Json.JsonSerializer.Deserialize<AuthenticatorAttestationRawResponse>(data);
- await using var dbContext = _contextFactory.CreateContext();
- var user = await dbContext.Users.Include(applicationUser => applicationUser.Fido2Credentials)
- .FirstOrDefaultAsync(applicationUser => applicationUser.Id == userId);
- if (user == null || !CreationStore.TryGetValue(userId, out var options))
- {
- return false;
- }
-
- // 2. Verify and make the credentials
- var success =
- await _fido2.MakeNewCredentialAsync(new() { AttestationResponse = attestationResponse, OriginalOptions = options, IsCredentialIdUniqueToUserCallback = (_, _) => Task.FromResult(true)});
-
- // 3. Store the credentials in db
- var newCredential = new Fido2Credential() { Name = name, ApplicationUserId = userId };
+ var attestationResponse = System.Text.Json.JsonSerializer.Deserialize<AuthenticatorAttestationRawResponse>(data)!;
+ await using var dbContext = _contextFactory.CreateContext();
+ var user = await dbContext.Users.Include(applicationUser => applicationUser.Fido2Credentials)
+ .FirstOrDefaultAsync(applicationUser => applicationUser.Id == userId);
+ if (user == null)
+ throw new InvalidOperationException("Unknown user");
- newCredential.SetBlob(new Fido2CredentialBlob()
+ // 2. Verify and make the credentials
+ var success =
+ await _fido2.MakeNewCredentialAsync(new()
{
- Descriptor = new PublicKeyCredentialDescriptor(success.Id),
- PublicKey = success.PublicKey,
- UserHandle = success.User.Id,
- SignatureCounter = success.SignCount,
- AaGuid = success.AaGuid.ToString(),
+ AttestationResponse = attestationResponse,
+ OriginalOptions = options,
+ IsCredentialIdUniqueToUserCallback = (_, _) => Task.FromResult(
+ user.Fido2Credentials.Where(f => f.Type == credType)
+ .All(f => !f.GetFido2Blob().Descriptor.Id.SequenceEqual(attestationResponse.RawId)))
});
- await dbContext.Fido2Credentials.AddAsync(newCredential);
- await dbContext.SaveChangesAsync();
- CreationStore.Remove(userId, out _);
- return true;
-
+ // 3. Store the credentials in db
+ var newCredential = new Fido2Credential() { Name = name, ApplicationUserId = userId, Type = credType };
- }
- catch (Exception)
+ newCredential.SetBlob(new Fido2CredentialBlob()
{
- return false;
- }
+ Descriptor = new PublicKeyCredentialDescriptor(success.Id),
+ PublicKey = success.PublicKey,
+ UserHandle = success.User.Id,
+ SignatureCounter = success.SignCount,
+ AaGuid = success.AaGuid.ToString(),
+ });
+
+ await dbContext.Fido2Credentials.AddAsync(newCredential);
+ await dbContext.SaveChangesAsync();
}
public async Task<List<Fido2Credential>> GetCredentials(string userId)
@@ -138,25 +127,22 @@ namespace BTCPayServer.Fido2
await context.SaveChangesAsync();
}
- public async Task<bool> HasCredentials(string userId)
- {
- await using var context = _contextFactory.CreateContext();
- return await context.Fido2Credentials.Where(fDevice => fDevice.ApplicationUserId == userId && fDevice.Type == Fido2Credential.CredentialType.FIDO2).AnyAsync();
- }
-
- public async Task<AssertionOptions> RequestLogin(string userId)
+ public async Task<AssertionOptions?> RequestLogin(string? userId)
{
- await using var dbContext = _contextFactory.CreateContext();
- var user = await dbContext.Users.Include(applicationUser => applicationUser.Fido2Credentials)
- .FirstOrDefaultAsync(applicationUser => applicationUser.Id == userId);
- if (!(user?.Fido2Credentials?.Any() is true))
+ List<PublicKeyCredentialDescriptor> existingCredentials = new();
+ if (userId is not null)
{
- return null;
+ await using var dbContext = _contextFactory.CreateContext();
+ var user = await dbContext.Users.Include(applicationUser => applicationUser.Fido2Credentials)
+ .FirstOrDefaultAsync(applicationUser => applicationUser.Id == userId);
+ if (user is not null)
+ existingCredentials.AddRange(user.Fido2Credentials
+ .Where(credential => credential.Type == Fido2Credential.CredentialType.FIDO2)
+ .Select(c => c.GetFido2Blob().Descriptor));
+ if (existingCredentials.Count == 0)
+ return null;
}
- var existingCredentials = user.Fido2Credentials
- .Where(credential => credential.Type == Fido2Credential.CredentialType.FIDO2)
- .Select(c => c.GetFido2Blob().Descriptor)
- .ToList();
+
var exts = new AuthenticationExtensionsClientInputs()
{
UserVerificationMethod = true,
@@ -169,53 +155,84 @@ namespace BTCPayServer.Fido2
new()
{
AllowedCredentials = existingCredentials,
- UserVerification = UserVerificationRequirement.Discouraged,
+ UserVerification = userId is null ? UserVerificationRequirement.Required : UserVerificationRequirement.Discouraged,
Extensions = exts
}
);
- LoginStore.AddOrReplace(userId, options);
return options;
}
- public async Task<bool> CompleteLogin(string userId, AuthenticatorAssertionRawResponse response)
+ public record LoginResult
{
+ public record Failed(string Reason) : LoginResult;
+
+ public record Success(ApplicationUser User) : LoginResult;
+ }
+
+ public async Task<LoginResult> CompleteLogin(string? userId, string responseJson, AssertionOptions options, bool passKey)
+ {
+ AuthenticatorAssertionRawResponse? response = null;
+ try
+ {
+ response = System.Text.Json.JsonSerializer.Deserialize<AuthenticatorAssertionRawResponse>(responseJson);
+ }
+ catch { }
+
+ if (response?.Response is null)
+ return new LoginResult.Failed("Invalid assertion");
+
+ if (userId is null && response.Response.UserHandle is { } handle)
+ userId = UTF8Encoding.UTF8.GetString(handle);
+
+ if (userId is null)
+ return new LoginResult.Failed("User Id not provided");
await using var dbContext = _contextFactory.CreateContext();
var user = await dbContext.Users.AsNoTracking()
.Include(applicationUser => applicationUser.Fido2Credentials)
.FirstOrDefaultAsync(applicationUser => applicationUser.Id == userId);
- if (user == null || !LoginStore.TryGetValue(userId, out var options))
- return false;
+ if (user == null)
+ return new LoginResult.Failed("Unknown user");
+ var credType = passKey ? Fido2Credential.CredentialType.Passkey : Fido2Credential.CredentialType.FIDO2;
var credential = user.Fido2Credentials
- .Where(fido2Credential => fido2Credential.Type is Fido2Credential.CredentialType.FIDO2)
+ .Where(fido2Credential => fido2Credential.Type == credType)
.Select(fido2Credential => (fido2Credential, fido2Credential.GetFido2Blob()))
.FirstOrDefault(fido2Credential => fido2Credential.Item2.Descriptor.Id.SequenceEqual(response.RawId));
if (credential.Item2 is null)
- return false;
+ return new LoginResult.Failed("Unknown credential");
- // 5. Make the assertion
- var res = await _fido2.MakeAssertionAsync(new()
+ try
{
- AssertionResponse = response,
- OriginalOptions = options,
- StoredPublicKey = credential.Item2.PublicKey,
- StoredSignatureCounter = credential.Item2.SignatureCounter,
- IsUserHandleOwnerOfCredentialIdCallback = (a, _) => Task.FromResult(credential.Item1.ApplicationUserId == UTF8Encoding.UTF8.GetString(a.UserHandle))
- });
+ // 5. Make the assertion
+ var res = await _fido2.MakeAssertionAsync(new()
+ {
+ AssertionResponse = response,
+ OriginalOptions = options,
+ StoredPublicKey = credential.Item2.PublicKey,
+ StoredSignatureCounter = credential.Item2.SignatureCounter,
+ IsUserHandleOwnerOfCredentialIdCallback = (a, _) =>
+ Task.FromResult(credential.Item1.ApplicationUserId == UTF8Encoding.UTF8.GetString(a.UserHandle))
+ });
- // 6. Store the updated counter
- await dbContext.Fido2Credentials.GetDbConnection().ExecuteAsync("""
- UPDATE "Fido2Credentials"
- SET "Blob2" = jsonb_set(COALESCE("Blob2", '{}'::jsonb), '{signatureCounter}', to_jsonb(@signatureCounter))
- WHERE "Id" = @id
- """, new
+ // 6. Store the updated counter
+ await dbContext.Fido2Credentials.GetDbConnection().ExecuteAsync("""
+ UPDATE "Fido2Credentials"
+ SET "Blob2" = jsonb_set(COALESCE("Blob2", '{}'::jsonb), '{signatureCounter}', to_jsonb(@signatureCounter)),
+ "LastUsedAt" = @now
+ WHERE "Id" = @id
+ """, new
+ {
+ id = credential.fido2Credential.Id,
+ signatureCounter = (long)res.SignCount,
+ now = DateTimeOffset.UtcNow
+ });
+ }
+ catch (Fido2VerificationException ex)
{
- id = credential.fido2Credential.Id,
- signatureCounter = (long)res.SignCount
- });
- LoginStore.Remove(userId, out _);
+ return new LoginResult.Failed("Invalid assertion: " + ex.Message);
+ }
// 7. return OK to client
- return true;
+ return new LoginResult.Success(user);
}
}
}
diff --git a/BTCPayServer/Fido2/Fido2TokenProvider.cs b/BTCPayServer/Fido2/Fido2TokenProvider.cs
new file mode 100644
index 0000000..1c12b79
--- /dev/null
+++ b/BTCPayServer/Fido2/Fido2TokenProvider.cs
@@ -0,0 +1,22 @@
+using System.Threading.Tasks;
+using BTCPayServer.Data;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.EntityFrameworkCore;
+
+namespace BTCPayServer.Fido2;
+
+public class Fido2TokenProvider(ApplicationDbContextFactory dbContextFactory) : IUserTwoFactorTokenProvider<ApplicationUser>
+{
+ public Task<string> GenerateAsync(string purpose, UserManager<ApplicationUser> manager, ApplicationUser user)
+ => Task.FromResult(string.Empty);
+
+ public Task<bool> ValidateAsync(string purpose, string token, UserManager<ApplicationUser> manager, ApplicationUser user)
+ => Task.FromResult(false);
+
+ public async Task<bool> CanGenerateTwoFactorTokenAsync(UserManager<ApplicationUser> manager, ApplicationUser user)
+ {
+ // Only FIDO2 and LNURLAuth credentials can generate two-factor tokens (Passkey is not second factor)
+ await using var context = dbContextFactory.CreateContext();
+ return await context.Fido2Credentials.AnyAsync(f => f.ApplicationUserId == user.Id && (f.Type == Fido2Credential.CredentialType.FIDO2 || f.Type == Fido2Credential.CredentialType.LNURLAuth));
+ }
+}
diff --git a/BTCPayServer/Fido2/FidoExtensions.cs b/BTCPayServer/Fido2/FidoExtensions.cs
index 9764eb1..e956807 100644
--- a/BTCPayServer/Fido2/FidoExtensions.cs
+++ b/BTCPayServer/Fido2/FidoExtensions.cs
@@ -18,7 +18,6 @@ namespace BTCPayServer.Fido2
var b = System.Text.Json.JsonSerializer.Serialize(descriptor);
if (a == b)
return;
- credential.Type = Fido2Credential.CredentialType.FIDO2;
credential.Blob2 = System.Text.Json.JsonSerializer.Serialize(descriptor);
}
}
diff --git a/BTCPayServer/Fido2/Models/AddFido2CredentialViewModel.cs b/BTCPayServer/Fido2/Models/AddFido2CredentialViewModel.cs
index f512249..90514b0 100644
--- a/BTCPayServer/Fido2/Models/AddFido2CredentialViewModel.cs
+++ b/BTCPayServer/Fido2/Models/AddFido2CredentialViewModel.cs
@@ -6,6 +6,7 @@ namespace BTCPayServer.Fido2.Models
{
public AuthenticatorAttachment? AuthenticatorAttachment { get; set; }
public string Name { get; set; }
+ public bool IsPasskey { get; set; }
}
}
diff --git a/BTCPayServer/Fido2/Models/LoginWithFido2ViewModel.cs b/BTCPayServer/Fido2/Models/LoginWithFido2ViewModel.cs
index a8ea688..b459ebf 100644
--- a/BTCPayServer/Fido2/Models/LoginWithFido2ViewModel.cs
+++ b/BTCPayServer/Fido2/Models/LoginWithFido2ViewModel.cs
@@ -2,9 +2,6 @@ namespace BTCPayServer.Fido2.Models
{
public class LoginWithFido2ViewModel
{
- public string UserId { get; set; }
-
- public bool RememberMe { get; set; }
public string Data { get; set; }
public string Response { get; set; }
}
diff --git a/BTCPayServer/Fido2/UIFido2Controller.cs b/BTCPayServer/Fido2/UIFido2Controller.cs
index 592f3ee..74ad4e8 100644
--- a/BTCPayServer/Fido2/UIFido2Controller.cs
+++ b/BTCPayServer/Fido2/UIFido2Controller.cs
@@ -1,10 +1,14 @@
+using System;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client;
+using BTCPayServer.Data;
using BTCPayServer.Fido2.Models;
+using Fido2NetLib;
using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization;
@@ -19,29 +23,32 @@ namespace BTCPayServer.Fido2
private IStringLocalizer StringLocalizer { get; } = stringLocalizer;
[HttpGet("{id}/delete")]
- public IActionResult Remove(string id)
+ public IActionResult Remove(string id, bool isPasskey = false)
{
- return View("Confirm", new ConfirmModel(StringLocalizer["Remove security device"], StringLocalizer["Your account will no longer have this security device as an option for two-factor authentication."], StringLocalizer["Delete"]));
+ return View("Confirm", new ConfirmModel(
+ isPasskey ? StringLocalizer["Remove passkey"] : StringLocalizer["Remove security device"],
+ isPasskey ? StringLocalizer["Your account will no longer have this passkey as an option for passwordless login."] : StringLocalizer["Your account will no longer have this security device as an option for two-factor authentication."],
+ StringLocalizer["Delete"]));
}
[HttpPost("{id}/delete")]
- public async Task<IActionResult> RemoveP(string id)
+ public async Task<IActionResult> RemoveP(string id, bool isPasskey = false)
{
await fido2Service.Remove(id, User.GetId());
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Success,
- Html = StringLocalizer["The security device was removed successfully."].Value
+ Html = (isPasskey ? StringLocalizer["The passkey was removed successfully."] : StringLocalizer["The security device was removed successfully."]).Value
});
- return RedirectToList();
+ return RedirectToList(isPasskey);
}
[HttpGet("register")]
public async Task<IActionResult> Create(AddFido2CredentialViewModel viewModel)
{
- var options = await fido2Service.RequestCreation(User.GetId());
+ var options = await fido2Service.RequestCreation(User.GetId(), viewModel.IsPasskey ? Fido2Credential.CredentialType.Passkey : Fido2Credential.CredentialType.FIDO2);
if (options is null)
{
TempData.SetStatusMessageModel(new StatusMessageModel
@@ -50,40 +57,48 @@ namespace BTCPayServer.Fido2
Html = StringLocalizer["The security device could not be registered."].Value
});
- return RedirectToList();
+ return RedirectToList(viewModel.IsPasskey);
}
+ HttpContext.Session.SetString("FIDO", options.ToJson());
ViewData["CredentialName"] = viewModel.Name ?? "";
- return View(options);
+ return View((options, viewModel.IsPasskey));
}
[HttpPost("register")]
- public async Task<IActionResult> CreateResponse([FromForm] string data, [FromForm] string name)
+ public async Task<IActionResult> CreateResponse([FromForm] string data, [FromForm] string name, [FromForm] bool isPasskey)
{
- if (await fido2Service.CompleteCreation(User.GetId(), name, data))
+ var options = CredentialCreateOptions.FromJson(HttpContext.Session.GetString("FIDO") ?? "");
+ try
{
-
+ await fido2Service.CompleteCreation(User.GetId(), name, data, options,
+ isPasskey ? Fido2Credential.CredentialType.Passkey : Fido2Credential.CredentialType.FIDO2);
+ HttpContext.Session.Remove("FIDO");
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Success,
Html = StringLocalizer["The security device was registered successfully."].Value
});
}
- else
+ catch (Exception ex)
{
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Error,
- Html = StringLocalizer["The security device could not be registered."].Value
+ Html = ex switch
+ {
+ Fido2VerificationException => StringLocalizer["The security device could not be registered. ({0})", ex.Message].Value,
+ _ => StringLocalizer["An unexpected error occurred while registering the security device."].Value
+ }
});
}
- return RedirectToList();
+ return RedirectToList(isPasskey);
}
- private ActionResult RedirectToList()
+ private ActionResult RedirectToList(bool isPasskey = false)
{
- return RedirectToAction("TwoFactorAuthentication", "UIManage");
+ return RedirectToAction(isPasskey ? "Passkeys" : "TwoFactorAuthentication", "UIManage");
}
}
}
diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs
index fb3fca8..c0796dd 100644
--- a/BTCPayServer/Hosting/BTCPayServerServices.cs
+++ b/BTCPayServer/Hosting/BTCPayServerServices.cs
@@ -918,17 +918,11 @@ namespace BTCPayServer.Hosting
services.AddAuthentication()
.AddCookie(AuthenticationSchemes.LimitedLogin, options =>
{
- options.Cookie.Name = "pwd_verified";
+ options.Cookie.Name = "limited_login";
options.ExpireTimeSpan = TimeSpan.FromMinutes(60); // short-lived
options.SlidingExpiration = false;
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = Microsoft.AspNetCore.Http.CookieSecurePolicy.SameAsRequest;
- options.Events.OnRedirectToLogin = context =>
- {
- context.RedirectUri = QueryHelpers.AddQueryString(context.RedirectUri, [KeyValuePair.Create("allowLimitedLogin", "true")]);
- context.Response.Redirect(context.RedirectUri);
- return Task.CompletedTask;
- };
options.LoginPath = "/login";
options.AccessDeniedPath = "/errors/403";
options.LogoutPath = "/logout";
diff --git a/BTCPayServer/Hosting/Startup.cs b/BTCPayServer/Hosting/Startup.cs
index 4dc25d5..8b506e5 100644
--- a/BTCPayServer/Hosting/Startup.cs
+++ b/BTCPayServer/Hosting/Startup.cs
@@ -104,6 +104,9 @@ namespace BTCPayServer.Hosting
services.AddIdentity<ApplicationUser, IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders()
+ .AddTokenProvider<Fido2TokenProvider>("FIDO2")
+ .AddTokenProvider<DisabledEmailTokenProvider>(TokenOptions.DefaultEmailProvider)
+ .AddTokenProvider<BTCPayAuthenticatorTokenProvider>(TokenOptions.DefaultAuthenticatorProvider)
.AddInvitationTokenProvider();
services.Configure<AuthenticationOptions>(opts =>
{
diff --git a/BTCPayServer/Models/AccountViewModels/LoginViewModel.cs b/BTCPayServer/Models/AccountViewModels/LoginViewModel.cs
index 9509523..f8190e5 100644
--- a/BTCPayServer/Models/AccountViewModels/LoginViewModel.cs
+++ b/BTCPayServer/Models/AccountViewModels/LoginViewModel.cs
@@ -4,20 +4,19 @@ namespace BTCPayServer.Models.AccountViewModels
{
public class LoginViewModel
{
- [Required]
[EmailAddress]
[Display(Name = "Email address")]
public string Email { get; set; }
- [Required]
[DataType(DataType.Password)]
[Display(Name = "Password")]
public string Password { get; set; }
public string LoginCode { get; set; }
+ public string PasskeyResponse { get; set; }
[Display(Name = "Remember me")]
public bool RememberMe { get; set; }
- public bool AllowLimitedLogin { get; set; }
+ public string Method { get; set; }
}
}
diff --git a/BTCPayServer/Models/AccountViewModels/LoginWith2faViewModel.cs b/BTCPayServer/Models/AccountViewModels/LoginWith2faViewModel.cs
deleted file mode 100644
index 3c31846..0000000
--- a/BTCPayServer/Models/AccountViewModels/LoginWith2faViewModel.cs
+++ /dev/null
@@ -1,18 +0,0 @@
-using System.ComponentModel.DataAnnotations;
-
-namespace BTCPayServer.Models.AccountViewModels
-{
- public class LoginWith2faViewModel
- {
- [Required]
- [StringLength(7, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
- [DataType(DataType.Text)]
- [Display(Name = "Authenticator code")]
- public string TwoFactorCode { get; set; }
-
- [Display(Name = "Remember this machine")]
- public bool RememberMachine { get; set; }
-
- public bool RememberMe { get; set; }
- }
-}
diff --git a/BTCPayServer/Models/AccountViewModels/LoginWithAuthenticatorModel.cs b/BTCPayServer/Models/AccountViewModels/LoginWithAuthenticatorModel.cs
new file mode 100644
index 0000000..6c9a24a
--- /dev/null
+++ b/BTCPayServer/Models/AccountViewModels/LoginWithAuthenticatorModel.cs
@@ -0,0 +1,13 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace BTCPayServer.Models.AccountViewModels
+{
+ public class LoginWithAuthenticatorModel
+ {
+ [Required]
+ [StringLength(7, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
+ [DataType(DataType.Text)]
+ [Display(Name = "Authenticator code")]
+ public string TwoFactorCode { get; set; }
+ }
+}
diff --git a/BTCPayServer/Models/AccountViewModels/SecondaryLoginViewModel.cs b/BTCPayServer/Models/AccountViewModels/SecondaryLoginViewModel.cs
index 283d452..ef31c1d 100644
--- a/BTCPayServer/Models/AccountViewModels/SecondaryLoginViewModel.cs
+++ b/BTCPayServer/Models/AccountViewModels/SecondaryLoginViewModel.cs
@@ -5,7 +5,7 @@ namespace BTCPayServer.Models.AccountViewModels
public class SecondaryLoginViewModel
{
public LoginWithFido2ViewModel LoginWithFido2ViewModel { get; set; }
- public LoginWith2faViewModel LoginWith2FaViewModel { get; set; }
+ public LoginWithAuthenticatorModel LoginWithAuthenticator { get; set; }
public LoginWithLNURLAuthViewModel LoginWithLNURLAuthViewModel { get; set; }
}
}
diff --git a/BTCPayServer/Models/ManageViewModels/TwoFactorAuthenticationViewModel.cs b/BTCPayServer/Models/ManageViewModels/TwoFactorAuthenticationViewModel.cs
index 7e1e754..73ce5e8 100644
--- a/BTCPayServer/Models/ManageViewModels/TwoFactorAuthenticationViewModel.cs
+++ b/BTCPayServer/Models/ManageViewModels/TwoFactorAuthenticationViewModel.cs
@@ -8,7 +8,7 @@ namespace BTCPayServer.Models.ManageViewModels
public int RecoveryCodesLeft { get; set; }
- public bool Is2faEnabled { get; set; }
+ public bool IsAuthenticatorEnabled { get; set; }
public List<Fido2Credential> Credentials { get; set; }
diff --git a/BTCPayServer/Plugins/GlobalSearch/DefaultSearchResultProvider.cs b/BTCPayServer/Plugins/GlobalSearch/DefaultSearchResultProvider.cs
index 38c2901..013ec3f 100644
--- a/BTCPayServer/Plugins/GlobalSearch/DefaultSearchResultProvider.cs
+++ b/BTCPayServer/Plugins/GlobalSearch/DefaultSearchResultProvider.cs
@@ -90,6 +90,8 @@ public class DefaultSearchResultProvider : ISearchResultItemProvider
AddPage(results, "Change your password", context.Url.Action(nameof(UIManageController.ChangePassword), "UIManage"), "Account", ["Password", "Change"]);
AddPage(results, "Configure Two-Factor Authentication", context.Url.Action(nameof(UIManageController.TwoFactorAuthentication), "UIManage"), "Account",
["2FA Security", "Two-Factor", "Authentication"]);
+ AddPage(results, "Configure Passkey Authentication", context.Url.Action(nameof(UIManageController.Passkeys), "UIManage"), "Account",
+ ["Passkey", "Authentication"]);
AddPage(results, "Manage API Keys", context.Url.Action(nameof(UIManageController.APIKeys), "UIManage"), "Account", ["API", "Keys"]);
AddPage(results, "Manage the notification settings", context.Url.Action(nameof(UIManageController.NotificationSettings), "UIManage"), "Account", ["Notifications", "Manage"]);
}
diff --git a/BTCPayServer/Plugins/Impersonation/LinkGenerator.Impersonation.cs b/BTCPayServer/Plugins/Impersonation/LinkGenerator.Impersonation.cs
index cd8a617..4f0a85c 100644
--- a/BTCPayServer/Plugins/Impersonation/LinkGenerator.Impersonation.cs
+++ b/BTCPayServer/Plugins/Impersonation/LinkGenerator.Impersonation.cs
@@ -1,5 +1,6 @@
#nullable enable
using BTCPayServer.Abstractions;
+using BTCPayServer.Controllers;
using BTCPayServer.Plugins.Impersonation;
using Microsoft.AspNetCore.Routing;
@@ -9,6 +10,6 @@ public static class ImpersonationUrlHelperExtensions
{
public static string LoginCodeLink(this LinkGenerator urlHelper, string loginCode, string? returnUrl, RequestBaseUrl requestBaseUrl)
{
- return urlHelper.GetUriByAction(nameof(UIImpersonationController.LoginUsingCode), "UIImpersonation", new { area = ImpersonationPlugin.Area, loginCode, returnUrl }, requestBaseUrl);
+ return urlHelper.GetUriByAction(nameof(UIAccountController.Login), "UIAccount", new { LoginCode = loginCode, returnUrl }, requestBaseUrl);
}
}
diff --git a/BTCPayServer/Plugins/Impersonation/UIImpersonationController.cs b/BTCPayServer/Plugins/Impersonation/UIImpersonationController.cs
index 973552d..75c178d 100644
--- a/BTCPayServer/Plugins/Impersonation/UIImpersonationController.cs
+++ b/BTCPayServer/Plugins/Impersonation/UIImpersonationController.cs
@@ -18,97 +18,12 @@ using NicolasDorier.RateLimits;
namespace BTCPayServer.Plugins.Impersonation;
[Area(ImpersonationPlugin.Area)]
-public class UIImpersonationController(
- UserLoginCodeService userLoginCodeService,
- IStringLocalizer stringLocalizer,
- UserManager<ApplicationUser> userManager,
- SignInManager<ApplicationUser> signInManager,
- ViewLocalizer viewLocalizer,
- UserService userService) : Controller
+public class UIImpersonationController : Controller
{
- public IStringLocalizer StringLocalizer { get; } = stringLocalizer;
- private UserService.CanLoginContext CreateLoginContext(ApplicationUser user)
- => new(user, StringLocalizer, viewLocalizer, this.HttpContext.Request.GetRequestBaseUrl());
-
[HttpGet("/account/login-codes")]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanViewProfile)]
public ActionResult LoginCodes()
{
return View();
}
-
- // 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))
- {
- // loginCode might be url: https://btcpay.example.com/login/code?loginCode=***&returnUrl=***
- // if that's the case, we need to extract the loginCode and the returnUrl from the query string.
- if (Uri.TryCreate(loginCode, UriKind.Absolute, out var uri))
- {
- var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query);
- if (query.TryGetValue("loginCode", out var code))
- {
- loginCode = code;
- }
- if (query.TryGetValue("returnUrl", out var url) && string.IsNullOrEmpty(returnUrl))
- {
- returnUrl = url;
- }
- }
-
- var userId = userLoginCodeService.Verify(loginCode);
- if (userId is null)
- {
- TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["Login code was invalid"].Value;
- return Login(returnUrl);
- }
- if (userId == User.GetIdOrNull())
- return Login(returnUrl);
-
- var user = await userManager.FindByIdAsync(userId);
- var loginContext = CreateLoginContext(user);
- if (!await userService.CanLogin(loginContext))
- {
- TempData.SetStatusLoginResult(loginContext);
- return Login(returnUrl);
- }
-
- var now = DateTimeOffset.UtcNow;
- var authProperties = new AuthenticationProperties
- {
- IssuedUtc = now,
- AllowRefresh = false,
- IsPersistent = true,
- ExpiresUtc = now.AddDays(1)
- };
-
- await signInManager.SignInAsync(user, authProperties, AuthenticationSchemes.Cookie);
- }
-
- return Login(returnUrl);
- }
-
- private IActionResult Login(string returnUrl = null, string email = null)
- {
- email ??= User.FindFirst(ClaimTypes.Email)?.Value;
- return RedirectToAction(nameof(UIAccountController.Login), "UIAccount", new { area = "", email, returnUrl });
- }
}
diff --git a/BTCPayServer/Plugins/Impersonation/UserLoginCodeService.cs b/BTCPayServer/Plugins/Impersonation/UserLoginCodeService.cs
index 23475c1..5161080 100644
--- a/BTCPayServer/Plugins/Impersonation/UserLoginCodeService.cs
+++ b/BTCPayServer/Plugins/Impersonation/UserLoginCodeService.cs
@@ -19,12 +19,13 @@ public class UserLoginCodeService(IMemoryCache memoryCache)
return code;
}
- public string? Verify(string code)
+ public string? Verify(string code, bool remove = true)
{
var key = CacheKey(code);
if (!memoryCache.TryGetValue(key, out var o) || o is not string userId)
return null;
- memoryCache.Remove(key);
+ if (remove)
+ memoryCache.Remove(key);
return userId;
}
}
diff --git a/BTCPayServer/Security/BTCPayAuthenticatorTokenProvider.cs b/BTCPayServer/Security/BTCPayAuthenticatorTokenProvider.cs
new file mode 100644
index 0000000..0dbd820
--- /dev/null
+++ b/BTCPayServer/Security/BTCPayAuthenticatorTokenProvider.cs
@@ -0,0 +1,11 @@
+using System.Threading.Tasks;
+using BTCPayServer.Data;
+using Microsoft.AspNetCore.Identity;
+
+namespace BTCPayServer.Security;
+
+public class BTCPayAuthenticatorTokenProvider : AuthenticatorTokenProvider<ApplicationUser>
+{
+ public override async Task<bool> CanGenerateTwoFactorTokenAsync(UserManager<ApplicationUser> manager, ApplicationUser user)
+ => user.AuthenticatorEnabled && await base.CanGenerateTwoFactorTokenAsync(manager, user);
+}
diff --git a/BTCPayServer/Security/DisabledEmailTokenProvider.cs b/BTCPayServer/Security/DisabledEmailTokenProvider.cs
new file mode 100644
index 0000000..a1aa32b
--- /dev/null
+++ b/BTCPayServer/Security/DisabledEmailTokenProvider.cs
@@ -0,0 +1,15 @@
+using System.Threading.Tasks;
+using BTCPayServer.Data;
+using Microsoft.AspNetCore.Identity;
+
+namespace BTCPayServer.Security;
+
+public class DisabledEmailTokenProvider : EmailTokenProvider<ApplicationUser>
+{
+ public override Task<bool> CanGenerateTwoFactorTokenAsync(UserManager<ApplicationUser> manager, ApplicationUser user)
+ {
+ // We currently don't allow the user of email as two-factor authentication,
+ // so we don't need to generate a token
+ return Task.FromResult(false);
+ }
+}
diff --git a/BTCPayServer/Services/UserService.cs b/BTCPayServer/Services/UserService.cs
index 791d1b6..fb780d8 100644
--- a/BTCPayServer/Services/UserService.cs
+++ b/BTCPayServer/Services/UserService.cs
@@ -130,7 +130,7 @@ namespace BTCPayServer.Services
public ApplicationUser User => _user ?? throw new InvalidOperationException("User is not set");
public List<LoginFailure> Failures { get; } = new();
/// <summary>
- /// A redirect URL to redirect the user if login failed.
+ /// A redirect URL to redirect the user if he isn't allowed to login.
/// </summary>
public string? FailedRedirectUrl { get; set; }
}
diff --git a/BTCPayServer/UserManagerExtensions.cs b/BTCPayServer/UserManagerExtensions.cs
index ddd33dc..015b51f 100644
--- a/BTCPayServer/UserManagerExtensions.cs
+++ b/BTCPayServer/UserManagerExtensions.cs
@@ -3,6 +3,7 @@ using System;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Security;
+using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
namespace BTCPayServer
@@ -21,12 +22,25 @@ namespace BTCPayServer
return await userManager.FindByIdAsync(idOrEmail);
}
+ public static async Task<bool> IsAuthenticatorConfigured(this UserManager<ApplicationUser> userManager, ApplicationUser user)
+ {
+ return (await userManager.GetValidTwoFactorProvidersAsync(user)).Contains(userManager.Options.Tokens.AuthenticatorTokenProvider);
+ }
+
public static async Task<string?> GenerateInvitationTokenAsync(this UserManager<ApplicationUser> userManager, string userId)
{
var token = Guid.NewGuid().ToString("n")[..12];
return await userManager.SetInvitationTokenAsync(userId, token) ? token : null;
}
+ public static async Task TwoFactorSignInAsync(this SignInManager<ApplicationUser> signInManager, ApplicationUser user)
+ {
+ var userIdAsync = await signInManager.UserManager.GetUserIdAsync(user);
+ var storeTwoFactorInfoMethod = typeof(SignInManager<ApplicationUser>).GetMethod("StoreTwoFactorInfo", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic);
+ var principal = (System.Security.Claims.ClaimsPrincipal)storeTwoFactorInfoMethod!.Invoke(null, new object[] { userIdAsync, (null as string)! })!;
+ await signInManager.Context.SignInAsync(IdentityConstants.TwoFactorUserIdScheme, principal!);
+ }
+
public static Task<bool> UnsetInvitationTokenAsync(this UserManager<ApplicationUser> userManager, string userId)
=> userManager.SetInvitationTokenAsync(userId, null);
diff --git a/BTCPayServer/Views/UIAccount/Lockout.cshtml b/BTCPayServer/Views/UIAccount/Lockout.cshtml
index d47ca12..0cb59db 100644
--- a/BTCPayServer/Views/UIAccount/Lockout.cshtml
+++ b/BTCPayServer/Views/UIAccount/Lockout.cshtml
@@ -1,18 +1,14 @@
-@model DateTimeOffset?
+@model ApplicationUser
@{
ViewData["Title"] = "Account disabled";
Layout = "_LayoutSignedOut";
}
-@if (Model is null)
+@if (Model.IsDisabledTemporarily)
{
- <p class="mb-0" text-translate="true">This account has been locked out because of multiple invalid login attempts. Please try again later.</p>
-}
-else if (DateTimeOffset.MaxValue - Model.Value < TimeSpan.FromSeconds(1))
-{
- <p class="mb-0" text-translate="true">Your account has been disabled. Please contact server administrator.</p>
+ <p class="mb-0"><span text-translate="true">This account has been locked out. Please try again</span> @Model.LockoutEnd!.Value.ToBrowserDate(ViewsRazor.DateDisplayFormat.Relative).</p>
}
else
{
- <p class="mb-0"><span text-translate="true">This account has been locked out. Please try again</span> @Model.Value.ToBrowserDate(ViewsRazor.DateDisplayFormat.Relative).</p>
+ <p class="mb-0" text-translate="true">Your account has been disabled. Please contact server administrator.</p>
}
diff --git a/BTCPayServer/Views/UIAccount/Login.cshtml b/BTCPayServer/Views/UIAccount/Login.cshtml
index 5c3198c..a610555 100644
--- a/BTCPayServer/Views/UIAccount/Login.cshtml
+++ b/BTCPayServer/Views/UIAccount/Login.cshtml
@@ -9,48 +9,69 @@
}
<form asp-route-returnurl="@ViewData["ReturnUrl"]" method="post" id="login-form" asp-action="Login">
- <input type="hidden" asp-for="AllowLimitedLogin"></input>
+ <input type="hidden" asp-for="PasskeyResponse"></input>
+ <input type="hidden" asp-for="LoginCode"></input>
<fieldset disabled="@(ViewData.ContainsKey("disabled") ? "disabled" : null)">
@if (!ViewContext.ModelState.IsValid)
{
<div asp-validation-summary="ModelOnly" class="@(ViewContext.ModelState.ErrorCount.Equals(1) ? "no-marker" : "")"></div>
}
- <div class="form-group">
- <label asp-for="Email" class="form-label"></label>
- <input asp-for="Email" class="form-control" required autofocus />
- <span asp-validation-for="Email" class="text-danger"></span>
- </div>
- <div class="form-group">
- <div class="d-flex justify-content-between">
- <label asp-for="Password" class="form-label"></label>
- <a asp-action="ForgotPassword" tabindex="-1" text-translate="true">Forgot password?</a>
+ <fieldset id="login-password-fieldset">
+ <div class="form-group">
+ <label asp-for="Email" class="form-label"></label>
+ <input asp-for="Email" class="form-control" required autofocus />
+ <span asp-validation-for="Email" class="text-danger"></span>
</div>
- <div class="input-group d-flex">
- <input asp-for="Password" class="form-control" required />
+ <div class="form-group">
+ <div class="d-flex justify-content-between">
+ <label asp-for="Password" class="form-label"></label>
+ <a asp-action="ForgotPassword" tabindex="-1" text-translate="true">Forgot password?</a>
+ </div>
+ <div class="input-group d-flex">
+ <input asp-for="Password" class="form-control" required />
+ </div>
+ <span asp-validation-for="Password" class="text-danger"></span>
</div>
- <span asp-validation-for="Password" class="text-danger"></span>
- </div>
+ </fieldset>
<div class="form-check">
<input asp-for="RememberMe" type="checkbox" class="form-check-input" />
<label asp-for="RememberMe" class="form-check-label"></label>
<span asp-validation-for="RememberMe" class="text-danger"></span>
</div>
<div class="form-group mt-4">
- <div class="btn-group w-100">
- <button type="submit" class="btn btn-primary btn-lg w-100" id="LoginButton"><span class="ps-3" text-translate="true">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="@StringLocalizer["Scan Login code with camera"]">
- <vc:icon symbol="scan-qr" />
- </button>
+ <button type="submit" class="btn btn-primary btn-lg w-100" id="LoginButton" name="Method" value="Password">
+ <span text-translate="true">Sign in with password</span>
+ </button>
+ <button type="submit" class="d-none" id="PasskeyButton" name="Method" value="Passkey" formnovalidate></button>
+ <button type="submit" class="d-none" id="LoginCodeButton" name="Method" value="LoginCode" formnovalidate></button>
+ </div>
+
+ <div class="text-center my-3 text-secondary only-for-js" id="alternative-login-separator">
+ <span text-translate="true">or sign in with</span>
+ </div>
+ <div class="d-flex gap-2 only-for-js">
+ <button type="button" class="btn btn-outline-secondary w-100" id="passkey-login-btn" style="display:none;">
+ <vc:icon symbol="key" />
+ <span text-translate="true">Passkey</span>
+ </button>
+ <button type="button" class="btn btn-outline-secondary w-100" data-bs-toggle="modal" data-bs-target="#scanModal" title="@StringLocalizer["Scan Login code with camera"]">
+ <vc:icon symbol="scan-qr" />
+ <span text-translate="true">LoginCode</span>
+ </button>
+ </div>
+ <div class="text-center mt-2 only-for-js" id="passkey-loading" style="display:none;">
+ <div class="spinner-border spinner-border-sm text-secondary" role="status">
+ <span class="visually-hidden">Loading...</span>
</div>
+ <span class="ms-2 text-secondary" text-translate="true">Waiting for passkey...</span>
</div>
+ <div class="alert alert-danger mt-2 only-for-js" id="passkey-error" style="display:none;"></div>
</fieldset>
</form>
-<form asp-area="@ImpersonationPlugin.Area" asp-controller="UIImpersonation" asp-action="LoginWithCode" asp-route-returnurl="@ViewData["ReturnUrl"]" method="post" id="logincode-form">
- <input asp-for="LoginCode" type="hidden" class="form-control"/>
-</form>
+
@if (!PoliciesSettings.LockSubscription)
{
- <p class="text-center mt-2 mb-0">
+ <p class="text-center my-3 mb-0">
<a id="Register" style="font-size:1.15rem" asp-action="Register" asp-route-returnurl="@ViewData["ReturnUrl"]" text-translate="true">Create your account</a>
</p>
}
@@ -64,11 +85,16 @@
<link href="~/vendor/vue-qrcode-reader/vue-qrcode-reader.css" rel="stylesheet" asp-append-version="true"/>
+ <!-- Passkey/WebAuthn scripts -->
+ <script src="~/js/webauthn/helpers.js" asp-append-version="true"></script>
+
<script type="text/javascript">
+ var passKeyOptionsUrl = @Safe.Json(Url.Action(nameof(BTCPayServer.Controllers.UIAccountController.GetPasskeyOptions)));
window.addEventListener("load", async () => {
initCameraScanningApp("Scan login code", data => {
document.getElementById("LoginCode").value = data;
- document.getElementById("logincode-form").submit();
+ document.getElementById("login-password-fieldset").disabled = true;
+ document.getElementById("LoginCodeButton").click();
}, "scanModal", true);
});
</script>
diff --git a/BTCPayServer/Views/UIAccount/LoginWith2fa.cshtml b/BTCPayServer/Views/UIAccount/LoginWith2fa.cshtml
deleted file mode 100644
index 9b94c3a..0000000
--- a/BTCPayServer/Views/UIAccount/LoginWith2fa.cshtml
+++ /dev/null
@@ -1,30 +0,0 @@
-@model LoginWith2faViewModel
-
-<div class="twoFaBox">
- <h2 class="h3 mb-3" text-translate="true">Two-Factor Authentication</h2>
- <form method="post" asp-route-returnUrl="@ViewData["ReturnUrl"]" asp-action="LoginWith2fa">
- @if (!ViewContext.ModelState.IsValid)
- {
- <div asp-validation-summary="ModelOnly"></div>
- }
- <input asp-for="RememberMe" type="hidden"/>
- <div class="form-group">
- <label asp-for="TwoFactorCode" class="form-label"></label>
- <input asp-for="TwoFactorCode" class="form-control" autocomplete="off" autofocus style="width:14ch"/>
- <span asp-validation-for="TwoFactorCode" class="text-danger"></span>
- </div>
- <div class="form-check mb-3">
- <input asp-for="RememberMachine" type="checkbox" class="form-check-input" />
- <label asp-for="RememberMachine" class="form-check-label"></label>
- <span asp-validation-for="RememberMachine" class="text-danger"></span>
- </div>
- <div class="form-group">
- <button type="submit" class="btn btn-primary" text-translate="true">Log in</button>
- </div>
- </form>
- <p class="text-secondary mt-4 mb-0">
- Don't have access to your authenticator device?
- <br>
- You can <a asp-action="LoginWithRecoveryCode" asp-route-returnUrl="@ViewData["ReturnUrl"]">log in with a recovery code</a>.
- </p>
-</div>
diff --git a/BTCPayServer/Views/UIAccount/LoginWithAuthenticator.cshtml b/BTCPayServer/Views/UIAccount/LoginWithAuthenticator.cshtml
new file mode 100644
index 0000000..6fb7908
--- /dev/null
+++ b/BTCPayServer/Views/UIAccount/LoginWithAuthenticator.cshtml
@@ -0,0 +1,24 @@
+@model LoginWithAuthenticatorModel
+
+<div class="twoFaBox">
+ <h2 class="h3 mb-3" text-translate="true">Two-Factor Authentication</h2>
+ <form method="post" asp-action="LoginWithAuthenticator">
+ @if (!ViewContext.ModelState.IsValid)
+ {
+ <div asp-validation-summary="ModelOnly"></div>
+ }
+ <div class="form-group">
+ <label asp-for="TwoFactorCode" class="form-label"></label>
+ <input asp-for="TwoFactorCode" class="form-control" autocomplete="off" autofocus style="width:14ch"/>
+ <span asp-validation-for="TwoFactorCode" class="text-danger"></span>
+ </div>
+ <div class="form-group">
+ <button type="submit" class="btn btn-primary" text-translate="true">Log in</button>
+ </div>
+ </form>
+ <p class="text-secondary mt-4 mb-0">
+ Don't have access to your authenticator device?
+ <br>
+ You can <a asp-action="LoginWithRecoveryCode">log in with a recovery code</a>.
+ </p>
+</div>
diff --git a/BTCPayServer/Views/UIAccount/LoginWithFido2.cshtml b/BTCPayServer/Views/UIAccount/LoginWithFido2.cshtml
index 837dacd..cb9029d 100644
--- a/BTCPayServer/Views/UIAccount/LoginWithFido2.cshtml
+++ b/BTCPayServer/Views/UIAccount/LoginWithFido2.cshtml
@@ -2,11 +2,9 @@
@model BTCPayServer.Fido2.Models.LoginWithFido2ViewModel
<div class="twoFaBox">
- <form id="fidoForm" asp-action="LoginWithFido2" method="post" asp-route-returnUrl="@ViewData["ReturnUrl"]">
+ <form id="fidoForm" asp-action="LoginWithFido2" method="post">
<input type="hidden" asp-for="Data"/>
<input type="hidden" asp-for="Response"/>
- <input type="hidden" asp-for="UserId"/>
- <input type="hidden" asp-for="RememberMe"/>
</form>
<h2 class="h3 mb-3" text-translate="true">FIDO2 Authentication</h2>
<p text-translate="true">Insert your security device and proceed.</p>
@@ -21,7 +19,7 @@
<button id="btn-start" class="btn btn-primary d-none" type="button" text-translate="true">Start</button>
<p id="error-message" class="d-none alert alert-danger mb-4"></p>
<button id="btn-retry" class="btn btn-secondary d-none" type="button" text-translate="true">Retry</button>
-
+
<script>
document.getElementById('btn-retry').addEventListener('click', () => window.location.reload())
// send to server for registering
diff --git a/BTCPayServer/Views/UIAccount/LoginWithLNURLAuth.cshtml b/BTCPayServer/Views/UIAccount/LoginWithLNURLAuth.cshtml
index f59cb05..0971fc8 100644
--- a/BTCPayServer/Views/UIAccount/LoginWithLNURLAuth.cshtml
+++ b/BTCPayServer/Views/UIAccount/LoginWithLNURLAuth.cshtml
@@ -8,9 +8,8 @@
}
<div id="lnurlauth-section" class="twoFaBox">
- <form id="authform" asp-action="LoginWithLNURLAuth" method="post" asp-route-returnUrl="@ViewData["ReturnUrl"]">
+ <form id="authform" asp-action="LoginWithLNURLAuth" method="post">
<input type="hidden" asp-for="LNURLEndpoint"/>
- <input type="hidden" asp-for="UserId"/>
</form>
<h2 class="h3 mb-3" text-translate="true">LNURL Authentication</h2>
<p text-translate="true">Scan the QR code with your Lightning wallet to sign in.</p>
@@ -53,7 +52,7 @@
document.getElementById("authform").submit();
}
}
- request.open("GET", @Safe.Json(Url.Action("LoginCheck", "UILNURLAuth", new { userId = Model.UserId })), true);
+ request.open("GET", @Safe.Json(Url.Action("LoginCheck", "UILNURLAuth")), true);
request.send(new FormData());
}
check();
diff --git a/BTCPayServer/Views/UIAccount/LoginWithLoginCode.cshtml b/BTCPayServer/Views/UIAccount/LoginWithLoginCode.cshtml
new file mode 100644
index 0000000..c3a8cd2
--- /dev/null
+++ b/BTCPayServer/Views/UIAccount/LoginWithLoginCode.cshtml
@@ -0,0 +1,34 @@
+@using BTCPayServer.Plugins.Impersonation
+@model LoginViewModel
+@inject BTCPayServer.Security.ContentSecurityPolicies Csp
+@inject BTCPayServer.Services.PoliciesSettings PoliciesSettings
+@{
+ ViewData["Title"] = ViewLocalizer["Sign in with login code"];
+ Layout = "_LayoutSignedOut";
+ Csp.UnsafeEval();
+}
+
+<form asp-route-returnurl="@ViewData["ReturnUrl"]" method="post" id="login-form" asp-action="Login">
+ <fieldset>
+ @if (!ViewContext.ModelState.IsValid)
+ {
+ <div asp-validation-summary="ModelOnly" class="@(ViewContext.ModelState.ErrorCount.Equals(1) ? "no-marker" : "")"></div>
+ }
+ <input asp-for="LoginCode" type="hidden" />
+ <div class="form-group">
+ <label asp-for="Email" class="form-label"></label>
+ <input asp-for="Email" class="form-control" readonly="readonly" />
+ <span asp-validation-for="Email" class="text-danger"></span>
+ </div>
+ <div class="form-check">
+ <input asp-for="RememberMe" type="checkbox" class="form-check-input" />
+ <label asp-for="RememberMe" class="form-check-label"></label>
+ <span asp-validation-for="RememberMe" class="text-danger"></span>
+ </div>
+ <div class="form-group mt-4">
+ <button type="submit" class="btn btn-primary btn-lg w-100" id="LoginButton" name="Method" value="LoginCode">
+ <span text-translate="true">Sign in with login code</span>
+ </button>
+ </div>
+ </fieldset>
+</form>
diff --git a/BTCPayServer/Views/UIAccount/LoginWithRecoveryCode.cshtml b/BTCPayServer/Views/UIAccount/LoginWithRecoveryCode.cshtml
index 2a765a0..74d809b 100644
--- a/BTCPayServer/Views/UIAccount/LoginWithRecoveryCode.cshtml
+++ b/BTCPayServer/Views/UIAccount/LoginWithRecoveryCode.cshtml
@@ -4,7 +4,7 @@
ViewData["Title"] = "Recovery code verification";
}
-<p text-translate="true">You have requested to log in with a recovery code. This login will not be remembered until you provide an authenticator app code at login or disable 2FA and log in again.</p>
+<p text-translate="true">You have requested to log in with a recovery code. This login will not be remembered until you provide an authenticator app code at login or disable authenticator and log in again.</p>
<form method="post">
<div class="form-group">
<label asp-for="RecoveryCode" class="form-label"></label>
diff --git a/BTCPayServer/Views/UIAccount/Register.cshtml b/BTCPayServer/Views/UIAccount/Register.cshtml
index f663a76..de2ed90 100644
--- a/BTCPayServer/Views/UIAccount/Register.cshtml
+++ b/BTCPayServer/Views/UIAccount/Register.cshtml
@@ -6,7 +6,7 @@
Layout = "_LayoutSignedOut";
}
-<form asp-route-returnUrl="@ViewData["ReturnUrl"]" asp-route-logon="true" method="post">
+<form asp-route-returnUrl="@ViewData["ReturnUrl"]" method="post">
<fieldset disabled="@(ViewData.ContainsKey("disabled") ? "disabled" : null)" >
@if (!ViewContext.ModelState.IsValid)
{
diff --git a/BTCPayServer/Views/UIAccount/SecondaryLogin.cshtml b/BTCPayServer/Views/UIAccount/SecondaryLogin.cshtml
index e4751fc..f98dc4a 100644
--- a/BTCPayServer/Views/UIAccount/SecondaryLogin.cshtml
+++ b/BTCPayServer/Views/UIAccount/SecondaryLogin.cshtml
@@ -13,31 +13,19 @@
<partial name="_ValidationScriptsPartial" />
}
-@if (Model.LoginWith2FaViewModel != null && Model.LoginWithFido2ViewModel != null && Model.LoginWithLNURLAuthViewModel != null)
-{
- <div asp-validation-summary="ModelOnly" class="@(ViewContext.ModelState.ErrorCount.Equals(1) ? "no-marker" : "")"></div>
-}
-else if (Model.LoginWith2FaViewModel == null && Model.LoginWithFido2ViewModel == null && Model.LoginWithLNURLAuthViewModel == null)
-{
- <div class="row">
- <div class="col-lg-12">
- <h2 class="bg-danger" text-translate="true">2FA and U2F/FIDO2 and LNURL-Auth Authentication Methods are not available. Please go to the https endpoint.</h2>
- <hr class="danger">
- </div>
- </div>
-}
+<partial name="_StatusMessage" />
<div class="row justify-content-center">
- @if (Model.LoginWith2FaViewModel != null)
+ @if (Model.LoginWithAuthenticator != null)
{
- <partial name="LoginWith2fa" model="@Model.LoginWith2FaViewModel"/>
+ <partial name="LoginWithAuthenticator" model="@Model.LoginWithAuthenticator" />
}
@if (Model.LoginWithFido2ViewModel != null)
{
- <partial name="LoginWithFido2" model="@Model.LoginWithFido2ViewModel"/>
+ <partial name="LoginWithFido2" model="@Model.LoginWithFido2ViewModel" />
}
@if (Model.LoginWithLNURLAuthViewModel != null)
{
- <partial name="LoginWithLNURLAuth" model="@Model.LoginWithLNURLAuthViewModel"/>
+ <partial name="LoginWithLNURLAuth" model="@Model.LoginWithLNURLAuthViewModel" />
}
</div>
diff --git a/BTCPayServer/Views/UIFido2/Create.cshtml b/BTCPayServer/Views/UIFido2/Create.cshtml
index 8f40747..e7be1a9 100644
--- a/BTCPayServer/Views/UIFido2/Create.cshtml
+++ b/BTCPayServer/Views/UIFido2/Create.cshtml
@@ -1,27 +1,47 @@
@using Newtonsoft.Json.Linq
-@model Fido2NetLib.CredentialCreateOptions
+@model (Fido2NetLib.CredentialCreateOptions, bool IsPasskey)
@{
- ViewData.SetLayoutModel(new(nameof(ManageNavPages.TwoFactorAuthentication), StringLocalizer["Register your security device"]));
+ var pageTitle = Model.IsPasskey ? StringLocalizer["Register your passkey"] : StringLocalizer["Register your security device"];
+ ViewData.SetLayoutModel(new(Model.IsPasskey ? nameof(ManageNavPages.Passkeys) : nameof(ManageNavPages.TwoFactorAuthentication), pageTitle));
}
<div class="sticky-header">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
- <a asp-controller="UIManage" asp-action="TwoFactorAuthentication" text-translate="true">Two Factor Authentication</a>
+ @if (Model.IsPasskey)
+ {
+ <a asp-controller="UIManage" asp-action="Passkeys" text-translate="true">Passkeys</a>
+ }
+ else
+ {
+ <a asp-controller="UIManage" asp-action="TwoFactorAuthentication" text-translate="true">Two Factor Authentication</a>
+ }
</li>
- <li class="breadcrumb-item active" aria-current="page" text-translate="true">Register Device</li>
+ <li class="breadcrumb-item active" aria-current="page">@(Model.IsPasskey ? StringLocalizer["Register Passkey"] : StringLocalizer["Register Device"])</li>
</ol>
<h2>@ViewData["Title"]</h2>
</nav>
</div>
<partial name="_StatusMessage" />
-<p text-translate="true">Insert your security device and proceed.</p>
+@if (Model.IsPasskey)
+{
+ <div class="alert alert-info mb-3">
+ <strong text-translate="true">What is a passkey?</strong>
+ <p class="mb-0" text-translate="true">Passkeys let you sign in without a password using biometrics (fingerprint, face recognition) or your device's screen lock. Once registered, you can use your passkey instead of entering your email and password.</p>
+ </div>
+ <p text-translate="true">Use your fingerprint, face recognition, or security key to register a new passkey.</p>
+}
+else
+{
+ <p text-translate="true">Insert your security device and proceed.</p>
+}
-<form asp-action="CreateResponse" id="registerForm">
+<form asp-controller="UIFido2" asp-action="CreateResponse" method="post" id="registerForm">
<input type="hidden" name="data" id="data"/>
<input type="hidden" name="name" id="name" value="@(ViewData.ContainsKey("CredentialName") ? ViewData["CredentialName"] : string.Empty)"/>
+ <input type="hidden" name="isPasskey" id="isPasskey" value="@(Model.IsPasskey ? "true" : "false")"/>
</form>
<div class="row">
<div class="col-xl-8">
@@ -43,7 +63,7 @@
<script>
document.getElementById('btn-retry').addEventListener('click', function () { window.location.reload() });
// send to server for registering
- window.makeCredentialOptions = @Json.Serialize(JToken.Parse(Model.ToJson()));
+ window.makeCredentialOptions = @Json.Serialize(JToken.Parse(Model.Item1.ToJson()));
</script>
<script src="~/js/webauthn/helpers.js"></script>
<script src="~/js/webauthn/register.js"></script>
diff --git a/BTCPayServer/Views/UIManage/EnableAuthenticator.cshtml b/BTCPayServer/Views/UIManage/EnableAuthenticator.cshtml
index 4a6cdc9..b008850 100644
--- a/BTCPayServer/Views/UIManage/EnableAuthenticator.cshtml
+++ b/BTCPayServer/Views/UIManage/EnableAuthenticator.cshtml
@@ -8,7 +8,7 @@
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
- <a asp-action="TwoFactorAuthentication" text-translate="true">Two Factor Authentication</a>
+ <a asp-controller="UIManage" asp-action="TwoFactorAuthentication" text-translate="true">Two Factor Authentication</a>
</li>
<li class="breadcrumb-item active" aria-current="page">@ViewData["Title"]</li>
</ol>
diff --git a/BTCPayServer/Views/UIManage/ManageNavPages.cs b/BTCPayServer/Views/UIManage/ManageNavPages.cs
index 581965b..e260885 100644
--- a/BTCPayServer/Views/UIManage/ManageNavPages.cs
+++ b/BTCPayServer/Views/UIManage/ManageNavPages.cs
@@ -2,6 +2,6 @@ namespace BTCPayServer.Views.Manage
{
public enum ManageNavPages
{
- Index, ChangePassword, TwoFactorAuthentication, APIKeys, Notifications, LoginCodes
+ Index, ChangePassword, TwoFactorAuthentication, Passkeys, APIKeys, Notifications, LoginCodes
}
}
diff --git a/BTCPayServer/Views/UIManage/Passkeys.cshtml b/BTCPayServer/Views/UIManage/Passkeys.cshtml
new file mode 100644
index 0000000..b1fff83
--- /dev/null
+++ b/BTCPayServer/Views/UIManage/Passkeys.cshtml
@@ -0,0 +1,60 @@
+@model List<BTCPayServer.Data.Fido2Credential>
+@using BTCPayServer.Fido2
+@{
+ ViewData.SetLayoutModel(new LayoutModel(nameof(ManageNavPages.Passkeys), StringLocalizer["Passkeys"])
+ .SetCategory(nameof(ManageNavPages)));
+}
+<div class="sticky-header">
+ <h2 class="my-1">@ViewData["Title"]</h2>
+</div>
+<partial name="_StatusMessage" />
+<div class="row">
+ <div class="col-xl-8 col-xxl-constrain">
+ <span html-translate="true">
+ <p>Passkeys are a simpler and safer way to sign in — no login or password required.</p>
+ <p>
+ With passkeys, users can log in using the same method they already use to unlock their device,
+ such as Face ID, Touch ID, Windows Hello, Android biometrics, or a hardware security key such as Yubikey.
+ </p>
+ </span>
+
+ @if (Model.Any())
+ {
+ <div class="list-group mb-3">
+ @foreach (var device in Model)
+ {
+ var name = string.IsNullOrEmpty(device.Name) ? "Unnamed passkey" : device.Name;
+ <div class="list-group-item d-flex justify-content-between align-items-center py-3">
+ <div class="mb-0">
+ <h5 class="w-100">@name</h5>
+ <span class="text-muted">
+ <span text-translate="true">Last used</span>:
+ @if (device.LastUsedAt is { } lastUsedAt)
+ {
+ @lastUsedAt.ToTimeAgo()
+ }
+ else
+ {
+ <span text-translate="true">Never</span>
+ }
+ </span>
+ </div>
+ <a asp-controller="UIFido2" asp-action="Remove" asp-route-id="@device.Id" asp-route-isPasskey="true" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-title="@StringLocalizer["Remove passkey"]" data-description="@ViewLocalizer["Your account will no longer have the passkey <strong>{0}</strong> as an option for passwordless login.", Html.Encode(name)]" data-confirm="@StringLocalizer["Delete"]" data-confirm-input="@StringLocalizer["Delete"]" text-translate="true">Remove</a>
+ </div>
+ }
+ </div>
+ }
+
+ <form id="passkey-form" asp-action="CreateCredential" class="mb-4">
+ <div class="input-group">
+ <input type="hidden" name="type" value="Passkey" />
+ <input type="text" class="form-control" name="Name" placeholder="@StringLocalizer["Passkey name (e.g., MacBook Touch ID)"]" />
+ <button id="btn-add-passkey" type="submit" class="btn btn-primary" text-translate="true">
+ Add
+ </button>
+ </div>
+ </form>
+ </div>
+</div>
+
+<partial name="_Confirm" model="@(new ConfirmModel(StringLocalizer["Passkeys"], StringLocalizer["Placeholder"], StringLocalizer["Placeholder"]))"/>
diff --git a/BTCPayServer/Views/UIManage/TwoFactorAuthentication.cshtml b/BTCPayServer/Views/UIManage/TwoFactorAuthentication.cshtml
index ddfa063..49e63c9 100644
--- a/BTCPayServer/Views/UIManage/TwoFactorAuthentication.cshtml
+++ b/BTCPayServer/Views/UIManage/TwoFactorAuthentication.cshtml
@@ -1,4 +1,5 @@
@model TwoFactorAuthenticationViewModel
+@using BTCPayServer.Fido2
@{
ViewData.SetLayoutModel(new LayoutModel(nameof(ManageNavPages.TwoFactorAuthentication), StringLocalizer["Two-Factor Authentication"])
.SetCategory(nameof(ManageNavPages)));
@@ -11,9 +12,9 @@
<div class="col-xl-8 col-xxl-constrain">
<p text-translate="true">Two-Factor Authentication (2FA) is an additional measure to protect your account. In addition to your password you will be asked for a second proof on login. This can be provided by an app (such as Google or Microsoft Authenticator) or a security device (like a Yubikey or your hardware wallet supporting FIDO2).</p>
- <h4 class="mb-3" text-translate="true">App-based 2FA</h4>
+ <h4 class="mb-3" text-translate="true">Authenticator</h4>
- @if (Model.Is2faEnabled)
+ @if (Model.IsAuthenticatorEnabled)
{
if (Model.RecoveryCodesLeft == 0)
{
@@ -48,15 +49,8 @@
}
<div class="list-group mb-3">
- @if (Model.Is2faEnabled)
+ @if (Model.IsAuthenticatorEnabled)
{
- <a asp-action="Disable2fa" class="list-group-item d-flex justify-content-between align-items-center list-group-item-action py-3" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-title="@StringLocalizer["Disable two-factor authentication (2FA)"]" data-description="@StringLocalizer["Disabling 2FA does not change the keys used in the authenticator apps. If you wish to change the key used in an authenticator app you should reset your authenticator keys."]" data-confirm="@StringLocalizer["Disable"]" data-confirm-input="@StringLocalizer["DISABLE"]">
- <div>
- <h5 text-translate="true">Disable 2FA</h5>
- <p class="mb-0 me-3" text-translate="true">Re-enabling will not require you to reconfigure your app.</p>
- </div>
- <vc:icon symbol="caret-right"/>
- </a>
<a asp-action="GenerateRecoveryCodes" class="list-group-item d-flex justify-content-between align-items-center list-group-item-action py-3" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-title="@StringLocalizer["Reset recovery codes"]" data-description="@StringLocalizer["Your existing recovery codes will no longer be valid!"]" data-confirm="@StringLocalizer["Reset"]" data-confirm-input="@StringLocalizer["RESET"]">
<div>
<h5 text-translate="true">Reset recovery codes</h5>
@@ -66,8 +60,8 @@
</a>
<a asp-action="ResetAuthenticator" class="list-group-item d-flex justify-content-between align-items-center list-group-item-action py-3" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-title="@StringLocalizer["Reset authenticator app"]" data-description="@StringLocalizer["This process disables 2FA until you verify your authenticator app and will also reset your 2FA recovery codes. If you do not complete your authenticator app configuration you may lose access to your account."]" data-confirm="@StringLocalizer["Reset"]" data-confirm-input="@StringLocalizer["RESET"]">
<div>
- <h5 text-translate="true">Reset app</h5>
- <p class="mb-0 me-3" text-translate="true">Invalidates the current authenticator configuration. Useful if you believe your authenticator settings were compromised.</p>
+ <h5 text-translate="true">Disable Authenticator</h5>
+ <p class="mb-0 me-3" text-translate="true">Invalidates the current authenticator configuration. Useful if you believe your authenticator settings were compromised or if you want to disable authenticator.</p>
</div>
<vc:icon symbol="caret-right"/>
</a>
@@ -83,7 +77,7 @@
{
<a asp-action="EnableAuthenticator" class="list-group-item d-flex justify-content-between align-items-center list-group-item-action py-3">
<div>
- <h5 text-translate="true">Enable 2FA</h5>
+ <h5 text-translate="true">Enable Authenticator</h5>
<p class="mb-0 me-3" text-translate="true">Using apps such as Google or Microsoft Authenticator.</p>
</div>
<vc:icon symbol="caret-right"/>
@@ -93,15 +87,20 @@
<h4 class="mt-4 mb-3" text-translate="true">Security devices</h4>
- @if (Model.Credentials.Any())
+ @{
+ var securityDevices = Model.Credentials.Where(c => c.Type == Fido2Credential.CredentialType.FIDO2).ToList();
+ var lnurlDevices = Model.Credentials.Where(c => c.Type == Fido2Credential.CredentialType.LNURLAuth).ToList();
+ var allSecurityDevices = securityDevices.Concat(lnurlDevices).ToList();
+ }
+ @if (allSecurityDevices.Any())
{
<div class="list-group mb-3">
- @foreach (var device in Model.Credentials)
+ @foreach (var device in allSecurityDevices)
{
var name = string.IsNullOrEmpty(device.Name) ? "Unnamed security device" : device.Name;
<div class="list-group-item d-flex justify-content-between align-items-center py-3">
<div class="mb-0">
- <h5 class="mb-0 w-100">@name</h5>
+ <h5 class="w-100">@name</h5>
@switch (device.Type)
{
case Fido2Credential.CredentialType.FIDO2:
@@ -111,6 +110,17 @@
<span class="text-muted" text-translate="true">Lightning node (LNURL Auth)</span>
break;
}
+ <span class="text-muted d-block">
+ <span text-translate="true">Last used</span>:
+ @if (device.LastUsedAt is { } lastUsedAt)
+ {
+ @lastUsedAt.ToTimeAgo()
+ }
+ else
+ {
+ <span text-translate="true">Never</span>
+ }
+ </span>
</div>
@if (device.Type == Fido2Credential.CredentialType.FIDO2)
@@ -126,15 +136,19 @@
</div>
}
- <form asp-action="CreateCredential">
+ <form id="security-device-form" asp-action="CreateCredential">
<div class="input-group">
<input type="text" class="form-control" name="Name" placeholder="@StringLocalizer["Security device name"]"/>
- <select asp-items="@Html.GetEnumSelectList<Fido2Credential.CredentialType>()" class="form-select w-auto" name="type"></select>
+ <select class="form-select w-auto" name="type">
+ <option text-translate="true" value="FIDO2">Security device (FIDO2)</option>
+ <option text-translate="true" value="LNURLAuth">Lightning node (LNURL Auth)</option>
+ </select>
<button id="btn-add" type="submit" class="btn btn-primary" text-translate="true">
Add
</button>
</div>
</form>
+
</div>
</div>
diff --git a/BTCPayServer/wwwroot/js/webauthn/helpers.js b/BTCPayServer/wwwroot/js/webauthn/helpers.js
index 9bf0faa..b9baba9 100644
--- a/BTCPayServer/wwwroot/js/webauthn/helpers.js
+++ b/BTCPayServer/wwwroot/js/webauthn/helpers.js
@@ -73,14 +73,14 @@ function showErrorAlert(message, error) {
footermsg = 'exception:' + error.toString();
}
console.error(message, footermsg);
-
+
const $info = document.getElementById("info-message");
if ($info) $info.classList.add("d-none");
document.getElementById("btn-retry").classList.remove("d-none");
document.getElementById("error-message").textContent = message;
for(let el of document.getElementsByClassName("fido-running")){
el.classList.add("d-none");
- }
+ }
document.getElementById("error-message").classList.remove("d-none");
}
@@ -113,3 +113,135 @@ function isSafari(){
//https://stackoverflow.com/a/23522755/275504
return /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
}
+
+function detectPasskeySupport() {
+ if (window.PublicKeyCredential === undefined ||
+ typeof window.PublicKeyCredential !== "function") {
+ return false;
+ }
+ return true;
+}
+
+// Initialize passkey login button on the login page
+function initPasskeyLogin() {
+ if (!detectPasskeySupport()) {
+ console.log("Passkey not supported in this browser");
+ return;
+ }
+
+ // Show the passkey login elements
+ const button = document.getElementById("passkey-login-btn");
+ if (button) {
+ button.style.display = "";
+ button.addEventListener("click", startPasskeyLogin);
+ }
+}
+
+// Start the passkey login flow
+async function startPasskeyLogin() {
+ const button = document.getElementById("passkey-login-btn");
+ const errorEl = document.getElementById("passkey-error");
+ const loadingEl = document.getElementById("passkey-loading");
+
+ // Hide any previous errors
+ if (errorEl) errorEl.style.display = "none";
+
+ // Show loading state
+ if (button) button.disabled = true;
+ if (loadingEl) loadingEl.style.display = "";
+
+ try {
+
+ const tokenInput = document.querySelector('input[name="__RequestVerificationToken"]');
+ const headers = { 'Content-Type': 'application/json' };
+ if (tokenInput?.value) headers['RequestVerificationToken'] = tokenInput.value;
+
+ // Request authentication options from server
+ const optionsResponse = await fetch(passKeyOptionsUrl, {
+ method: "POST",
+ headers: headers
+ });
+
+ if (!optionsResponse.ok) {
+ throw new Error("Failed to get passkey options");
+ }
+
+ const options = await optionsResponse.json();
+
+ // Convert challenge from base64url to ArrayBuffer
+ options.challenge = coerceToArrayBuffer(options.challenge, "challenge");
+
+ // Convert allowCredentials if present
+ if (options.allowCredentials) {
+ options.allowCredentials = options.allowCredentials.map(cred => {
+ cred.id = coerceToArrayBuffer(cred.id, "allowCredentials.id");
+ return cred;
+ });
+ }
+
+ // Perform WebAuthn authentication
+ let credential;
+ try {
+ credential = await navigator.credentials.get({ publicKey: options });
+ } catch (err) {
+ if (err.name === "NotAllowedError") {
+ throw new Error("Authentication was cancelled or timed out");
+ }
+ throw err;
+ }
+
+ // Submit the credential to the server
+ await submitPasskeyCredential(credential);
+
+ } catch (error) {
+ console.error("Passkey login error:", error);
+ showPasskeyError(error.message || "Passkey authentication failed");
+ } finally {
+ // Reset loading state
+ if (button) button.disabled = false;
+ if (loadingEl) loadingEl.style.display = "none";
+ }
+}
+
+// Submit the passkey credential to the server
+async function submitPasskeyCredential(credential) {
+ // Prepare the assertion response
+ const authData = new Uint8Array(credential.response.authenticatorData);
+ const clientDataJSON = new Uint8Array(credential.response.clientDataJSON);
+ const rawId = new Uint8Array(credential.rawId);
+ const sig = new Uint8Array(credential.response.signature);
+
+ const data = {
+ id: credential.id,
+ rawId: coerceToBase64Url(rawId),
+ type: credential.type,
+ extensions: credential.getClientExtensionResults(),
+ response: {
+ authenticatorData: coerceToBase64Url(authData),
+ clientDataJSON: coerceToBase64Url(clientDataJSON),
+ signature: coerceToBase64Url(sig)
+ }
+ };
+
+ // Add userHandle if present (for discoverable credentials)
+ if (credential.response.userHandle) {
+ data.response.userHandle = coerceToBase64Url(new Uint8Array(credential.response.userHandle));
+ }
+
+ // Submit via hidden form (to include anti-forgery token)
+ document.getElementById("PasskeyResponse").value = JSON.stringify(data);
+ document.querySelector('#login-password-fieldset').disabled = true;
+ document.getElementById("PasskeyButton").click();
+}
+
+// Show an error message
+function showPasskeyError(message) {
+ const errorEl = document.getElementById("passkey-error");
+ if (errorEl) {
+ errorEl.textContent = message;
+ errorEl.style.display = "";
+ }
+}
+
+// Initialize on page load
+document.addEventListener("DOMContentLoaded", initPasskeyLogin);
diff --git a/BTCPayServer/wwwroot/js/webauthn/register.js b/BTCPayServer/wwwroot/js/webauthn/register.js
index e73ad68..39698bb 100644
--- a/BTCPayServer/wwwroot/js/webauthn/register.js
+++ b/BTCPayServer/wwwroot/js/webauthn/register.js
@@ -21,7 +21,7 @@ async function register(makeCredentialOptions) {
publicKey: makeCredentialOptions
});
} catch (e) {
- var msg = "Could not create credentials in browser. Probably because the username is already registered with your authenticator. Please change username or authenticator."
+ var msg = "This device does not support WebAuthn. Please use a different browser or device."
showErrorAlert(msg, e);
return;
}
@@ -53,7 +53,7 @@ async function registerNewCredential(newCredential) {
clientDataJSON: coerceToBase64Url(clientDataJSON)
}
};
-
+
document.getElementById("data").value = JSON.stringify(data);
document.getElementById("registerForm").submit();
}
Why this scored 59/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.