Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Volo.Abp.Identity.Settings;
using Volo.Abp.Settings;
using Volo.Abp.Timing;

namespace Volo.Abp.Identity.AspNetCore;

Expand Down Expand Up @@ -42,7 +40,7 @@ public AbpSignInManager(
_identityUserManager = userManager;
}

public async override Task<SignInResult> PasswordSignInAsync(
public override async Task<SignInResult> PasswordSignInAsync(
string userName,
string password,
bool isPersistent,
Expand Down Expand Up @@ -86,7 +84,17 @@ public async override Task<SignInResult> PasswordSignInAsync(
return await base.PasswordSignInAsync(userName, password, isPersistent, lockoutOnFailure);
}

protected async override Task<SignInResult> PreSignInCheck(IdentityUser user)
/// <summary>
/// This is to call the protection method PreSignInCheck
/// </summary>
/// <param name="user">The user</param>
/// <returns>Null if the user should be allowed to sign in, otherwise the SignInResult why they should be denied.</returns>
public virtual async Task<SignInResult> CallPreSignInCheckAsync(IdentityUser user)
{
return await PreSignInCheck(user);
}

protected override async Task<SignInResult> PreSignInCheck(IdentityUser user)
{
if (!user.IsActive)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace Volo.Abp.Identity;

public static class IdentityUserPasskeyConsts
{
/// <summary>
/// Default value: 1024
/// </summary>
public static int MaxCredentialIdLength { get; set; } = 1024;
}
Original file line number Diff line number Diff line change
Expand Up @@ -160,4 +160,9 @@ Task UpdateOrganizationAsync(
Task<List<IdentityUserIdWithRoleNames>> GetRoleNamesAsync(
IEnumerable<Guid> userIds,
CancellationToken cancellationToken = default);

Task<IdentityUser> FindByPasskeyIdAsync(
byte[] credentialId,
bool includeDetails = true,
CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
using System;

namespace Volo.Abp.Identity;

/// <summary>
/// Represents data associated with a passkey.
/// </summary>
public class IdentityPasskeyData
{
/// <summary>
/// Gets or sets the public key associated with this passkey.
/// </summary>
public virtual byte[] PublicKey { get; set; }

/// <summary>
/// Gets or sets the friendly name for this passkey.
/// </summary>
public virtual string? Name { get; set; }

/// <summary>
/// Gets or sets the time this passkey was created.
/// </summary>
public virtual DateTimeOffset CreatedAt { get; set; }

/// <summary>
/// Gets or sets the signature counter for this passkey.
/// </summary>
public virtual uint SignCount { get; set; }

/// <summary>
/// Gets or sets the transports supported by this passkey.
/// </summary>
/// <remarks>
/// See <see href="https://www.w3.org/TR/webauthn-3/#enumdef-authenticatortransport"/>.
/// </remarks>
public virtual string[] Transports { get; set; }

/// <summary>
/// Gets or sets whether the passkey has a verified user.
/// </summary>
public virtual bool IsUserVerified { get; set; }

/// <summary>
/// Gets or sets whether the passkey is eligible for backup.
/// </summary>
public virtual bool IsBackupEligible { get; set; }

/// <summary>
/// Gets or sets whether the passkey is currently backed up.
/// </summary>
public virtual bool IsBackedUp { get; set; }

/// <summary>
/// Gets or sets the attestation object associated with this passkey.
/// </summary>
/// <remarks>
/// See <see href="https://www.w3.org/TR/webauthn-3/#attestation-object"/>.
/// </remarks>
public virtual byte[] AttestationObject { get; set; }

/// <summary>
/// Gets or sets the collected client data JSON associated with this passkey.
/// </summary>
/// <remarks>
/// See <see href="https://www.w3.org/TR/webauthn-3/#dictdef-collectedclientdata"/>.
/// </remarks>
public virtual byte[] ClientDataJson { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,11 @@ public class IdentityUser : FullAuditedAggregateRoot<Guid>, IUser, IHasEntityVer
/// </summary>
public virtual DateTimeOffset? LastPasswordChangeTime { get; protected set; }

/// <summary>
/// Gets or sets the last sign-in time for the user.
/// </summary>
public virtual DateTimeOffset? LastSignInTime { get; protected set; }

//TODO: Can we make collections readonly collection, which will provide encapsulation. But... can work for all ORMs?

/// <summary>
Expand Down Expand Up @@ -159,6 +164,11 @@ public class IdentityUser : FullAuditedAggregateRoot<Guid>, IUser, IHasEntityVer
/// </summary>
public virtual ICollection<IdentityUserPasswordHistory> PasswordHistories { get; protected set; }

/// <summary>
/// Navigation property for this users passkeys.
/// </summary>
public virtual ICollection<IdentityUserPasskey> Passkeys { get; protected set; }

protected IdentityUser()
{
}
Expand Down Expand Up @@ -188,6 +198,7 @@ public IdentityUser(
Tokens = new Collection<IdentityUserToken>();
OrganizationUnits = new Collection<IdentityUserOrganizationUnit>();
PasswordHistories = new Collection<IdentityUserPasswordHistory>();
Passkeys = new Collection<IdentityUserPasskey>();
}

public virtual void AddRole(Guid roleId)
Expand Down Expand Up @@ -403,6 +414,27 @@ public virtual void SetLastPasswordChangeTime(DateTimeOffset? lastPasswordChange
LastPasswordChangeTime = lastPasswordChangeTime;
}

public virtual void SetLastSignInTime(DateTimeOffset? lastSignInTime)
{
LastSignInTime = lastSignInTime;
}

[CanBeNull]
public virtual IdentityUserPasskey FindPasskey(byte[] credentialId)
{
return Passkeys.FirstOrDefault(x => x.UserId == Id && x.CredentialId.SequenceEqual(credentialId));
}

public virtual void AddPasskey(byte[] credentialId, IdentityPasskeyData passkeyData)
{
Passkeys.Add(new IdentityUserPasskey(Id, credentialId, passkeyData, TenantId));
}

public virtual void RemovePasskey(byte[] credentialId)
{
Passkeys.RemoveAll(x => x.CredentialId.SequenceEqual(credentialId));
}

public override string ToString()
{
return $"{base.ToString()}, UserName = {UserName}";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
using System;
using Volo.Abp.Domain.Entities;
using Volo.Abp.MultiTenancy;

namespace Volo.Abp.Identity;

/// <summary>
/// Represents a passkey credential for a user in the identity system.
/// </summary>
/// <remarks>
/// See <see href="https://www.w3.org/TR/webauthn-3/#credential-record"/>.
/// </remarks>
public class IdentityUserPasskey : Entity, IMultiTenant
{
public virtual Guid? TenantId { get; protected set; }

/// <summary>
/// Gets or sets the primary key of the user that owns this passkey.
/// </summary>
public virtual Guid UserId { get; protected set; }

/// <summary>
/// Gets or sets the credential ID for this passkey.
/// </summary>
public virtual byte[] CredentialId { get; set; }

/// <summary>
/// Gets or sets additional data associated with this passkey.
/// </summary>
public virtual IdentityPasskeyData Data { get; set; }

protected IdentityUserPasskey()
{

}

public IdentityUserPasskey(
Guid userId,
byte[] credentialId,
IdentityPasskeyData data,
Guid? tenantId)
{
UserId = userId;
CredentialId = credentialId;
Data = data;
TenantId = tenantId;
}

public override object[] GetKeys()
{
return new object[] { UserId, CredentialId };
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using Microsoft.AspNetCore.Identity;

namespace Volo.Abp.Identity;

public static class IdentityUserPasskeyExtensions
{
public static void UpdateFromUserPasskeyInfo(this IdentityUserPasskey passkey, UserPasskeyInfo passkeyInfo)
{
passkey.Data.Name = passkeyInfo.Name;
passkey.Data.SignCount = passkeyInfo.SignCount;
passkey.Data.IsBackedUp = passkeyInfo.IsBackedUp;
passkey.Data.IsUserVerified = passkeyInfo.IsUserVerified;
}

public static UserPasskeyInfo ToUserPasskeyInfo(this IdentityUserPasskey passkey)
{
return new UserPasskeyInfo(
passkey.CredentialId,
passkey.Data.PublicKey,
passkey.Data.CreatedAt,
passkey.Data.SignCount,
passkey.Data.Transports,
passkey.Data.IsUserVerified,
passkey.Data.IsBackupEligible,
passkey.Data.IsBackedUp,
passkey.Data.AttestationObject,
passkey.Data.ClientDataJson)
{
Name = passkey.Data.Name
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public class IdentityUserStore :
IUserAuthenticationTokenStore<IdentityUser>,
IUserAuthenticatorKeyStore<IdentityUser>,
IUserTwoFactorRecoveryCodeStore<IdentityUser>,
IUserPasskeyStore<IdentityUser>,
ITransientDependency
{
private const string InternalLoginProvider = "[AspNetUserStore]";
Expand Down Expand Up @@ -1123,6 +1124,110 @@ public virtual Task<string> GetRecoveryCodeTokenNameAsync()
return Task.FromResult(RecoveryCodeTokenName);
}

/// <summary>
/// Creates a new passkey credential in the store for the specified <paramref name="user"/>,
/// or updates an existing passkey.
/// </summary>
/// <param name="user">The user to create the passkey credential for.</param>
/// <param name="passkey"></param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> used to propagate notifications that the operation should be canceled.</param>
/// <returns>The <see cref="Task"/> that represents the asynchronous operation.</returns>
public virtual async Task AddOrUpdatePasskeyAsync(IdentityUser user, UserPasskeyInfo passkey, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();

await UserRepository.EnsureCollectionLoadedAsync(user, u => u.Passkeys, cancellationToken);

var userPasskey = user.FindPasskey(passkey.CredentialId);
if (userPasskey != null)
{
userPasskey.UpdateFromUserPasskeyInfo(passkey);
}
else
{
user.AddPasskey(passkey.CredentialId, new IdentityPasskeyData()
{
PublicKey = passkey.PublicKey,
Name = passkey.Name,
CreatedAt = passkey.CreatedAt,
Transports = passkey.Transports,
SignCount = passkey.SignCount,
IsUserVerified = passkey.IsUserVerified,
IsBackupEligible = passkey.IsBackupEligible,
IsBackedUp = passkey.IsBackedUp,
AttestationObject = passkey.AttestationObject,
ClientDataJson = passkey.ClientDataJson,
});
}
}

/// <summary>
/// Gets the passkey credentials for the specified <paramref name="user"/>.
/// </summary>
/// <param name="user">The user whose passkeys should be retrieved.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> used to propagate notifications that the operation should be canceled.</param>
/// <returns>The <see cref="Task"/> that represents the asynchronous operation, containing a list of the user's passkeys.</returns>
public virtual async Task<IList<UserPasskeyInfo>> GetPasskeysAsync(IdentityUser user, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();

Check.NotNull(user, nameof(user));

await UserRepository.EnsureCollectionLoadedAsync(user, u => u.Passkeys, cancellationToken);

return user.Passkeys.Select(p => p.ToUserPasskeyInfo()).ToList();
}

/// <summary>
/// Finds and returns a user, if any, associated with the specified passkey credential identifier.
/// </summary>
/// <param name="credentialId">The passkey credential id to search for.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> used to propagate notifications that the operation should be canceled.</param>
/// <returns>
/// The <see cref="Task"/> that represents the asynchronous operation, containing the user, if any, associated with the specified passkey credential id.
/// </returns>
public virtual async Task<IdentityUser> FindByPasskeyIdAsync(byte[] credentialId, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return await UserRepository.FindByPasskeyIdAsync(credentialId, cancellationToken: cancellationToken);
}

/// <summary>
/// Finds a passkey for the specified user with the specified credential id.
/// </summary>
/// <param name="user">The user whose passkey should be retrieved.</param>
/// <param name="credentialId">The credential id to search for.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> used to propagate notifications that the operation should be canceled.</param>
/// <returns>The <see cref="Task"/> that represents the asynchronous operation, containing the user's passkey information.</returns>
public virtual async Task<UserPasskeyInfo> FindPasskeyAsync(IdentityUser user, byte[] credentialId, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();

Check.NotNull(user, nameof(user));
Check.NotNull(credentialId, nameof(credentialId));

await UserRepository.EnsureCollectionLoadedAsync(user, u => u.Passkeys, cancellationToken);
return user.FindPasskey(credentialId)?.ToUserPasskeyInfo();
}

/// <summary>
/// Removes a passkey credential from the specified <paramref name="user"/>.
/// </summary>
/// <param name="user">The user to remove the passkey credential from.</param>
/// <param name="credentialId">The credential id of the passkey to remove.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> used to propagate notifications that the operation should be canceled.</param>
/// <returns>The <see cref="Task"/> that represents the asynchronous operation.</returns>
public virtual async Task RemovePasskeyAsync(IdentityUser user, byte[] credentialId, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();

Check.NotNull(user, nameof(user));
Check.NotNull(credentialId, nameof(credentialId));

await UserRepository.EnsureCollectionLoadedAsync(user, u => u.Passkeys, cancellationToken);
user.RemovePasskey(credentialId);
}

public virtual void Dispose()
{

Expand Down
Loading
Loading