Merge pull request #5744 from sbwalker/dev

add passkey infrastructure
This commit is contained in:
Shaun Walker
2025-10-23 12:46:53 -04:00
committed by GitHub
7 changed files with 343 additions and 13 deletions

View File

@@ -97,7 +97,7 @@
<br /><br /> <br /><br />
@if (_allowtwofactor) @if (_allowtwofactor)
{ {
<Section Name="MFA" Heading="Multi-Factor Authentication" ResourceKey="MFA" Expanded="true"> <Section Name="MFA" Heading="Multi-Factor Authentication" ResourceKey="MFA">
<div class="container"> <div class="container">
<div class="row mb-1 align-items-center"> <div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="twofactor" HelpText="Indicates if you are using two factor authentication. Two factor authentication requires you to enter a verification code sent via email after you sign in." ResourceKey="TwoFactor"></Label> <Label Class="col-sm-3" For="twofactor" HelpText="Indicates if you are using two factor authentication. Two factor authentication requires you to enter a verification code sent via email after you sign in." ResourceKey="TwoFactor"></Label>
@@ -111,11 +111,34 @@
</div> </div>
</Section> </Section>
} }
<Section Name="External" Heading="External Login" ResourceKey="External" Expanded="true"> <Section Name="External" Heading="External Login" ResourceKey="External">
</Section> </Section>
<Section Name="Passkeys" Heading="Passkeys" ResourceKey="Passkeys" Expanded="true"> <Section Name="Passkeys" Heading="Passkeys" ResourceKey="Passkeys">
<button type="button" class="btn btn-primary" @onclick="AddPasskey">@SharedLocalizer["Add"]</button>
<Pager Items="@_passkeys">
<Header>
<th style="width: 1px;">&nbsp;</th>
<th style="width: 1px;">&nbsp;</th>
<th>@Localizer["Passkey"]</th>
</Header>
<Row>
@if (context.CredentialId != _passkeyId)
{
<td><button type="button" class="btn btn-primary" @onclick="@(() => EditPasskey(context))">@SharedLocalizer["Edit"]</button></td>
<td><ActionDialog Action="Delete" OnClick="@(async () => await DeletePasskey(context))" ResourceKey="DeleteAlias" Class="btn btn-danger" Header="Delete Alias" Message="@string.Format(Localizer["Confirm.Passkey.Delete", context.CredentialId])" /></td>
<td>@context.Name</td>
}
else
{
<td><button type="button" class="btn btn-success" @onclick="@(async () => await SavePasskey())">@SharedLocalizer["Save"]</button></td>
<td><button type="button" class="btn btn-secondary" @onclick="@(async () => await CancelPasskey())">@SharedLocalizer["Cancel"]</button></td>
<td><input id="aliasname" class="form-control" @bind="@_passkeyName" /></td>
}
</Row>
</Pager>
<br /><br />
</Section> </Section>
<Section Name="Logout" Heading="Logout" ResourceKey="Logout" Expanded="true"> <Section Name="Logout" Heading="Logout" ResourceKey="Logout">
<button type="button" class="btn btn-danger" @onclick="Logout">@Localizer["Logout Everywhere"]</button> <button type="button" class="btn btn-danger" @onclick="Logout">@Localizer["Logout Everywhere"]</button>
</Section> </Section>
<br /> <br />
@@ -411,6 +434,10 @@
private File _photo = null; private File _photo = null;
private string _imagefiles = string.Empty; private string _imagefiles = string.Empty;
private List<Passkey> _passkeys;
private byte[] _passkeyId;
private string _passkeyName = string.Empty;
private List<Profile> _profiles; private List<Profile> _profiles;
private Dictionary<string, string> _userSettings; private Dictionary<string, string> _userSettings;
private string _category = string.Empty; 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() private bool ValidateProfiles()
{ {
foreach (Profile profile in _profiles) foreach (Profile profile in _profiles)

View File

@@ -172,6 +172,33 @@ namespace Oqtane.Services
/// <param name="notify">Indicates if new users should be notified by email</param> /// <param name="notify">Indicates if new users should be notified by email</param>
/// <returns></returns> /// <returns></returns>
Task<Dictionary<string, string>> ImportUsersAsync(int siteId, int fileId, bool notify); Task<Dictionary<string, string>> ImportUsersAsync(int siteId, int fileId, bool notify);
/// <summary>
/// Get passkeys for a user
/// </summary>
/// <returns></returns>
Task<List<Passkey>> GetPasskeysAsync();
/// <summary>
/// Add a user passkey
/// </summary>
/// <param name="passkey"></param>
/// <returns></returns>
Task<Passkey> AddPasskeyAsync(Passkey passkey);
/// <summary>
/// Update a user passkey
/// </summary>
/// <param name="passkey"></param>
/// <returns></returns>
Task<Passkey> UpdatePasskeyAsync(Passkey passkey);
/// <summary>
/// Delete a user passkey
/// </summary>
/// <param name="credentialId"></param>
/// <returns></returns>
Task DeletePasskeyAsync(byte[] credentialId);
} }
[PrivateApi("Don't show in the documentation, as everything should use the Interface")] [PrivateApi("Don't show in the documentation, as everything should use the Interface")]
@@ -302,5 +329,25 @@ namespace Oqtane.Services
{ {
return await PostJsonAsync<Dictionary<string, string>>($"{Apiurl}/import?siteid={siteId}&fileid={fileId}&notify={notify}", null); return await PostJsonAsync<Dictionary<string, string>>($"{Apiurl}/import?siteid={siteId}&fileid={fileId}&notify={notify}", null);
} }
public async Task<List<Passkey>> GetPasskeysAsync()
{
return await GetJsonAsync<List<Passkey>>($"{Apiurl}/passkey");
}
public async Task<Passkey> AddPasskeyAsync(Passkey passkey)
{
return await PostJsonAsync<Passkey>($"{Apiurl}/passkey", passkey);
}
public async Task<Passkey> UpdatePasskeyAsync(Passkey passkey)
{
return await PutJsonAsync<Passkey>($"{Apiurl}/passkey", passkey);
}
public async Task DeletePasskeyAsync(byte[] credentialId)
{
await DeleteAsync($"{Apiurl}/passkey?id={credentialId}");
}
} }
} }

