diff --git a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpSignInManager.cs b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpSignInManager.cs index f4b4a2d35a..09f3e4bf24 100644 --- a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpSignInManager.cs +++ b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpSignInManager.cs @@ -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; @@ -42,7 +40,7 @@ public AbpSignInManager( _identityUserManager = userManager; } - public async override Task PasswordSignInAsync( + public override async Task PasswordSignInAsync( string userName, string password, bool isPersistent, @@ -86,7 +84,17 @@ public async override Task PasswordSignInAsync( return await base.PasswordSignInAsync(userName, password, isPersistent, lockoutOnFailure); } - protected async override Task PreSignInCheck(IdentityUser user) + /// + /// This is to call the protection method PreSignInCheck + /// + /// The user + /// Null if the user should be allowed to sign in, otherwise the SignInResult why they should be denied. + public virtual async Task CallPreSignInCheckAsync(IdentityUser user) + { + return await PreSignInCheck(user); + } + + protected override async Task PreSignInCheck(IdentityUser user) { if (!user.IsActive) { diff --git a/modules/identity/src/Volo.Abp.Identity.Domain.Shared/Volo/Abp/Identity/IdentityUserPasskeyConsts.cs b/modules/identity/src/Volo.Abp.Identity.Domain.Shared/Volo/Abp/Identity/IdentityUserPasskeyConsts.cs new file mode 100644 index 0000000000..37953c4964 --- /dev/null +++ b/modules/identity/src/Volo.Abp.Identity.Domain.Shared/Volo/Abp/Identity/IdentityUserPasskeyConsts.cs @@ -0,0 +1,9 @@ +namespace Volo.Abp.Identity; + +public static class IdentityUserPasskeyConsts +{ + /// + /// Default value: 1024 + /// + public static int MaxCredentialIdLength { get; set; } = 1024; +} diff --git a/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IIdentityUserRepository.cs b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IIdentityUserRepository.cs index 8da3237f83..33ec2eab95 100644 --- a/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IIdentityUserRepository.cs +++ b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IIdentityUserRepository.cs @@ -160,4 +160,9 @@ Task UpdateOrganizationAsync( Task> GetRoleNamesAsync( IEnumerable userIds, CancellationToken cancellationToken = default); + + Task FindByPasskeyIdAsync( + byte[] credentialId, + bool includeDetails = true, + CancellationToken cancellationToken = default); } diff --git a/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityPasskeyData.cs b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityPasskeyData.cs new file mode 100644 index 0000000000..8c26227741 --- /dev/null +++ b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityPasskeyData.cs @@ -0,0 +1,68 @@ +using System; + +namespace Volo.Abp.Identity; + +/// +/// Represents data associated with a passkey. +/// +public class IdentityPasskeyData +{ + /// + /// Gets or sets the public key associated with this passkey. + /// + public virtual byte[] PublicKey { get; set; } + + /// + /// Gets or sets the friendly name for this passkey. + /// + public virtual string? Name { get; set; } + + /// + /// Gets or sets the time this passkey was created. + /// + public virtual DateTimeOffset CreatedAt { get; set; } + + /// + /// Gets or sets the signature counter for this passkey. + /// + public virtual uint SignCount { get; set; } + + /// + /// Gets or sets the transports supported by this passkey. + /// + /// + /// See . + /// + public virtual string[] Transports { get; set; } + + /// + /// Gets or sets whether the passkey has a verified user. + /// + public virtual bool IsUserVerified { get; set; } + + /// + /// Gets or sets whether the passkey is eligible for backup. + /// + public virtual bool IsBackupEligible { get; set; } + + /// + /// Gets or sets whether the passkey is currently backed up. + /// + public virtual bool IsBackedUp { get; set; } + + /// + /// Gets or sets the attestation object associated with this passkey. + /// + /// + /// See . + /// + public virtual byte[] AttestationObject { get; set; } + + /// + /// Gets or sets the collected client data JSON associated with this passkey. + /// + /// + /// See . + /// + public virtual byte[] ClientDataJson { get; set; } +} diff --git a/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityUser.cs b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityUser.cs index be9ccfe8bb..3f4a91117c 100644 --- a/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityUser.cs +++ b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityUser.cs @@ -127,6 +127,11 @@ public class IdentityUser : FullAuditedAggregateRoot, IUser, IHasEntityVer /// public virtual DateTimeOffset? LastPasswordChangeTime { get; protected set; } + /// + /// Gets or sets the last sign-in time for the user. + /// + public virtual DateTimeOffset? LastSignInTime { get; protected set; } + //TODO: Can we make collections readonly collection, which will provide encapsulation. But... can work for all ORMs? /// @@ -159,6 +164,11 @@ public class IdentityUser : FullAuditedAggregateRoot, IUser, IHasEntityVer /// public virtual ICollection PasswordHistories { get; protected set; } + /// + /// Navigation property for this users passkeys. + /// + public virtual ICollection Passkeys { get; protected set; } + protected IdentityUser() { } @@ -188,6 +198,7 @@ public IdentityUser( Tokens = new Collection(); OrganizationUnits = new Collection(); PasswordHistories = new Collection(); + Passkeys = new Collection(); } public virtual void AddRole(Guid roleId) @@ -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}"; diff --git a/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityUserPasskey.cs b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityUserPasskey.cs new file mode 100644 index 0000000000..59aad98ea6 --- /dev/null +++ b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityUserPasskey.cs @@ -0,0 +1,53 @@ +using System; +using Volo.Abp.Domain.Entities; +using Volo.Abp.MultiTenancy; + +namespace Volo.Abp.Identity; + +/// +/// Represents a passkey credential for a user in the identity system. +/// +/// +/// See . +/// +public class IdentityUserPasskey : Entity, IMultiTenant +{ + public virtual Guid? TenantId { get; protected set; } + + /// + /// Gets or sets the primary key of the user that owns this passkey. + /// + public virtual Guid UserId { get; protected set; } + + /// + /// Gets or sets the credential ID for this passkey. + /// + public virtual byte[] CredentialId { get; set; } + + /// + /// Gets or sets additional data associated with this passkey. + /// + 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 }; + } +} diff --git a/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityUserPasskeyExtensions.cs b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityUserPasskeyExtensions.cs new file mode 100644 index 0000000000..fd1bd3fbf5 --- /dev/null +++ b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityUserPasskeyExtensions.cs @@ -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 + }; + } +} diff --git a/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityUserStore.cs b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityUserStore.cs index 83fe745677..f407e6c977 100644 --- a/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityUserStore.cs +++ b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityUserStore.cs @@ -32,6 +32,7 @@ public class IdentityUserStore : IUserAuthenticationTokenStore, IUserAuthenticatorKeyStore, IUserTwoFactorRecoveryCodeStore, + IUserPasskeyStore, ITransientDependency { private const string InternalLoginProvider = "[AspNetUserStore]"; @@ -1123,6 +1124,110 @@ public virtual Task GetRecoveryCodeTokenNameAsync() return Task.FromResult(RecoveryCodeTokenName); } + /// + /// Creates a new passkey credential in the store for the specified , + /// or updates an existing passkey. + /// + /// The user to create the passkey credential for. + /// + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + 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, + }); + } + } + + /// + /// Gets the passkey credentials for the specified . + /// + /// The user whose passkeys should be retrieved. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation, containing a list of the user's passkeys. + public virtual async Task> 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(); + } + + /// + /// Finds and returns a user, if any, associated with the specified passkey credential identifier. + /// + /// The passkey credential id to search for. + /// The used to propagate notifications that the operation should be canceled. + /// + /// The that represents the asynchronous operation, containing the user, if any, associated with the specified passkey credential id. + /// + public virtual async Task FindByPasskeyIdAsync(byte[] credentialId, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return await UserRepository.FindByPasskeyIdAsync(credentialId, cancellationToken: cancellationToken); + } + + /// + /// Finds a passkey for the specified user with the specified credential id. + /// + /// The user whose passkey should be retrieved. + /// The credential id to search for. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation, containing the user's passkey information. + public virtual async Task 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(); + } + + /// + /// Removes a passkey credential from the specified . + /// + /// The user to remove the passkey credential from. + /// The credential id of the passkey to remove. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + 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() { diff --git a/modules/identity/src/Volo.Abp.Identity.EntityFrameworkCore/Volo/Abp/Identity/EntityFrameworkCore/EfCoreIdentityUserRepository.cs b/modules/identity/src/Volo.Abp.Identity.EntityFrameworkCore/Volo/Abp/Identity/EntityFrameworkCore/EfCoreIdentityUserRepository.cs index 25646ac90a..68c05738db 100644 --- a/modules/identity/src/Volo.Abp.Identity.EntityFrameworkCore/Volo/Abp/Identity/EntityFrameworkCore/EfCoreIdentityUserRepository.cs +++ b/modules/identity/src/Volo.Abp.Identity.EntityFrameworkCore/Volo/Abp/Identity/EntityFrameworkCore/EfCoreIdentityUserRepository.cs @@ -94,6 +94,15 @@ into gp return userRoles.Concat(orgUnitRoles).GroupBy(x => x.Id).Select(x => new IdentityUserIdWithRoleNames { Id = x.Key, RoleNames = x.SelectMany(y => y.RoleNames).Distinct().ToArray() }).ToList(); } + public virtual async Task FindByPasskeyIdAsync(byte[] credentialId, bool includeDetails = true, CancellationToken cancellationToken = default) + { + return await (await GetDbSetAsync()) + .IncludeDetails(includeDetails) + .Where(u => u.Passkeys.Any(x => x.CredentialId.SequenceEqual(credentialId))) + .OrderBy(x => x.Id) + .FirstOrDefaultAsync(GetCancellationToken(cancellationToken)); + } + public virtual async Task> GetRoleNamesInOrganizationUnitAsync( Guid id, CancellationToken cancellationToken = default) @@ -468,11 +477,11 @@ protected virtual async Task> GetFilteredQueryableAsync { var upperFilter = filter?.ToUpperInvariant(); var query = await GetQueryableAsync(); - + if (id.HasValue) { return query.Where(x => x.Id == id); - } + } if (roleId.HasValue) { diff --git a/modules/identity/src/Volo.Abp.Identity.EntityFrameworkCore/Volo/Abp/Identity/EntityFrameworkCore/IdentityDbContextModelBuilderExtensions.cs b/modules/identity/src/Volo.Abp.Identity.EntityFrameworkCore/Volo/Abp/Identity/EntityFrameworkCore/IdentityDbContextModelBuilderExtensions.cs index 1b396754a8..2b302e4cf8 100644 --- a/modules/identity/src/Volo.Abp.Identity.EntityFrameworkCore/Volo/Abp/Identity/EntityFrameworkCore/IdentityDbContextModelBuilderExtensions.cs +++ b/modules/identity/src/Volo.Abp.Identity.EntityFrameworkCore/Volo/Abp/Identity/EntityFrameworkCore/IdentityDbContextModelBuilderExtensions.cs @@ -47,6 +47,7 @@ public static void ConfigureIdentity([NotNull] this ModelBuilder builder) b.HasMany(u => u.Tokens).WithOne().HasForeignKey(ur => ur.UserId).IsRequired(); b.HasMany(u => u.OrganizationUnits).WithOne().HasForeignKey(ur => ur.UserId).IsRequired(); b.HasMany(u => u.PasswordHistories).WithOne().HasForeignKey(ur => ur.UserId).IsRequired(); + b.HasMany(u => u.Passkeys).WithOne().HasForeignKey(ur => ur.UserId).IsRequired(); b.HasIndex(u => u.NormalizedUserName); b.HasIndex(u => u.NormalizedEmail); @@ -176,6 +177,20 @@ public static void ConfigureIdentity([NotNull] this ModelBuilder builder) }); } + builder.Entity(b => + { + b.ToTable(AbpIdentityDbProperties.DbTablePrefix + "UserPasskeys", AbpIdentityDbProperties.DbSchema); + + b.ConfigureByConvention(); + + b.HasKey(p => p.CredentialId); + + b.Property(p => p.CredentialId).HasMaxLength(IdentityUserPasskeyConsts.MaxCredentialIdLength); // Defined in WebAuthn spec to be no longer than 1023 bytes + b.OwnsOne(p => p.Data).ToJson(); + + b.ApplyObjectExtensionMappings(); + }); + builder.Entity(b => { b.ToTable(AbpIdentityDbProperties.DbTablePrefix + "OrganizationUnits", AbpIdentityDbProperties.DbSchema); diff --git a/modules/identity/src/Volo.Abp.Identity.EntityFrameworkCore/Volo/Abp/Identity/EntityFrameworkCore/IdentityEfCoreQueryableExtensions.cs b/modules/identity/src/Volo.Abp.Identity.EntityFrameworkCore/Volo/Abp/Identity/EntityFrameworkCore/IdentityEfCoreQueryableExtensions.cs index 0d51910e97..b5ce878a4f 100644 --- a/modules/identity/src/Volo.Abp.Identity.EntityFrameworkCore/Volo/Abp/Identity/EntityFrameworkCore/IdentityEfCoreQueryableExtensions.cs +++ b/modules/identity/src/Volo.Abp.Identity.EntityFrameworkCore/Volo/Abp/Identity/EntityFrameworkCore/IdentityEfCoreQueryableExtensions.cs @@ -18,7 +18,8 @@ public static IQueryable IncludeDetails(this IQueryable x.Claims) .Include(x => x.Tokens) .Include(x => x.OrganizationUnits) - .Include(x => x.PasswordHistories); + .Include(x => x.PasswordHistories) + .Include(x => x.Passkeys); } public static IQueryable IncludeDetails(this IQueryable queryable, bool include = true) diff --git a/modules/identity/src/Volo.Abp.Identity.MongoDB/Volo/Abp/Identity/MongoDB/MongoIdentityUserRepository.cs b/modules/identity/src/Volo.Abp.Identity.MongoDB/Volo/Abp/Identity/MongoDB/MongoIdentityUserRepository.cs index 29b9817e05..2a8654cd3b 100644 --- a/modules/identity/src/Volo.Abp.Identity.MongoDB/Volo/Abp/Identity/MongoDB/MongoIdentityUserRepository.cs +++ b/modules/identity/src/Volo.Abp.Identity.MongoDB/Volo/Abp/Identity/MongoDB/MongoIdentityUserRepository.cs @@ -443,6 +443,14 @@ public virtual async Task> GetRoleNamesAsync( return result; } + public virtual async Task FindByPasskeyIdAsync(byte[] credentialId, bool includeDetails = true, CancellationToken cancellationToken = default) + { + return await (await GetQueryableAsync(cancellationToken)) + .Where(u => u.Passkeys.Any(x => x.CredentialId == credentialId)) + .OrderBy(x => x.Id) + .FirstOrDefaultAsync(GetCancellationToken(cancellationToken)); + } + protected virtual async Task> GetFilteredQueryableAsync( string filter = null, Guid? roleId = null, diff --git a/modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/IdentityUserStore_Tests.cs b/modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/IdentityUserStore_Tests.cs index 65c3ff5a39..b3285a4353 100644 --- a/modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/IdentityUserStore_Tests.cs +++ b/modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/IdentityUserStore_Tests.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Security.Claims; +using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Identity; using Shouldly; @@ -766,4 +768,103 @@ public async Task RedeemCodeAsync() await uow.CompleteAsync(); } } + + [Fact] + public async Task AddOrUpdatePasskeyAsync() + { + using (var uow = _unitOfWorkManager.Begin()) + { + var credentialId = (byte[]) [1, 2]; + var user = await _identityUserStore.FindByIdAsync(_testData.UserBobId.ToString()); + user.Passkeys.ShouldBeEmpty(); + + var passkey = new UserPasskeyInfo(credentialId, null!, default, 0, null, false, false, false, null!, null!); + await _identityUserStore.AddOrUpdatePasskeyAsync(user, passkey, CancellationToken.None); + + user = await _identityUserStore.FindByIdAsync(_testData.UserBobId.ToString()); + user.Passkeys.ShouldNotBeEmpty(); + user.FindPasskey(credentialId).ShouldNotBeNull(); + + await uow.CompleteAsync(); + } + } + + [Fact] + public async Task GetPasskeysAsync() + { + using (var uow = _unitOfWorkManager.Begin()) + { + var user = await _identityUserStore.FindByIdAsync(_testData.UserJohnId.ToString()); + var passkeys = await _identityUserStore.GetPasskeysAsync(user, CancellationToken.None); + passkeys.Count.ShouldBe(2); + + user = await _identityUserStore.FindByIdAsync(_testData.UserBobId.ToString()); + passkeys = await _identityUserStore.GetPasskeysAsync(user, CancellationToken.None); + passkeys.ShouldBeEmpty(); + + await uow.CompleteAsync(); + } + } + + [Fact] + public async Task FindByPasskeyIdAsync() + { + using (var uow = _unitOfWorkManager.Begin()) + { + var user = await _identityUserStore.FindByPasskeyIdAsync(_testData.PasskeyCredentialId1, CancellationToken.None); + user.ShouldNotBeNull(); + user.Id.ShouldBe(_testData.UserJohnId); + + user = await _identityUserStore.FindByPasskeyIdAsync(_testData.PasskeyCredentialId2, CancellationToken.None); + user.ShouldNotBeNull(); + user.Id.ShouldBe(_testData.UserJohnId); + + user = await _identityUserStore.FindByPasskeyIdAsync(_testData.PasskeyCredentialId3, CancellationToken.None); + user.ShouldNotBeNull(); + user.Id.ShouldBe(_testData.UserNeoId); + + await uow.CompleteAsync(); + } + } + + [Fact] + public async Task FindPasskeyAsync() + { + using (var uow = _unitOfWorkManager.Begin()) + { + var user = await _identityUserStore.FindByIdAsync(_testData.UserJohnId.ToString()); + var passkey = await _identityUserStore.FindPasskeyAsync(user, _testData.PasskeyCredentialId1, CancellationToken.None); + passkey.ShouldNotBeNull(); + passkey.CredentialId.ShouldBe(_testData.PasskeyCredentialId1); + + passkey = await _identityUserStore.FindPasskeyAsync(user, _testData.PasskeyCredentialId2, CancellationToken.None); + passkey.ShouldNotBeNull(); + passkey.CredentialId.ShouldBe(_testData.PasskeyCredentialId2); + + passkey = await _identityUserStore.FindPasskeyAsync(user, _testData.PasskeyCredentialId3, CancellationToken.None); + passkey.ShouldBeNull(); + + await uow.CompleteAsync(); + } + } + + [Fact] + public async Task RemovePasskeyAsync() + { + using (var uow = _unitOfWorkManager.Begin()) + { + var user = await _identityUserStore.FindByIdAsync(_testData.UserJohnId.ToString()); + user.Passkeys.Count.ShouldBe(2); + + var credentialId = user.Passkeys.First().CredentialId; + + await _identityUserStore.RemovePasskeyAsync(user, credentialId, CancellationToken.None); + + user = await _identityUserStore.FindByIdAsync(_testData.UserJohnId.ToString()); + user.Passkeys.Count.ShouldBe(1); + user.FindPasskey(credentialId).ShouldBeNull(); + + await uow.CompleteAsync(); + } + } } diff --git a/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/AbpIdentityTestDataBuilder.cs b/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/AbpIdentityTestDataBuilder.cs index 944c6928c1..15ef80b4c4 100644 --- a/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/AbpIdentityTestDataBuilder.cs +++ b/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/AbpIdentityTestDataBuilder.cs @@ -140,6 +140,8 @@ private async Task AddUsers() john.AddLogin(new UserLoginInfo("twitter", "johnx", "John Nash")); john.AddClaim(_guidGenerator, new Claim("TestClaimType", "42")); john.SetToken("test-provider", "test-name", "test-value"); + john.AddPasskey(_testData.PasskeyCredentialId1, new IdentityPasskeyData()); + john.AddPasskey(_testData.PasskeyCredentialId2, new IdentityPasskeyData()); await _userRepository.InsertAsync(john); var david = new IdentityUser(_testData.UserDavidId, "david", "david@abp.io"); @@ -152,6 +154,7 @@ private async Task AddUsers() neo.AddRole(_supporterRole.Id); neo.AddClaim(_guidGenerator, new Claim("TestClaimType", "43")); neo.AddOrganizationUnit(_ou111.Id); + neo.AddPasskey(_testData.PasskeyCredentialId3, new IdentityPasskeyData()); await _userRepository.InsertAsync(neo); var bob = new IdentityUser(_testData.UserBobId, "bob", "bob@abp.io"); diff --git a/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/IdentityTestData.cs b/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/IdentityTestData.cs index 0172dc016a..b6cb14de5c 100644 --- a/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/IdentityTestData.cs +++ b/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/IdentityTestData.cs @@ -15,4 +15,7 @@ public class IdentityTestData : ISingletonDependency public Guid UserBobId { get; } = Guid.NewGuid(); public Guid AgeClaimId { get; } = Guid.NewGuid(); public Guid EducationClaimId { get; } = Guid.NewGuid(); + public byte[] PasskeyCredentialId1 { get; } = (byte[])[1, 2, 3, 4, 5, 6, 7, 8]; + public byte[] PasskeyCredentialId2 { get; } = (byte[])[8, 7, 6, 5, 4, 3, 2, 1]; + public byte[] PasskeyCredentialId3 { get; } = (byte[])[1, 2, 3, 4, 8, 7, 6, 5,]; } diff --git a/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/IdentityUserRepository_Tests.cs b/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/IdentityUserRepository_Tests.cs index e81c0cbd38..58dc072359 100644 --- a/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/IdentityUserRepository_Tests.cs +++ b/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/IdentityUserRepository_Tests.cs @@ -159,7 +159,7 @@ public async Task GetListAsync() StringComparison.OrdinalIgnoreCase ).ShouldBeGreaterThan(0); } - + users = await UserRepository.GetListAsync(null, 5, 0, null, roleId: TestData.RoleManagerId); users.ShouldContain(x => x.UserName == "john.nash"); users.ShouldContain(x => x.UserName == "neo"); @@ -294,4 +294,23 @@ public async Task UpdateOrganizationAsync() ou112Users.ShouldContain(x => x.UserName == "john.nash"); ou112Users.ShouldContain(x => x.UserName == "neo"); } + + + [Fact] + public async Task FindByPasskeyIdAsync() + { + var user = await UserRepository.FindByPasskeyIdAsync(TestData.PasskeyCredentialId1); + user.ShouldNotBeNull(); + user.Id.ShouldBe(TestData.UserJohnId); + + user = await UserRepository.FindByPasskeyIdAsync(TestData.PasskeyCredentialId2); + user.ShouldNotBeNull(); + user.Id.ShouldBe(TestData.UserJohnId); + + user = await UserRepository.FindByPasskeyIdAsync(TestData.PasskeyCredentialId3); + user.ShouldNotBeNull(); + user.Id.ShouldBe(TestData.UserNeoId); + + (await UserRepository.FindByPasskeyIdAsync((byte[])[1, 2, 3])).ShouldBeNull(); + } } diff --git a/modules/identityserver/src/Volo.Abp.IdentityServer.Domain/Volo/Abp/IdentityServer/AspNetIdentity/AbpResourceOwnerPasswordValidator.cs b/modules/identityserver/src/Volo.Abp.IdentityServer.Domain/Volo/Abp/IdentityServer/AspNetIdentity/AbpResourceOwnerPasswordValidator.cs index d4e0fb5490..b5694cb2fa 100644 --- a/modules/identityserver/src/Volo.Abp.IdentityServer.Domain/Volo/Abp/IdentityServer/AspNetIdentity/AbpResourceOwnerPasswordValidator.cs +++ b/modules/identityserver/src/Volo.Abp.IdentityServer.Domain/Volo/Abp/IdentityServer/AspNetIdentity/AbpResourceOwnerPasswordValidator.cs @@ -322,6 +322,9 @@ protected virtual async Task SetSuccessResultAsync(ResourceOwnerPasswordValidati additionalClaims.ToArray() ); + user.SetLastSignInTime(DateTimeOffset.UtcNow); + await UserManager.UpdateAsync(user); + await IdentitySecurityLogManager.SaveAsync( new IdentitySecurityLogContext { diff --git a/modules/openiddict/src/Volo.Abp.OpenIddict.AspNetCore/Volo/Abp/OpenIddict/Controllers/TokenController.Password.cs b/modules/openiddict/src/Volo.Abp.OpenIddict.AspNetCore/Volo/Abp/OpenIddict/Controllers/TokenController.Password.cs index 5059c7c5ca..07be60da44 100644 --- a/modules/openiddict/src/Volo.Abp.OpenIddict.AspNetCore/Volo/Abp/OpenIddict/Controllers/TokenController.Password.cs +++ b/modules/openiddict/src/Volo.Abp.OpenIddict.AspNetCore/Volo/Abp/OpenIddict/Controllers/TokenController.Password.cs @@ -405,6 +405,9 @@ await IdentitySecurityLogManager.SaveAsync( } ); + user.SetLastSignInTime(DateTimeOffset.UtcNow); + await UserManager.UpdateAsync(user); + return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); }