diff --git a/BTCPayServer.Data/ApplicationDbContext.cs b/BTCPayServer.Data/ApplicationDbContext.cs index 2668799d4..aebcee3f9 100644 --- a/BTCPayServer.Data/ApplicationDbContext.cs +++ b/BTCPayServer.Data/ApplicationDbContext.cs @@ -55,6 +55,7 @@ namespace BTCPayServer.Data public DbSet StoreWebhooks { get; set; } public DbSet Stores { get; set; } public DbSet U2FDevices { get; set; } + public DbSet Fido2Credentials { get; set; } public DbSet UserStore { get; set; } public DbSet Wallets { get; set; } public DbSet WalletTransactions { get; set; } @@ -99,6 +100,7 @@ namespace BTCPayServer.Data StoreWebhookData.OnModelCreating(builder); //StoreData.OnModelCreating(builder); U2FDevice.OnModelCreating(builder); + Fido2Credential.OnModelCreating(builder); Data.UserStore.OnModelCreating(builder); //WalletData.OnModelCreating(builder); WalletTransactionData.OnModelCreating(builder); diff --git a/BTCPayServer.Data/Data/ApplicationUser.cs b/BTCPayServer.Data/Data/ApplicationUser.cs index c5dc97702..1461de612 100644 --- a/BTCPayServer.Data/Data/ApplicationUser.cs +++ b/BTCPayServer.Data/Data/ApplicationUser.cs @@ -16,5 +16,6 @@ namespace BTCPayServer.Data public List Notifications { get; set; } public List UserStores { get; set; } + public List Fido2Credentials { get; set; } } } diff --git a/BTCPayServer.Data/Data/Fido2Credential.cs b/BTCPayServer.Data/Data/Fido2Credential.cs new file mode 100644 index 000000000..fe57f3fd5 --- /dev/null +++ b/BTCPayServer.Data/Data/Fido2Credential.cs @@ -0,0 +1,33 @@ +using System; +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; + +namespace BTCPayServer.Data +{ + public class Fido2Credential + { + public string Name { get; set; } + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public string Id { get; set; } + + public string ApplicationUserId { get; set; } + + public byte[] Blob { get; set; } + public CredentialType Type { get; set; } + public enum CredentialType + { + FIDO2, + U2F + } + public static void OnModelCreating(ModelBuilder builder) + { + builder.Entity() + .HasOne(o => o.ApplicationUser) + .WithMany(i => i.Fido2Credentials) + .HasForeignKey(i => i.ApplicationUserId).OnDelete(DeleteBehavior.Cascade); + + } + + public ApplicationUser ApplicationUser { get; set; } + } +} diff --git a/BTCPayServer.Data/Migrations/20210314092253_Fido2Credentials.cs b/BTCPayServer.Data/Migrations/20210314092253_Fido2Credentials.cs new file mode 100644 index 000000000..1f8c71cc8 --- /dev/null +++ b/BTCPayServer.Data/Migrations/20210314092253_Fido2Credentials.cs @@ -0,0 +1,48 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using BTCPayServer.Data; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +namespace BTCPayServer.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20210314092253_Fido2Credentials")] + public partial class Fido2Credentials : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Fido2Credentials", + columns: table => new + { + Id = table.Column(nullable: false), + Name = table.Column(nullable: true), + ApplicationUserId = table.Column(nullable: true), + Blob = table.Column(nullable: true), + Type = table.Column(nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Fido2Credentials", x => x.Id); + table.ForeignKey( + name: "FK_Fido2Credentials_AspNetUsers_ApplicationUserId", + column: x => x.ApplicationUserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Fido2Credentials_ApplicationUserId", + table: "Fido2Credentials", + column: "ApplicationUserId"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Fido2Credentials"); + } + } +} diff --git a/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs b/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs index 1703daf27..bf11741bd 100644 --- a/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs @@ -170,6 +170,31 @@ namespace BTCPayServer.Migrations b.ToTable("AspNetUsers"); }); + modelBuilder.Entity("BTCPayServer.Data.Fido2Credential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApplicationUserId") + .HasColumnType("TEXT"); + + b.Property("Blob") + .HasColumnType("BLOB"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationUserId"); + + b.ToTable("Fido2Credentials"); + }); + modelBuilder.Entity("BTCPayServer.Data.HistoricalAddressInvoiceData", b => { b.Property("InvoiceDataId") @@ -958,6 +983,14 @@ namespace BTCPayServer.Migrations .OnDelete(DeleteBehavior.Cascade); }); + modelBuilder.Entity("BTCPayServer.Data.Fido2Credential", b => + { + b.HasOne("BTCPayServer.Data.ApplicationUser", "ApplicationUser") + .WithMany("Fido2Credentials") + .HasForeignKey("ApplicationUserId") + .OnDelete(DeleteBehavior.Cascade); + }); + modelBuilder.Entity("BTCPayServer.Data.HistoricalAddressInvoiceData", b => { b.HasOne("BTCPayServer.Data.InvoiceData", "InvoiceData") diff --git a/BTCPayServer.Tests/BTCPayServer.Tests.csproj b/BTCPayServer.Tests/BTCPayServer.Tests.csproj index 87929b282..bd0492c3f 100644 --- a/BTCPayServer.Tests/BTCPayServer.Tests.csproj +++ b/BTCPayServer.Tests/BTCPayServer.Tests.csproj @@ -53,4 +53,8 @@ + + + + diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj index 8ee871776..d5b1cf8ba 100644 --- a/BTCPayServer/BTCPayServer.csproj +++ b/BTCPayServer/BTCPayServer.csproj @@ -51,6 +51,8 @@ + + diff --git a/BTCPayServer/Components/NotificationsDropdown/Default.cshtml b/BTCPayServer/Components/NotificationsDropdown/Default.cshtml index f7a5c5954..e5c5fdbac 100644 --- a/BTCPayServer/Components/NotificationsDropdown/Default.cshtml +++ b/BTCPayServer/Components/NotificationsDropdown/Default.cshtml @@ -57,7 +57,7 @@ else if (!disabled) { var user = await UserManager.GetUserAsync(User); - disabled = user.DisabledNotifications == "all"; + disabled = user?.DisabledNotifications == "all"; } } @if (!disabled) diff --git a/BTCPayServer/Controllers/AccountController.cs b/BTCPayServer/Controllers/AccountController.cs index b97af77f8..f35323378 100644 --- a/BTCPayServer/Controllers/AccountController.cs +++ b/BTCPayServer/Controllers/AccountController.cs @@ -1,24 +1,25 @@ using System; using System.Globalization; -using System.Security.Policy; using System.Threading.Tasks; using BTCPayServer.Abstractions.Constants; using BTCPayServer.Abstractions.Extensions; using BTCPayServer.Abstractions.Models; using BTCPayServer.Data; using BTCPayServer.Events; +using BTCPayServer.Fido2; +using BTCPayServer.Fido2.Models; using BTCPayServer.Logging; -using BTCPayServer.Models; using BTCPayServer.Models.AccountViewModels; -using BTCPayServer.Security; using BTCPayServer.Services; using BTCPayServer.U2F; using BTCPayServer.U2F.Models; +using Fido2NetLib; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; +using Newtonsoft.Json.Linq; using NicolasDorier.RateLimits; using U2F.Core.Exceptions; @@ -35,7 +36,7 @@ namespace BTCPayServer.Controllers readonly Configuration.BTCPayServerOptions _Options; private readonly BTCPayServerEnvironment _btcPayServerEnvironment; public U2FService _u2FService; - private readonly RateLimitService _rateLimitService; + private readonly Fido2Service _fido2Service; private readonly EventAggregator _eventAggregator; readonly ILogger _logger; @@ -47,8 +48,8 @@ namespace BTCPayServer.Controllers Configuration.BTCPayServerOptions options, BTCPayServerEnvironment btcPayServerEnvironment, U2FService u2FService, - RateLimitService rateLimitService, - EventAggregator eventAggregator) + EventAggregator eventAggregator, + Fido2Service fido2Service) { _userManager = userManager; _signInManager = signInManager; @@ -57,7 +58,7 @@ namespace BTCPayServer.Controllers _Options = options; _btcPayServerEnvironment = btcPayServerEnvironment; _u2FService = u2FService; - _rateLimitService = rateLimitService; + _fido2Service = fido2Service; _eventAggregator = eventAggregator; _logger = Logs.PayServer; } @@ -125,7 +126,9 @@ namespace BTCPayServer.Controllers return View(model); } - if (!await _userManager.IsLockedOutAsync(user) && await _u2FService.HasDevices(user.Id)) + var u2fDevices = await _u2FService.HasDevices(user.Id); + var fido2Devices = await _fido2Service.HasCredentials(user.Id); + if (!await _userManager.IsLockedOutAsync(user) && u2fDevices || fido2Devices) { if (await _userManager.CheckPasswordAsync(user, model.Password)) { @@ -144,7 +147,8 @@ namespace BTCPayServer.Controllers return View("SecondaryLogin", new SecondaryLoginViewModel() { LoginWith2FaViewModel = twoFModel, - LoginWithU2FViewModel = await BuildU2FViewModel(model.RememberMe, user) + LoginWithU2FViewModel = u2fDevices? await BuildU2FViewModel(model.RememberMe, user) : null, + LoginWithFido2ViewModel = fido2Devices? await BuildFido2ViewModel(model.RememberMe, user): null, }); } else @@ -210,6 +214,77 @@ namespace BTCPayServer.Controllers return null; } + private async Task BuildFido2ViewModel(bool rememberMe, ApplicationUser user) + { + if (_btcPayServerEnvironment.IsSecure) + { + var r = await _fido2Service.RequestLogin(user.Id); + if (r is null) + { + return null; + } + return new LoginWithFido2ViewModel() + { + Data = r, + UserId = user.Id, + RememberMe = rememberMe + }; + } + return null; + } + + [HttpPost] + [AllowAnonymous] + [ValidateAntiForgeryToken] + public async Task LoginWithFido2(LoginWithFido2ViewModel viewModel, string returnUrl = null) + { + if (!CanLoginOrRegister()) + { + return RedirectToAction("Login"); + } + + ViewData["ReturnUrl"] = returnUrl; + var user = await _userManager.FindByIdAsync(viewModel.UserId); + + if (user == null) + { + return NotFound(); + } + + var errorMessage = string.Empty; + try + { + if (await _fido2Service.CompleteLogin(viewModel.UserId, JObject.Parse(viewModel.Response).ToObject())) + { + await _signInManager.SignInAsync(user, viewModel.RememberMe, "FIDO2"); + _logger.LogInformation("User logged in."); + return RedirectToLocal(returnUrl); + } + + errorMessage = "Invalid login attempt."; + } + catch (U2fException e) + { + errorMessage = e.Message; + } + + ModelState.AddModelError(string.Empty, errorMessage); + viewModel.Response = null; + return View("SecondaryLogin", new SecondaryLoginViewModel() + { + LoginWithFido2ViewModel = viewModel, + LoginWithU2FViewModel = (await _u2FService.HasDevices(user.Id)) ? await BuildU2FViewModel(viewModel.RememberMe, user) : null, + LoginWith2FaViewModel = !user.TwoFactorEnabled + ? null + : new LoginWith2faViewModel() + { + RememberMe = viewModel.RememberMe + } + }); + } + + + [HttpPost] [AllowAnonymous] [ValidateAntiForgeryToken] @@ -280,7 +355,8 @@ namespace BTCPayServer.Controllers return View("SecondaryLogin", new SecondaryLoginViewModel() { LoginWith2FaViewModel = new LoginWith2faViewModel { RememberMe = rememberMe }, - LoginWithU2FViewModel = (await _u2FService.HasDevices(user.Id)) ? await BuildU2FViewModel(rememberMe, user) : null + LoginWithU2FViewModel = (await _u2FService.HasDevices(user.Id)) ? await BuildU2FViewModel(rememberMe, user) : null, + LoginWithFido2ViewModel = (await _fido2Service.HasCredentials(user.Id)) ? await BuildFido2ViewModel(rememberMe, user) : null, }); } @@ -326,7 +402,8 @@ namespace BTCPayServer.Controllers return View("SecondaryLogin", new SecondaryLoginViewModel() { LoginWith2FaViewModel = model, - LoginWithU2FViewModel = (await _u2FService.HasDevices(user.Id)) ? await BuildU2FViewModel(rememberMe, user) : null + LoginWithU2FViewModel = (await _u2FService.HasDevices(user.Id)) ? await BuildU2FViewModel(rememberMe, user) : null, + LoginWithFido2ViewModel = (await _fido2Service.HasCredentials(user.Id)) ? await BuildFido2ViewModel(rememberMe, user) : null, }); } } diff --git a/BTCPayServer/Fido2/Fido2Controller.cs b/BTCPayServer/Fido2/Fido2Controller.cs new file mode 100644 index 000000000..98b776594 --- /dev/null +++ b/BTCPayServer/Fido2/Fido2Controller.cs @@ -0,0 +1,104 @@ +using System.Threading.Tasks; +using BTCPayServer.Abstractions.Extensions; +using BTCPayServer.Abstractions.Models; +using BTCPayServer.Data; +using BTCPayServer.Fido2; +using BTCPayServer.Models; +using Fido2NetLib; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Newtonsoft.Json.Linq; + +namespace BTCPayServer.U2F.Models +{ + + [Route("fido2")] + [Authorize] + public class Fido2Controller : Controller + { + private readonly UserManager _userManager; + private readonly Fido2Service _fido2Service; + + public Fido2Controller(UserManager userManager, Fido2Service fido2Service) + { + _userManager = userManager; + _fido2Service = fido2Service; + } + + [HttpGet("")] + public async Task List() + { + return View(new Fido2AuthenticationViewModel() + { + Credentials = await _fido2Service.GetCredentials( _userManager.GetUserId(User)) + }); + } + + [HttpGet("{id}/delete")] + public IActionResult Remove(string id) + { + return View("Confirm", new ConfirmModel("Are you sure you want to remove FIDO2 credential?", "Your account will no longer have this credential as an option for MFA.", "Remove")); + } + + [HttpPost("{id}/delete")] + public async Task RemoveP(string id) + { + + await _fido2Service.Remove(id, _userManager.GetUserId(User)); + + TempData.SetStatusMessageModel(new StatusMessageModel + { + Severity = StatusMessageModel.StatusSeverity.Success, + Html = $"FIDO2 Credentials were removed successfully." + }); + + return RedirectToAction(nameof(List)); + } + + [HttpGet("register")] + public async Task Create(AddFido2CredentialViewModel viewModel) + { + var options = await _fido2Service.RequestCreation(_userManager.GetUserId(User)); + if (options is null) + { + TempData.SetStatusMessageModel(new StatusMessageModel + { + Severity = StatusMessageModel.StatusSeverity.Error, + Html = $"FIDO2 Credentials could not be saved." + }); + + return RedirectToAction(nameof(List)); + } + + ViewData["CredentialName"] = viewModel.Name ?? ""; + return View(options); + } + + [HttpPost("register")] + public async Task CreateResponse([FromForm] string data, [FromForm] string name) + { + var attestationResponse = JObject.Parse(data).ToObject(); + if (await _fido2Service.CompleteCreation(_userManager.GetUserId(User), name, attestationResponse)) + { + + TempData.SetStatusMessageModel(new StatusMessageModel + { + Severity = StatusMessageModel.StatusSeverity.Success, + Html = $"FIDO2 Credentials were saved successfully." + }); + } + else + { + TempData.SetStatusMessageModel(new StatusMessageModel + { + Severity = StatusMessageModel.StatusSeverity.Error, + Html = $"FIDO2 Credentials could not be saved." + }); + } + + return RedirectToAction(nameof(List)); + } + + } +} diff --git a/BTCPayServer/Fido2/Fido2Service.cs b/BTCPayServer/Fido2/Fido2Service.cs new file mode 100644 index 000000000..8da2f009e --- /dev/null +++ b/BTCPayServer/Fido2/Fido2Service.cs @@ -0,0 +1,206 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using BTCPayServer.Data; +using ExchangeSharp; +using Fido2NetLib; +using Fido2NetLib.Objects; +using Microsoft.EntityFrameworkCore; +using NBitcoin; + +namespace BTCPayServer.Fido2 +{ + public class Fido2Service + { + private static readonly ConcurrentDictionary CreationStore = + new ConcurrentDictionary(); + private static readonly ConcurrentDictionary LoginStore = + new ConcurrentDictionary(); + private readonly ApplicationDbContextFactory _contextFactory; + private readonly IFido2 _fido2; + + public Fido2Service(ApplicationDbContextFactory contextFactory, IFido2 fido2) + { + _contextFactory = contextFactory; + _fido2 = fido2; + } + + public async Task RequestCreation(string userId) + { + await using var dbContext = _contextFactory.CreateContext(); + var user = await dbContext.Users.Include(applicationUser => applicationUser.Fido2Credentials) + .FirstOrDefaultAsync(applicationUser => applicationUser.Id == userId); + if (user == null) + { + return null; + } + + // 2. Get user existing keys by username + var existingKeys = + user.Fido2Credentials + .Where(credential => credential.Type == Fido2Credential.CredentialType.FIDO2) + .Select(c => c.GetBlob().Descriptor).ToList(); + + // 3. Create options + var authenticatorSelection = new AuthenticatorSelection + { + RequireResidentKey = false, UserVerification = UserVerificationRequirement.Preferred + }; + + var exts = new AuthenticationExtensionsClientInputs() + { + Extensions = true, + UserVerificationIndex = true, + Location = true, + UserVerificationMethod = true, + BiometricAuthenticatorPerformanceBounds = new AuthenticatorBiometricPerfBounds + { + FAR = float.MaxValue, FRR = float.MaxValue + }, + }; + + var options = _fido2.RequestNewCredential( + new Fido2User() {DisplayName = user.UserName, Name = user.UserName, Id = user.Id.ToBytesUTF8()}, + existingKeys, authenticatorSelection, AttestationConveyancePreference.None, exts); + + // options.Rp = new PublicKeyCredentialRpEntity(Request.Host.Host, options.Rp.Name, ""); + CreationStore.AddOrReplace(userId, options); + return options; + } + + public async Task CompleteCreation(string userId, string name, AuthenticatorAttestationRawResponse attestationResponse) + { + 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(attestationResponse, options, args => Task.FromResult(true)); + + // 3. Store the credentials in db + var newCredential = new Fido2Credential() + { + Name = name, + ApplicationUserId = userId + }; + + newCredential.SetBlob(new Fido2CredentialBlob() + { + Descriptor = new PublicKeyCredentialDescriptor(success.Result.CredentialId), + PublicKey = success.Result.PublicKey, + UserHandle = success.Result.User.Id, + SignatureCounter = success.Result.Counter, + CredType = success.Result.CredType, + AaGuid = success.Result.Aaguid.ToString(), + }); + + await dbContext.Fido2Credentials.AddAsync(newCredential); + await dbContext.SaveChangesAsync(); + CreationStore.Remove(userId, out _); + return true; + + + } + + public async Task> GetCredentials(string userId) + { + await using var context = _contextFactory.CreateContext(); + return await context.Fido2Credentials + .Where(device => device.ApplicationUserId == userId) + .ToListAsync(); + } + + public async Task Remove(string id, string userId) + { + await using var context = _contextFactory.CreateContext(); + var device = await context.Fido2Credentials.FindAsync( id); + if (device == null || !device.ApplicationUserId.Equals(userId, StringComparison.InvariantCulture)) + { + return; + } + + context.Fido2Credentials.Remove(device); + await context.SaveChangesAsync(); + } + + public async Task HasCredentials(string userId) + { + await using var context = _contextFactory.CreateContext(); + return await context.Fido2Credentials.Where(fDevice => fDevice.ApplicationUserId == userId).AnyAsync(); + } + + public async Task 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)) + { + return null; + } + var existingCredentials = user.Fido2Credentials + .Where(credential => credential.Type == Fido2Credential.CredentialType.FIDO2) + .Select(c => c.GetBlob().Descriptor) + .ToList(); + var exts = new AuthenticationExtensionsClientInputs() + { + SimpleTransactionAuthorization = "FIDO", + GenericTransactionAuthorization = new TxAuthGenericArg + { + ContentType = "text/plain", + Content = new byte[] { 0x46, 0x49, 0x44, 0x4F } + }, + UserVerificationIndex = true, + Location = true, + UserVerificationMethod = true + }; + + // 3. Create options + var options = _fido2.GetAssertionOptions( + existingCredentials, + UserVerificationRequirement.Discouraged, + exts + ); + LoginStore.AddOrReplace(userId, options); + return options; + } + + public async Task CompleteLogin(string userId, AuthenticatorAssertionRawResponse response){ + await using var dbContext = _contextFactory.CreateContext(); + var user = await dbContext.Users.Include(applicationUser => applicationUser.Fido2Credentials) + .FirstOrDefaultAsync(applicationUser => applicationUser.Id == userId); + if (user == null || !LoginStore.TryGetValue(userId, out var options)) + { + return false; + } + + var credential = user.Fido2Credentials + .Where(fido2Credential => fido2Credential.Type is Fido2Credential.CredentialType.FIDO2) + .Select(fido2Credential => (fido2Credential, fido2Credential.GetBlob())) + .FirstOrDefault(fido2Credential => fido2Credential.Item2.Descriptor.Id.SequenceEqual(response.Id)); + if (credential.Item2 is null) + { + return false; + } + + // 5. Make the assertion + var res = await _fido2.MakeAssertionAsync(response, options, credential.Item2.PublicKey, + credential.Item2.SignatureCounter, x => Task.FromResult(true)); + + // 6. Store the updated counter + credential.Item2.SignatureCounter = res.Counter; + credential.fido2Credential.SetBlob(credential.Item2); + await dbContext.SaveChangesAsync(); + LoginStore.Remove(userId, out _); + + // 7. return OK to client + return true; + } + } +} diff --git a/BTCPayServer/Fido2/FidoExtensions.cs b/BTCPayServer/Fido2/FidoExtensions.cs new file mode 100644 index 000000000..774f98931 --- /dev/null +++ b/BTCPayServer/Fido2/FidoExtensions.cs @@ -0,0 +1,30 @@ +using System; +using NBXplorer; +using Newtonsoft.Json.Linq; + +namespace BTCPayServer.Data +{ + public static class Fido2Extensions + { + public static Fido2CredentialBlob GetBlob(this Fido2Credential credential) + { + var result = credential.Blob == null + ? new Fido2CredentialBlob() + : JObject.Parse(ZipUtils.Unzip(credential.Blob)).ToObject(); + return result; + } + public static bool SetBlob(this Fido2Credential credential, Fido2CredentialBlob descriptor) + { + var original = new Serializer(null).ToString(credential.GetBlob()); + var newBlob = new Serializer(null).ToString(descriptor); + if (original == newBlob) + return false; + credential.Type = Fido2Credential.CredentialType.FIDO2; + credential.Blob = ZipUtils.Zip(newBlob); + return true; + } + + + + } +} diff --git a/BTCPayServer/Fido2/Models/AddFido2CredentialViewModel.cs b/BTCPayServer/Fido2/Models/AddFido2CredentialViewModel.cs new file mode 100644 index 000000000..0819f7724 --- /dev/null +++ b/BTCPayServer/Fido2/Models/AddFido2CredentialViewModel.cs @@ -0,0 +1,11 @@ +using Fido2NetLib.Objects; + +namespace BTCPayServer.U2F.Models +{ + public class AddFido2CredentialViewModel + { + public AuthenticatorAttachment? AuthenticatorAttachment { get; set; } + public string Name { get; set; } + } + +} diff --git a/BTCPayServer/Fido2/Models/Fido2AuthenticationViewModel.cs b/BTCPayServer/Fido2/Models/Fido2AuthenticationViewModel.cs new file mode 100644 index 000000000..4b5402be9 --- /dev/null +++ b/BTCPayServer/Fido2/Models/Fido2AuthenticationViewModel.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using BTCPayServer.Data; + +namespace BTCPayServer.U2F.Models +{ + public class Fido2AuthenticationViewModel + { + public List Credentials { get; set; } + } +} diff --git a/BTCPayServer/Fido2/Models/Fido2CredentialBlob.cs b/BTCPayServer/Fido2/Models/Fido2CredentialBlob.cs new file mode 100644 index 000000000..3523933a9 --- /dev/null +++ b/BTCPayServer/Fido2/Models/Fido2CredentialBlob.cs @@ -0,0 +1,18 @@ +using Fido2NetLib; +using Fido2NetLib.Objects; +using Newtonsoft.Json; + +namespace BTCPayServer.Data +{ + public class Fido2CredentialBlob + { + public PublicKeyCredentialDescriptor Descriptor { get; set; } + [JsonConverter(typeof(Base64UrlConverter))] + public byte[] PublicKey { get; set; } + [JsonConverter(typeof(Base64UrlConverter))] + public byte[] UserHandle { get; set; } + public uint SignatureCounter { get; set; } + public string CredType { get; set; } + public string AaGuid { get; set; } + } +} diff --git a/BTCPayServer/Fido2/Models/LoginWithFido2ViewModel.cs b/BTCPayServer/Fido2/Models/LoginWithFido2ViewModel.cs new file mode 100644 index 000000000..23eb1a61a --- /dev/null +++ b/BTCPayServer/Fido2/Models/LoginWithFido2ViewModel.cs @@ -0,0 +1,13 @@ +using Fido2NetLib; + +namespace BTCPayServer.Fido2.Models +{ + public class LoginWithFido2ViewModel + { + public string UserId { get; set; } + + public bool RememberMe { get; set; } + public AssertionOptions Data { get; set; } + public string Response { get; set; } + } +} diff --git a/BTCPayServer/Hosting/Startup.cs b/BTCPayServer/Hosting/Startup.cs index a27caeb4e..1863f770f 100644 --- a/BTCPayServer/Hosting/Startup.cs +++ b/BTCPayServer/Hosting/Startup.cs @@ -1,8 +1,10 @@ using System; using System.IO; +using System.Linq; using System.Net; using BTCPayServer.Configuration; using BTCPayServer.Data; +using BTCPayServer.Fido2; using BTCPayServer.Filters; using BTCPayServer.Logging; using BTCPayServer.PaymentRequest; @@ -12,9 +14,11 @@ using BTCPayServer.Services.Apps; using BTCPayServer.Storage; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; +using Fido2NetLib; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Server.Kestrel.Core; @@ -78,6 +82,29 @@ namespace BTCPayServer.Hosting services.AddProviderStorage(); services.AddSession(); services.AddSignalR(); + services.AddFido2(options => + { + options.ServerName = "BTCPay Server"; + }) + .AddCachedMetadataService(config => + { + //They'll be used in a "first match wins" way in the order registered + config.AddStaticMetadataRepository(); + }); + var descriptor =services.Single(descriptor => descriptor.ServiceType == typeof(Fido2Configuration)); + services.Remove(descriptor); + services.AddScoped(provider => + { + var httpContext = provider.GetService(); + return new Fido2Configuration() + { + ServerName = "BTCPay Server", + Origin = $"{httpContext.HttpContext.Request.Scheme}://{httpContext.HttpContext.Request.Host}", + ServerDomain = httpContext.HttpContext.Request.Host.Host + }; + }); + services.AddScoped(); + var mvcBuilder= services.AddMvc(o => { o.Filters.Add(new XFrameOptionsAttribute("DENY")); diff --git a/BTCPayServer/Models/AccountViewModels/SecondaryLoginViewModel.cs b/BTCPayServer/Models/AccountViewModels/SecondaryLoginViewModel.cs index 1129100e4..f5ad99247 100644 --- a/BTCPayServer/Models/AccountViewModels/SecondaryLoginViewModel.cs +++ b/BTCPayServer/Models/AccountViewModels/SecondaryLoginViewModel.cs @@ -1,9 +1,11 @@ +using BTCPayServer.Fido2.Models; using BTCPayServer.U2F.Models; namespace BTCPayServer.Models.AccountViewModels { public class SecondaryLoginViewModel { + public LoginWithFido2ViewModel LoginWithFido2ViewModel { get; set; } public LoginWith2faViewModel LoginWith2FaViewModel { get; set; } public LoginWithU2FViewModel LoginWithU2FViewModel { get; set; } } diff --git a/BTCPayServer/Program.cs b/BTCPayServer/Program.cs index 6a87c7a10..05acfa15b 100644 --- a/BTCPayServer/Program.cs +++ b/BTCPayServer/Program.cs @@ -47,6 +47,7 @@ namespace BTCPayServer l.AddFilter("Microsoft", LogLevel.Error); l.AddFilter("System.Net.Http.HttpClient", LogLevel.Critical); l.AddFilter("Microsoft.AspNetCore.Antiforgery.Internal", LogLevel.Critical); + l.AddFilter("Fido2NetLib.DistributedCacheMetadataService", LogLevel.Error); l.AddProvider(new CustomConsoleLogProvider(processor)); }) .UseStartup() diff --git a/BTCPayServer/Views/Account/LoginWithFido2.cshtml b/BTCPayServer/Views/Account/LoginWithFido2.cshtml new file mode 100644 index 000000000..c3dccad84 --- /dev/null +++ b/BTCPayServer/Views/Account/LoginWithFido2.cshtml @@ -0,0 +1,32 @@ +@model BTCPayServer.Fido2.Models.LoginWithFido2ViewModel + +
+ + + + + +
+ +
+
+
+
+