View File

@@ -463,5 +463,55 @@ namespace Oqtane.Controllers
return null; return null;
} }
} }
// GET: api/<controller>/passkey
[HttpGet("passkey")]
[Authorize]
public async Task<IEnumerable<Passkey>> GetPasskeys()
{
return await _userManager.GetPasskeys(_userPermissions.GetUser(User).UserId);
}
// POST api/<controller>/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/<controller>/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/<controller>/passkey?id=x
[HttpDelete("passkey")]
[Authorize]
public async Task DeletePasskey(byte[] id)
{
await _userManager.DeletePasskey(_userPermissions.GetUser(User).UserId, id);
}
} }
} }

View File

@@ -4,6 +4,7 @@ using System.Globalization;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Net; using System.Net;
using System.Security.Policy;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
@@ -35,6 +36,10 @@ namespace Oqtane.Managers
Task<UserValidateResult> ValidateUser(string username, string email, string password); Task<UserValidateResult> ValidateUser(string username, string email, string password);
Task<bool> ValidatePassword(string password); Task<bool> ValidatePassword(string password);
Task<Dictionary<string, string>> ImportUsers(int siteId, string filePath, bool notify); Task<Dictionary<string, string>> ImportUsers(int siteId, string filePath, bool notify);
Task<List<Passkey>> GetPasskeys(int userId);
Task AddPasskey(Passkey passkey);
Task UpdatePasskey(Passkey passkey);
Task DeletePasskey(int userId, byte[] credentialId);
} }
public class UserManager : IUserManager public class UserManager : IUserManager
@@ -369,15 +374,6 @@ namespace Oqtane.Managers
IdentityUser identityuser = await _identityUserManager.FindByNameAsync(user.Username); IdentityUser identityuser = await _identityUserManager.FindByNameAsync(user.Username);
if (identityuser != null) 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); var result = await _identitySignInManager.CheckPasswordSignInAsync(identityuser, user.Password, true);
if (result.Succeeded) if (result.Succeeded)
{ {
@@ -827,5 +823,72 @@ namespace Oqtane.Managers
return result; return result;
} }
public async Task<List<Passkey>> GetPasskeys(int userId)
{
var passkeys = new List<Passkey>();
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);
}
}
}
} }
} }

View File

@@ -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<AspNetUserTokensEntityBuilder>
{
private const string _entityTableName = "AspNetUserTokens";
private readonly PrimaryKey<AspNetUserTokensEntityBuilder> _primaryKey = new("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name });
private readonly ForeignKey<AspNetUserTokensEntityBuilder> _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<AddColumnOperation> UserId { get; set; }
public OperationBuilder<AddColumnOperation> LoginProvider { get; set; }
public OperationBuilder<AddColumnOperation> Name { get; set; }
public OperationBuilder<AddColumnOperation> Value { get; set; }
}
}

View File

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

View File

@@ -0,0 +1,28 @@
namespace Oqtane.Models
{
/// <summary>
/// Passkey properties
/// </summary>
public class Passkey
{
/// <summary>
/// the credential ID for this passkey
/// </summary>
public byte[] CredentialId { get; set; }
/// <summary>
/// The friendly name of the passkey
/// </summary>
public string Name { get; set; }
/// <summary>
/// The User which this passkey belongs to
/// </summary>
public int UserId { get; set; }
/// <summary>
/// A serialized JSON object from the navigator.credentials.create() JavaScript API - only populated during Add
/// </summary>
public string CredentialJson { get; set; }
}
}