diff --git a/Oqtane.Client/Modules/Admin/UserProfile/Index.razor b/Oqtane.Client/Modules/Admin/UserProfile/Index.razor index cee52e7a..1f829f67 100644 --- a/Oqtane.Client/Modules/Admin/UserProfile/Index.razor +++ b/Oqtane.Client/Modules/Admin/UserProfile/Index.razor @@ -97,7 +97,7 @@

@if (_allowtwofactor) { -
+
@@ -111,11 +111,34 @@
} -
+
-
+
+ + +
+   +   + @Localizer["Passkey"] +
+ + @if (context.CredentialId != _passkeyId) + { + + + @context.Name + } + else + { + + + + } + +
+

-
+

@@ -411,6 +434,10 @@ private File _photo = null; private string _imagefiles = string.Empty; + private List _passkeys; + private byte[] _passkeyId; + private string _passkeyName = string.Empty; + private List _profiles; private Dictionary _userSettings; private string _category = string.Empty; @@ -603,6 +630,51 @@ } } + private async Task GetPasskeys() + { + _passkeys = await UserService.GetPasskeysAsync(); + } + + private async Task AddPasskey() + { + _passkeyName = $"{PageState.User.DisplayName}{_passkeys.Count + 1}"; // set default name + await UserService.AddPasskeyAsync(new Passkey { Name = _passkeyName, CredentialJson = "" }); + await GetPasskeys(); + StateHasChanged(); + } + + private void EditPasskey(Passkey passkey) + { + _passkeyId = passkey.CredentialId; + _passkeyName = passkey.Name; + StateHasChanged(); + } + + private async Task DeletePasskey(Passkey passkey) + { + await UserService.DeletePasskeyAsync(passkey.CredentialId); + await GetPasskeys(); + StateHasChanged(); + } + + private async Task SavePasskey() + { + if (!string.IsNullOrEmpty(_passkeyName)) + { + await UserService.UpdatePasskeyAsync(new Passkey { CredentialId = _passkeyId, Name = _passkeyName }); + await GetPasskeys(); + _passkeyName = ""; + StateHasChanged(); + } + } + + private async Task CancelPasskey() + { + await GetPasskeys(); + _passkeyName = ""; + StateHasChanged(); + } + private bool ValidateProfiles() { foreach (Profile profile in _profiles) diff --git a/Oqtane.Client/Services/UserService.cs b/Oqtane.Client/Services/UserService.cs index ca5203e4..6d249b5a 100644 --- a/Oqtane.Client/Services/UserService.cs +++ b/Oqtane.Client/Services/UserService.cs @@ -172,6 +172,33 @@ namespace Oqtane.Services /// Indicates if new users should be notified by email /// Task> ImportUsersAsync(int siteId, int fileId, bool notify); + + /// + /// Get passkeys for a user + /// + /// + Task> GetPasskeysAsync(); + + /// + /// Add a user passkey + /// + /// + /// + Task AddPasskeyAsync(Passkey passkey); + + /// + /// Update a user passkey + /// + /// + /// + Task UpdatePasskeyAsync(Passkey passkey); + + /// + /// Delete a user passkey + /// + /// + /// + Task DeletePasskeyAsync(byte[] credentialId); } [PrivateApi("Don't show in the documentation, as everything should use the Interface")] @@ -302,5 +329,25 @@ namespace Oqtane.Services { return await PostJsonAsync>($"{Apiurl}/import?siteid={siteId}&fileid={fileId}¬ify={notify}", null); } + + public async Task> GetPasskeysAsync() + { + return await GetJsonAsync>($"{Apiurl}/passkey"); + } + + public async Task AddPasskeyAsync(Passkey passkey) + { + return await PostJsonAsync($"{Apiurl}/passkey", passkey); + } + + public async Task UpdatePasskeyAsync(Passkey passkey) + { + return await PutJsonAsync($"{Apiurl}/passkey", passkey); + } + + public async Task DeletePasskeyAsync(byte[] credentialId) + { + await DeleteAsync($"{Apiurl}/passkey?id={credentialId}"); + } } } diff --git a/Oqtane.Server/Controllers/UserController.cs b/Oqtane.Server/Controllers/UserController.cs index 33a934f1..2141acc6 100644 --- a/Oqtane.Server/Controllers/UserController.cs +++ b/Oqtane.Server/Controllers/UserController.cs @@ -463,5 +463,55 @@ namespace Oqtane.Controllers return null; } } + + // GET: api//passkey + [HttpGet("passkey")] + [Authorize] + public async Task> GetPasskeys() + { + return await _userManager.GetPasskeys(_userPermissions.GetUser(User).UserId); + } + + // POST api//passkey + [HttpPost("passkey")] + [Authorize] + public async Task AddPasskey([FromBody] Passkey passkey) + { + if (ModelState.IsValid) + { + passkey.UserId = _userPermissions.GetUser(User).UserId; + await _userManager.AddPasskey(passkey); + } + else + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized User Passkey Post Attempt {PassKey}", passkey); + HttpContext.Response.StatusCode = (int)HttpStatusCode.Forbidden; + } + } + + // PUT api//passkey + [HttpPut("passkey")] + [Authorize] + public async Task UpdatePasskey([FromBody] Passkey passkey) + { + if (ModelState.IsValid) + { + passkey.UserId = _userPermissions.GetUser(User).UserId; + await _userManager.UpdatePasskey(passkey); + } + else + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized User Passkey Put Attempt {PassKey}", passkey); + HttpContext.Response.StatusCode = (int)HttpStatusCode.Forbidden; + } + } + + // DELETE api//passkey?id=x + [HttpDelete("passkey")] + [Authorize] + public async Task DeletePasskey(byte[] id) + { + await _userManager.DeletePasskey(_userPermissions.GetUser(User).UserId, id); + } } } diff --git a/Oqtane.Server/Managers/UserManager.cs b/Oqtane.Server/Managers/UserManager.cs index 5b5514ee..8c4bf457 100644 --- a/Oqtane.Server/Managers/UserManager.cs +++ b/Oqtane.Server/Managers/UserManager.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.IO; using System.Linq; using System.Net; +using System.Security.Policy; using System.Threading.Tasks; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Caching.Memory; @@ -35,6 +36,10 @@ namespace Oqtane.Managers Task ValidateUser(string username, string email, string password); Task ValidatePassword(string password); Task> ImportUsers(int siteId, string filePath, bool notify); + Task> GetPasskeys(int userId); + Task AddPasskey(Passkey passkey); + Task UpdatePasskey(Passkey passkey); + Task DeletePasskey(int userId, byte[] credentialId); } public class UserManager : IUserManager @@ -369,15 +374,6 @@ namespace Oqtane.Managers IdentityUser identityuser = await _identityUserManager.FindByNameAsync(user.Username); if (identityuser != null) { - try - { - var passKeysFunctional = await _identityUserManager.GetPasskeysAsync(identityuser); - } - catch (Exception ex) - { - var error = ex.ToString(); - } - var result = await _identitySignInManager.CheckPasswordSignInAsync(identityuser, user.Password, true); if (result.Succeeded) { @@ -827,5 +823,72 @@ namespace Oqtane.Managers return result; } + + public async Task> GetPasskeys(int userId) + { + var passkeys = new List(); + var user = _users.GetUser(userId); + if (user != null) + { + var identityuser = await _identityUserManager.FindByNameAsync(user.Username); + if (identityuser != null) + { + var userpasskeys = await _identityUserManager.GetPasskeysAsync(identityuser); + foreach (var userpasskey in userpasskeys) + { + passkeys.Add(new Passkey { CredentialId = userpasskey.CredentialId, Name = userpasskey.Name, UserId = userId }); + } + } + } + return passkeys; + } + + public async Task AddPasskey(Passkey passkey) + { + var user = _users.GetUser(passkey.UserId); + if (user != null) + { + var identityuser = await _identityUserManager.FindByNameAsync(user.Username); + if (identityuser != null) + { + var attestationResult = await _identitySignInManager.PerformPasskeyAttestationAsync(passkey.CredentialJson); + if (attestationResult.Succeeded) + { + var addPasskeyResult = await _identityUserManager.AddOrUpdatePasskeyAsync(identityuser, attestationResult.Passkey); + } + } + } + } + + public async Task UpdatePasskey(Passkey passkey) + { + var user = _users.GetUser(passkey.UserId); + if (user != null) + { + var identityuser = await _identityUserManager.FindByNameAsync(user.Username); + if (identityuser != null) + { + var userPasskeyInfo = await _identityUserManager.GetPasskeyAsync(identityuser, passkey.CredentialId); + if (userPasskeyInfo != null) + { + userPasskeyInfo.Name = passkey.Name; + await _identityUserManager.AddOrUpdatePasskeyAsync(identityuser, userPasskeyInfo); + } + } + } + } + + public async Task DeletePasskey(int userId, byte[] credentialId) + { + var user = _users.GetUser(userId); + if (user != null) + { + var identityuser = await _identityUserManager.FindByNameAsync(user.Username); + if (identityuser != null) + { + await _identityUserManager.RemovePasskeyAsync(identityuser, credentialId); + } + } + } } } diff --git a/Oqtane.Server/Migrations/EntityBuilders/AspNetUserTokensEntityBuilder.cs b/Oqtane.Server/Migrations/EntityBuilders/AspNetUserTokensEntityBuilder.cs new file mode 100644 index 00000000..16bf640b --- /dev/null +++ b/Oqtane.Server/Migrations/EntityBuilders/AspNetUserTokensEntityBuilder.cs @@ -0,0 +1,42 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations.Operations; +using Microsoft.EntityFrameworkCore.Migrations.Operations.Builders; +using Oqtane.Databases.Interfaces; + +// ReSharper disable MemberCanBePrivate.Global +// ReSharper disable UnusedAutoPropertyAccessor.Global + +namespace Oqtane.Migrations.EntityBuilders +{ + public class AspNetUserTokensEntityBuilder : BaseEntityBuilder + { + private const string _entityTableName = "AspNetUserTokens"; + private readonly PrimaryKey _primaryKey = new("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); + private readonly ForeignKey _aspNetUsersForeignKey = new("FK_AspNetUserTokens_AspNetUsers_UserId", x => x.UserId, "AspNetUsers", "Id", ReferentialAction.Cascade); + + public AspNetUserTokensEntityBuilder(MigrationBuilder migrationBuilder, IDatabase database) : base(migrationBuilder, database) + { + EntityTableName = _entityTableName; + PrimaryKey = _primaryKey; + ForeignKeys.Add(_aspNetUsersForeignKey); + } + + protected override AspNetUserTokensEntityBuilder BuildTable(ColumnsBuilder table) + { + UserId = AddStringColumn(table, "UserId", 450); + LoginProvider = AddStringColumn(table, "LoginProvider", 128); + Name = AddStringColumn(table, "Name", 128); + Value = AddMaxStringColumn(table, "Value", true); + + return this; + } + + public OperationBuilder UserId { get; set; } + + public OperationBuilder LoginProvider { get; set; } + + public OperationBuilder Name { get; set; } + + public OperationBuilder Value { get; set; } + } +} diff --git a/Oqtane.Server/Migrations/Tenant/10000002_AddAspNetUserTokens.cs b/Oqtane.Server/Migrations/Tenant/10000002_AddAspNetUserTokens.cs new file mode 100644 index 00000000..4a66848e --- /dev/null +++ b/Oqtane.Server/Migrations/Tenant/10000002_AddAspNetUserTokens.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Oqtane.Databases.Interfaces; +using Oqtane.Migrations.EntityBuilders; +using Oqtane.Repository; + +namespace Oqtane.Migrations.Tenant +{ + [DbContext(typeof(TenantDBContext))] + [Migration("Tenant.10.00.00.02")] + public class AddAspNetUserTokens : MultiDatabaseMigration + { + public AddAspNetUserTokens(IDatabase database) : base(database) + { + } + + protected override void Up(MigrationBuilder migrationBuilder) + { + var aspNetUserTokensEntityBuilder = new AspNetUserTokensEntityBuilder(migrationBuilder, ActiveDatabase); + aspNetUserTokensEntityBuilder.Create(); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + // not implemented + } + } +} diff --git a/Oqtane.Shared/Models/Passkey.cs b/Oqtane.Shared/Models/Passkey.cs new file mode 100644 index 00000000..31671890 --- /dev/null +++ b/Oqtane.Shared/Models/Passkey.cs @@ -0,0 +1,28 @@ +namespace Oqtane.Models +{ + /// + /// Passkey properties + /// + public class Passkey + { + /// + /// the credential ID for this passkey + /// + public byte[] CredentialId { get; set; } + + /// + /// The friendly name of the passkey + /// + public string Name { get; set; } + + /// + /// The User which this passkey belongs to + /// + public int UserId { get; set; } + + /// + /// A serialized JSON object from the navigator.credentials.create() JavaScript API - only populated during Add + /// + public string CredentialJson { get; set; } + } +}