FIDO2 Authentication

+
+
+ +

Insert your security key into your computer's USB port. If it has a button, tap on it.

+
+

+ Retry +
+
+
+
+ + + diff --git a/BTCPayServer/Views/Account/SecondaryLogin.cshtml b/BTCPayServer/Views/Account/SecondaryLogin.cshtml index 89f579e19..6d4024c31 100644 --- a/BTCPayServer/Views/Account/SecondaryLogin.cshtml +++ b/BTCPayServer/Views/Account/SecondaryLogin.cshtml @@ -5,15 +5,15 @@
- @if (Model.LoginWith2FaViewModel != null && Model.LoginWithU2FViewModel != null) + @if (Model.LoginWith2FaViewModel != null && Model.LoginWithU2FViewModel != null && Model.LoginWithFido2ViewModel != null) {
} - else if (Model.LoginWith2FaViewModel == null && Model.LoginWithU2FViewModel == null) + else if (Model.LoginWith2FaViewModel == null && Model.LoginWithU2FViewModel == null && Model.LoginWithFido2ViewModel == null) {
-

Both 2FA and U2F Authentication Methods are not available. Please go to the https endpoint

+

Both 2FA and U2F/FIDO2 Authentication Methods are not available. Please go to the https endpoint.


@@ -33,6 +33,12 @@
} + @if (Model.LoginWithFido2ViewModel != null) + { +
+ +
+ }
diff --git a/BTCPayServer/Views/Fido2/Create.cshtml b/BTCPayServer/Views/Fido2/Create.cshtml new file mode 100644 index 000000000..5c27445dd --- /dev/null +++ b/BTCPayServer/Views/Fido2/Create.cshtml @@ -0,0 +1,25 @@ +@model Fido2NetLib.CredentialCreateOptions +@{ + ViewData.SetActivePageAndTitle(ManageNavPages.Fido2, "Register FIDO2 Credentials"); +} +
+ + +
+
+
+
+ +

