@ -97,7 +97,7 @@
|
||||
<br /><br />
|
||||
@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="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>
|
||||
@ -111,11 +111,34 @@
|
||||
</div>
|
||||
</Section>
|
||||
}
|
||||
<Section Name="External" Heading="External Login" ResourceKey="External" Expanded="true">
|
||||
<Section Name="External" Heading="External Login" ResourceKey="External">
|
||||
</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;"> </th>
|
||||
<th style="width: 1px;"> </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 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>
|
||||
</Section>
|
||||
<br />
|
||||
@ -411,6 +434,10 @@
|
||||
private File _photo = null;
|
||||
private string _imagefiles = string.Empty;
|
||||
|
||||
private List<Passkey> _passkeys;
|
||||
private byte[] _passkeyId;
|
||||
private string _passkeyName = string.Empty;
|
||||
|
||||
private List<Profile> _profiles;
|
||||
private Dictionary<string, string> _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)
|
||||
|
||||
@ -172,6 +172,33 @@ namespace Oqtane.Services
|
||||
/// <param name="notify">Indicates if new users should be notified by email</param>
|
||||
/// <returns></returns>
|
||||
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")]
|
||||
@ -302,5 +329,25 @@ namespace Oqtane.Services
|
||||
{
|
||||
return await PostJsonAsync<Dictionary<string, string>>($"{Apiurl}/import?siteid={siteId}&fileid={fileId}¬ify={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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -463,5 +463,55 @@ namespace Oqtane.Controllers
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<UserValidateResult> ValidateUser(string username, string email, string password);
|
||||
Task<bool> ValidatePassword(string password);
|
||||
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
|
||||
@ -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<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
28
Oqtane.Shared/Models/Passkey.cs
Normal file
28
Oqtane.Shared/Models/Passkey.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user