Insert your security key into your computer's USB port. If it has a button, tap on it.

+
+

+ Retry +
+
+ + + + diff --git a/BTCPayServer/Views/Fido2/List.cshtml b/BTCPayServer/Views/Fido2/List.cshtml new file mode 100644 index 000000000..994daf6ed --- /dev/null +++ b/BTCPayServer/Views/Fido2/List.cshtml @@ -0,0 +1,44 @@ +@model BTCPayServer.U2F.Models.Fido2AuthenticationViewModel +@{ + ViewData.SetActivePageAndTitle(ManageNavPages.Fido2, "Registered FIDO2 Credentials"); +} + + + + + + + + + + + + @foreach (var device in Model.Credentials) + { + + + + + } + @if (!Model.Credentials.Any()) + { + + + + } + +
NameActions
@(string.IsNullOrEmpty(device.Name)? "Unnamed FIDO2 credential": device.Name) + Remove +
+ No registered credentials +
+
+
+ + + +
+
diff --git a/BTCPayServer/Views/Fido2/_ViewImports.cshtml b/BTCPayServer/Views/Fido2/_ViewImports.cshtml new file mode 100644 index 000000000..381eef44f --- /dev/null +++ b/BTCPayServer/Views/Fido2/_ViewImports.cshtml @@ -0,0 +1 @@ +@using BTCPayServer.Views.Manage diff --git a/BTCPayServer/Views/Fido2/_ViewStart.cshtml b/BTCPayServer/Views/Fido2/_ViewStart.cshtml new file mode 100644 index 000000000..057f5beae --- /dev/null +++ b/BTCPayServer/Views/Fido2/_ViewStart.cshtml @@ -0,0 +1,6 @@ +@{ + Layout = "../Shared/_NavLayout.cshtml"; + ViewBag.MainTitle = "Manage your account"; + + ViewData["NavPartialName"] = "../Manage/_Nav"; +} diff --git a/BTCPayServer/Views/Manage/ManageNavPages.cs b/BTCPayServer/Views/Manage/ManageNavPages.cs index 55990d785..e32539749 100644 --- a/BTCPayServer/Views/Manage/ManageNavPages.cs +++ b/BTCPayServer/Views/Manage/ManageNavPages.cs @@ -2,6 +2,6 @@ namespace BTCPayServer.Views.Manage { public enum ManageNavPages { - Index, ChangePassword, TwoFactorAuthentication, U2F, APIKeys, Notifications + Index, ChangePassword, TwoFactorAuthentication, U2F, APIKeys, Notifications, Fido2 } } diff --git a/BTCPayServer/Views/Manage/_Nav.cshtml b/BTCPayServer/Views/Manage/_Nav.cshtml index 18caf22a9..9d7e4dbd7 100644 --- a/BTCPayServer/Views/Manage/_Nav.cshtml +++ b/BTCPayServer/Views/Manage/_Nav.cshtml @@ -1,12 +1,13 @@ @inject SignInManager SignInManager - - + diff --git a/BTCPayServer/wwwroot/js/webauthn/helpers.js b/BTCPayServer/wwwroot/js/webauthn/helpers.js new file mode 100644 index 000000000..7098cb8d9 --- /dev/null +++ b/BTCPayServer/wwwroot/js/webauthn/helpers.js @@ -0,0 +1,109 @@ +coerceToArrayBuffer = function (thing, name) { + if (typeof thing === "string") { + // base64url to base64 + thing = thing.replace(/-/g, "+").replace(/_/g, "/"); + + // base64 to Uint8Array + var str = window.atob(thing); + var bytes = new Uint8Array(str.length); + for (var i = 0; i < str.length; i++) { + bytes[i] = str.charCodeAt(i); + } + thing = bytes; + } + + // Array to Uint8Array + if (Array.isArray(thing)) { + thing = new Uint8Array(thing); + } + + // Uint8Array to ArrayBuffer + if (thing instanceof Uint8Array) { + thing = thing.buffer; + } + + // error if none of the above worked + if (!(thing instanceof ArrayBuffer)) { + throw new TypeError("could not coerce '" + name + "' to ArrayBuffer"); + } + + return thing; +}; + + +coerceToBase64Url = function (thing) { + // Array or ArrayBuffer to Uint8Array + if (Array.isArray(thing)) { + thing = Uint8Array.from(thing); + } + + if (thing instanceof ArrayBuffer) { + thing = new Uint8Array(thing); + } + + // Uint8Array to base64 + if (thing instanceof Uint8Array) { + var str = ""; + var len = thing.byteLength; + + for (var i = 0; i < len; i++) { + str += String.fromCharCode(thing[i]); + } + thing = window.btoa(str); + } + + if (typeof thing !== "string") { + throw new Error("could not coerce to string"); + } + + // base64 to base64url + // NOTE: "=" at the end of challenge is optional, strip it off here + thing = thing.replace(/\+/g, "-").replace(/\//g, "_").replace(/=*$/g, ""); + + return thing; +}; + + + +// HELPERS + +function showErrorAlert(message, error) { + let footermsg = ''; + if (error) { + footermsg = 'exception:' + error.toString(); + } + console.error(message, footermsg); + 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"); + +} + +function detectFIDOSupport() { + if (window.PublicKeyCredential === undefined || + typeof window.PublicKeyCredential !== "function") { + //$('#register-button').attr("disabled", true); + //$('#login-button').attr("disabled", true); + var el = document.getElementById("error-message"); + el.textContent = "Your browser does not support FIDO2/WebAuthN"; + el.classList.remove("d-none"); + return false; + } + return true; +} + +/** + * + * Get a form value + * @param {any} selector + */ +function value(selector) { + var el = document.querySelector(selector); + if (el.type === "checkbox") { + return el.checked; + } + return el.value; +} diff --git a/BTCPayServer/wwwroot/js/webauthn/login.js b/BTCPayServer/wwwroot/js/webauthn/login.js new file mode 100644 index 000000000..6bcd71ebe --- /dev/null +++ b/BTCPayServer/wwwroot/js/webauthn/login.js @@ -0,0 +1,66 @@ + +if (detectFIDOSupport() && makeAssertionOptions){ + + login(makeAssertionOptions); +} + +async function login(makeAssertionOptions) { + + console.log("Assertion Options Object", makeAssertionOptions); + const challenge = makeAssertionOptions.challenge.replace(/-/g, "+").replace(/_/g, "/"); + makeAssertionOptions.challenge = Uint8Array.from(atob(challenge), c => c.charCodeAt(0)); + + // fix escaping. Change this to coerce + makeAssertionOptions.allowCredentials.forEach(function (listItem) { + var fixedId = listItem.id.replace(/\_/g, "/").replace(/\-/g, "+"); + listItem.id = Uint8Array.from(atob(fixedId), c => c.charCodeAt(0)); + }); + + console.log("Assertion options", makeAssertionOptions); + + + let credential; + try { + credential = await navigator.credentials.get({ publicKey: makeAssertionOptions }) + } catch (err) { + showErrorAlert(err.message ? err.message : err); + return; + } + + try { + await verifyAssertionWithServer(credential); + } catch (e) { + showErrorAlert("Could not verify assertion", e); + } +} + + +/** + * Sends the credential to the the FIDO2 server for assertion + * @param {any} assertedCredential + */ +async function verifyAssertionWithServer(assertedCredential) { + + // Move data into Arrays incase it is super long + let authData = new Uint8Array(assertedCredential.response.authenticatorData); + let clientDataJSON = new Uint8Array(assertedCredential.response.clientDataJSON); + let rawId = new Uint8Array(assertedCredential.rawId); + let sig = new Uint8Array(assertedCredential.response.signature); + const data = { + id: assertedCredential.id, + rawId: coerceToBase64Url(rawId), + type: assertedCredential.type, + extensions: assertedCredential.getClientExtensionResults(), + response: { + authenticatorData: coerceToBase64Url(authData), + clientDataJson: coerceToBase64Url(clientDataJSON), + signature: coerceToBase64Url(sig) + } + }; + + + document.getElementById("Response").value = JSON.stringify(data); + document.getElementById("fidoForm").submit(); +} + + diff --git a/BTCPayServer/wwwroot/js/webauthn/register.js b/BTCPayServer/wwwroot/js/webauthn/register.js new file mode 100644 index 000000000..36bfec1d5 --- /dev/null +++ b/BTCPayServer/wwwroot/js/webauthn/register.js @@ -0,0 +1,67 @@ + +if (detectFIDOSupport() && makeCredentialOptions){ + register(makeCredentialOptions); +} + +async function register(makeCredentialOptions) { + console.log("Credential Options Object", makeCredentialOptions); + // Turn the challenge back into the accepted format of padded base64 + makeCredentialOptions.challenge = coerceToArrayBuffer(makeCredentialOptions.challenge); + // Turn ID into a UInt8Array Buffer for some reason + makeCredentialOptions.user.id = coerceToArrayBuffer(makeCredentialOptions.user.id); + + makeCredentialOptions.excludeCredentials = makeCredentialOptions.excludeCredentials.map((c) => { + c.id = coerceToArrayBuffer(c.id); + return c; + }); + + if (makeCredentialOptions.authenticatorSelection.authenticatorAttachment == null) makeCredentialOptions.authenticatorSelection.authenticatorAttachment = undefined; + + console.log("Credential Options Formatted", makeCredentialOptions); + + + console.log("Creating PublicKeyCredential..."); + + let newCredential; + try { + newCredential = await navigator.credentials.create({ + 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." + showErrorAlert(msg, e); + return; + } + + console.log("PublicKeyCredential Created", newCredential); + + try { + registerNewCredential(newCredential); + + } catch (e) { + showErrorAlert(err.message ? err.message : err); + + } +} + +// This should be used to verify the auth data with the server +async function registerNewCredential(newCredential) { + // Move data into Arrays incase it is super long + let attestationObject = new Uint8Array(newCredential.response.attestationObject); + let clientDataJSON = new Uint8Array(newCredential.response.clientDataJSON); + let rawId = new Uint8Array(newCredential.rawId); + + const data = { + id: newCredential.id, + rawId: coerceToBase64Url(rawId), + type: newCredential.type, + extensions: newCredential.getClientExtensionResults(), + response: { + AttestationObject: coerceToBase64Url(attestationObject), + clientDataJson: coerceToBase64Url(clientDataJSON) + } + }; + + document.getElementById("data").value = JSON.stringify(data); + document.getElementById("registerForm").submit(); +}