From 9b088b8928a16fbed89026015bee86d05ea7bb15 Mon Sep 17 00:00:00 2001 From: maliming Date: Tue, 2 Jun 2026 11:13:04 +0800 Subject: [PATCH 1/2] Replace default token provider with single-active variant Introduce AbpDefaultTokenProvider (10-min lifespan) replacing DataProtectorTokenProvider under TokenOptions.DefaultProvider, so challenge tokens (RequiresTwoFactor, ShouldChangePassword) become single-active. Document the token provider lineup and fix two factual errors in the 2FA doc. --- docs/en/modules/identity-pro.md | 1 + docs/en/modules/identity/token-providers.md | 136 ++++++++++++++++++ .../identity/two-factor-authentication.md | 6 +- .../AspNetCore/AbpDefaultTokenProvider.cs | 28 ++++ .../AbpDefaultTokenProviderOptions.cs | 13 ++ .../AspNetCore/AbpIdentityAspNetCoreModule.cs | 1 + .../AbpDefaultTokenProvider_Tests.cs | 122 ++++++++++++++++ 7 files changed, 304 insertions(+), 3 deletions(-) create mode 100644 docs/en/modules/identity/token-providers.md create mode 100644 modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpDefaultTokenProvider.cs create mode 100644 modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpDefaultTokenProviderOptions.cs create mode 100644 modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpDefaultTokenProvider_Tests.cs diff --git a/docs/en/modules/identity-pro.md b/docs/en/modules/identity-pro.md index 7e66e5514c..f916849dd9 100644 --- a/docs/en/modules/identity-pro.md +++ b/docs/en/modules/identity-pro.md @@ -440,4 +440,5 @@ This module doesn't define any additional distributed event. See the [standard d * [Two Factor Authentication](./identity/two-factor-authentication.md) * [Session Management](./identity/session-management.md) * [Password History](./identity/password-history.md) +* [Identity Token Providers](./identity/token-providers.md) diff --git a/docs/en/modules/identity/token-providers.md b/docs/en/modules/identity/token-providers.md new file mode 100644 index 0000000000..609d376b71 --- /dev/null +++ b/docs/en/modules/identity/token-providers.md @@ -0,0 +1,136 @@ +```json +//[doc-seo] +{ + "Description": "Learn how ABP Identity replaces the ASP.NET Core Identity built-in token providers with single-active variants, what each provider is used for, and how to configure or replace them." +} +``` + +# Identity Token Providers + +ASP.NET Core Identity uses `IUserTwoFactorTokenProvider` to issue and validate one-off tokens such as password reset, email confirmation, change email, two-factor codes, and so on. The default registrations (`DataProtectorTokenProvider` and the TOTP-based `EmailTokenProvider` / `PhoneNumberTokenProvider`) are general-purpose: tokens stay valid for the full configured lifespan and are not invalidated when a new token is issued. + +ABP replaces the `Default`, `Email`, and `Phone` provider registrations with single-active variants, and redirects `IdentityOptions.Tokens.PasswordResetTokenProvider` / `EmailConfirmationTokenProvider` / `ChangeEmailTokenProvider` to dedicated single-active providers. Generating a new token for the same `(user, provider, purpose)` invalidates the previously issued one, and tokens for the DataProtector-based providers are short-lived by default. The `Authenticator` provider is left as-is because authenticator apps require TOTP. The replacements are wired up in `AbpIdentityAspNetCoreModule.PreConfigureServices`. + +## Built-in Providers + +| Provider key | Provider | Default | Used by | +| --- | --- | --- | --- | +| `TokenOptions.DefaultProvider` (`"Default"`) | `AbpDefaultTokenProvider` | 10 minutes | Generic challenge tokens (e.g. `RequiresTwoFactor`, `ShouldChangePassword`) issued by IdentityServer / OpenIddict password flow endpoints | +| `AbpPasswordResetTokenProvider.ProviderName` (`"AbpPasswordReset"`) | `AbpPasswordResetTokenProvider` | 2 hours | `UserManager.GeneratePasswordResetTokenAsync` / `ResetPasswordAsync` | +| `AbpEmailConfirmationTokenProvider.ProviderName` (`"AbpEmailConfirmation"`) | `AbpEmailConfirmationTokenProvider` | 2 hours | `UserManager.GenerateEmailConfirmationTokenAsync` / `ConfirmEmailAsync` | +| `AbpChangeEmailTokenProvider.ProviderName` (`"AbpChangeEmail"`) | `AbpChangeEmailTokenProvider` | 2 hours | `UserManager.GenerateChangeEmailTokenAsync` / `ChangeEmailAsync` | +| `LinkUserTokenProviderConsts.LinkUserTokenProviderName` (`"AbpLinkUser"`) | `LinkUserTokenProvider` | 10 minutes | `IdentityLinkUserManager.GenerateLinkTokenAsync` / `VerifyLinkTokenAsync` for cross-tenant account linking | +| `TokenOptions.DefaultEmailProvider` (`"Email"`) | `AbpEmailTwoFactorTokenProvider` | 3 minutes | 6-digit numeric 2FA code delivered by email | +| `TokenOptions.DefaultPhoneProvider` (`"Phone"`) | `AbpPhoneNumberTwoFactorTokenProvider` | 3 minutes | 6-digit numeric 2FA code delivered by SMS, also used by `UserManager.GenerateChangePhoneNumberTokenAsync` | +| `TokenOptions.DefaultAuthenticatorProvider` (`"Authenticator"`) | ASP.NET Core's built-in `AuthenticatorTokenProvider` | TOTP timestep | Authenticator-app TOTP per [RFC 6238](https://datatracker.ietf.org/doc/html/rfc6238) | + +`IdentityOptions.Tokens.PasswordResetTokenProvider`, `EmailConfirmationTokenProvider`, and `ChangeEmailTokenProvider` are redirected by ABP to the dedicated single-active providers above. `ChangePhoneNumberTokenProvider` keeps its ASP.NET Core default of `"Phone"`, so it shares the 2FA phone provider's 6-digit-code semantics rather than going through the DataProtector pipeline. + +## How ABP Token Providers Differ from the Defaults + +The default `DataProtectorTokenProvider` creates a protected token blob containing the user id, purpose, security stamp and a creation timestamp. Validation unprotects the blob, checks the security stamp, and compares the timestamp against `DataProtectionTokenProviderOptions.TokenLifespan` (1 day by default). No server-side state is kept, so older tokens stay valid in parallel and the only ways to revoke before expiration are rotating the user's `SecurityStamp` (which signs every session out) or waiting out the lifespan. One day is fine for an emailed reset link, but far too long for a login-time challenge token where the user is expected to complete the next step within minutes. + +The default email and phone providers use TOTP-style 6-digit codes. A code can be used more than once during its short validity window (the implementation accepts the previous timestep as well, giving an effective 3–6 minute window), and requesting another code in the same window returns the same value, which is confusing for a user who requests a new code after a typo. + +ABP changes these registrations to make the affected tokens single-active and to use shorter defaults where appropriate: + +| Property | ASP.NET Core default | ABP replacement | +| --- | --- | --- | +| New token revokes the old one (same user/purpose) | ❌ Multiple tokens valid in parallel | ✅ Single-active | +| Lifespan tightened per use case | ❌ Same 1 day for every DataProtector token | ✅ 10 min – 2 h | +| Server-side revoke without rotating `SecurityStamp` | ❌ Not supported | ✅ `Remove*TokenAsync` helpers | +| 2FA code consumed on successful verification | ❌ Replayable within the validity window | ✅ Single-use | +| Re-issuing a 2FA code in the same window | ⚠️ Same code returned | ✅ New random code | + +`SecurityStamp`-based invalidation still applies on top of the ABP variants: rotating a user's security stamp invalidates every issued token regardless of provider. + +## How Single-Active Tokens Work + +The DataProtector-based providers (`AbpDefaultTokenProvider`, `AbpPasswordResetTokenProvider`, `AbpEmailConfirmationTokenProvider`, `AbpChangeEmailTokenProvider`, `LinkUserTokenProvider`) all derive from the abstract `AbpSingleActiveTokenProvider`, which itself extends ASP.NET Core's `DataProtectorTokenProvider`. On top of the base provider it adds a stored-hash check: + +1. **Generation.** The base provider produces the protected token blob as usual. The provider then computes `SHA-256(token)` and stores its hex string in the user-token table under the login provider `"[AbpSingleActiveToken]"` and the name `":"`. Generating a new token overwrites the same entry, so the previous token's stored hash no longer matches. +2. **Validation.** After the base provider has accepted the token (`SecurityStamp` and `DataProtector` checks), the stored hash is loaded and compared against `SHA-256(submitted token)` using `CryptographicOperations.FixedTimeEquals`. If no hash exists, the token is rejected. A non-hex stored value is treated as invalid rather than thrown. + +This has the following effects: + +- **Generating a new token invalidates the previous one** for the same `(user, provider, purpose)`. Multiple requests in flight will only let the most recent token complete. +- **Per-purpose isolation.** The stored hash key includes the purpose, so a `RequiresTwoFactor` token and a `ShouldChangePassword` token issued under the same `"Default"` provider do not invalidate each other. +- **`SecurityStamp` rotation invalidates every issued token.** This is inherited from the base `DataProtectorTokenProvider` and is unchanged. +- **Validation never throws on data corruption.** A non-hex stored hash returns `false` from `ValidateAsync` instead of propagating a `FormatException`. + +The 2FA OTP providers (`AbpEmailTwoFactorTokenProvider`, `AbpPhoneNumberTwoFactorTokenProvider`) use a different mechanism — see [Two Factor Authentication](./two-factor-authentication.md#how-the-verification-code-is-generated) for the numeric-code single-use design. + +## Configuring the Providers + +Each DataProtector-based provider exposes an options class deriving from `DataProtectionTokenProviderOptions`, configurable through the standard [options pattern](../../framework/fundamentals/options.md): + +| Options class | Default | Used by | +| --- | --- | --- | +| `AbpDefaultTokenProviderOptions` | 10 minutes | Generic challenge tokens (login flow) | +| `AbpPasswordResetTokenProviderOptions` | 2 hours | Password reset links | +| `AbpEmailConfirmationTokenProviderOptions` | 2 hours | Email confirmation links | +| `AbpChangeEmailTokenProviderOptions` | 2 hours | Change-email confirmation links | +| `AbpLinkUserTokenProviderOptions` | 10 minutes | Cross-tenant account linking | + +Override them in your module's `ConfigureServices`: + +```csharp +Configure(options => +{ + options.TokenLifespan = TimeSpan.FromMinutes(15); +}); + +Configure(options => +{ + options.TokenLifespan = TimeSpan.FromHours(1); +}); +``` + +The `Name` property is set by the constructor of each options class and should not normally be changed — it is the same key that the provider is registered under in `IdentityOptions.Tokens.ProviderMap`. + +For OTP-based options see [Configuring the Default Providers](./two-factor-authentication.md#configuring-the-default-providers) in the 2FA document. + +## Invalidating a Stored Token + +To force a stored single-active token to become invalid before its natural expiration (for example after a security-relevant action), call one of the `IdentityUserManagerSingleActiveTokenExtensions` helpers: + +```csharp +await UserManager.RemovePasswordResetTokenAsync(user); +await UserManager.RemoveEmailConfirmationTokenAsync(user); +await UserManager.RemoveChangeEmailTokenAsync(user, newEmail); +await UserManager.RemoveLinkUserTokenAsync(user); +await UserManager.RemoveLinkUserTokenAsync(user, customPurpose); +``` + +Each method removes the stored hash under `"[AbpSingleActiveToken]"` for the corresponding purpose. Validation afterwards returns `false` even if the token blob itself is still within its DataProtector lifespan and the `SecurityStamp` is unchanged. + +For tokens issued by `AbpDefaultTokenProvider` (e.g. `RequiresTwoFactor`, `ShouldChangePassword`), call `UserManager.RemoveAuthenticationTokenAsync` directly: + +```csharp +await UserManager.RemoveAuthenticationTokenAsync( + user, + AbpSingleActiveTokenProvider.InternalLoginProvider, + TokenOptions.DefaultProvider + ":" + nameof(SignInResult.RequiresTwoFactor)); +``` + +## Replacing a Provider + +If the built-in behavior does not match your requirements (different storage backend, different lifespan policy, alphanumeric codes, etc.), register your own implementation under the same key. `IdentityBuilder.AddTokenProvider` writes to `IdentityOptions.Tokens.ProviderMap` and the last registration wins: + +```csharp +PreConfigure(builder => +{ + builder.AddTokenProvider(TokenOptions.DefaultProvider); + builder.AddTokenProvider(AbpPasswordResetTokenProvider.ProviderName); +}); +``` + +The most ergonomic starting point for a single-active variant is to subclass `AbpSingleActiveTokenProvider` and supply your own options class. For a numeric-code provider, subclass `AbpTwoFactorTokenProvider` instead — see the [Two Factor Authentication](./two-factor-authentication.md#replacing-the-verification-code-provider) document. + +## Compatibility Notes + +- **Tokens issued before the upgrade are rejected after the switch.** The ABP providers look for a stored entry that older tokens (and TOTP 2FA codes) do not have, so they fail validation. Users should request a new password reset link, email confirmation, or 2FA code after the upgrade. +- **Opt out by re-registering the provider key.** If you want the original ASP.NET Core behavior (multi-active, 1 day lifespan) for a specific key, register `DataProtectorTokenProvider` (or your own provider) under the same key after the ABP module has run. `AddTokenProvider` writes to `IdentityOptions.Tokens.ProviderMap` and the last registration wins. +- **Stored entries are per-tenant.** The single-active hashes are persisted as `IdentityUserToken` records, which carry the user's `TenantId`. They are not shared across tenants. +- **Cleanup behavior.** `Remove*TokenAsync` helpers delete the stored hash entry directly. Generating a new token under the same `(user, provider, purpose)` overwrites the existing entry. DataProtector-based tokens, unlike 2FA OTP codes, are not consumed on successful verification — the stored hash remains until a new token is issued or the entry is explicitly removed. +- **Custom purposes work transparently.** A call like `GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, "MyCustomPurpose")` goes through `AbpDefaultTokenProvider` and gets single-active semantics for `(user, "Default", "MyCustomPurpose")` automatically. The same applies to any custom token provider you register that subclasses `AbpSingleActiveTokenProvider`. diff --git a/docs/en/modules/identity/two-factor-authentication.md b/docs/en/modules/identity/two-factor-authentication.md index c94962ce99..b1998e87c4 100644 --- a/docs/en/modules/identity/two-factor-authentication.md +++ b/docs/en/modules/identity/two-factor-authentication.md @@ -129,11 +129,11 @@ The codes delivered by the **Email** and **SMS** verification providers are prod - `AbpEmailTwoFactorTokenProvider` is registered under `TokenOptions.DefaultEmailProvider` and replaces ASP.NET Core Identity's TOTP-based `EmailTokenProvider`. - `AbpPhoneNumberTwoFactorTokenProvider` is registered under `TokenOptions.DefaultPhoneProvider` and replaces ASP.NET Core Identity's TOTP-based `PhoneNumberTokenProvider`. -Both derive from the abstract `AbpTwoFactorTokenProvider`. The `Authenticator` provider is unaffected: it is overridden by `AbpAuthenticatorTokenProvider`, which still relies on TOTP ([RFC 6238](https://datatracker.ietf.org/doc/html/rfc6238)) because authenticator apps require it. +Both derive from the abstract `AbpTwoFactorTokenProvider`. The `Authenticator` provider remains ASP.NET Core Identity's built-in `AuthenticatorTokenProvider`, because authenticator apps require TOTP ([RFC 6238](https://datatracker.ietf.org/doc/html/rfc6238)). -On generation, the provider produces a cryptographically-random numeric code (default 6 digits), encrypts it together with an absolute UTC expiration via `IDataProtector`, and persists the resulting blob in the user tokens table. The plaintext code is sent to the user via email/SMS and is never stored. Validation reloads the persisted entry, verifies it has not expired, decrypts and compares constant-time against the submitted input, and — on success — removes the entry so it cannot be replayed. +On generation, the provider produces a cryptographically-random numeric code (default 6 digits), encrypts the code with `IDataProtector`, appends the absolute expiration as a Unix-seconds value, and persists the combined string in the user tokens table. The plaintext code is sent to the user via email/SMS and is never stored. Validation reloads the persisted entry, verifies it has not expired, decrypts and compares constant-time against the submitted input, and — on success — removes the entry so it cannot be replayed. -This persisted, single-use design has a few properties worth being explicit about: +This persisted, single-use design has the following effects: 1. **A generated code is single-use.** Successful verification removes the stored entry. Re-submitting the same code from a concurrent session fails. 2. **Generating a new code invalidates the previous one.** `SetToken` overwrites the same `(provider, name)` row, so at most one code is valid at any time. Re-issuing a code (e.g. when the user requests a new one) replaces the stored entry and the previously delivered code stops working. diff --git a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpDefaultTokenProvider.cs b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpDefaultTokenProvider.cs new file mode 100644 index 0000000000..55e874aaaa --- /dev/null +++ b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpDefaultTokenProvider.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Volo.Abp.Identity; +using Volo.Abp.Threading; + +namespace Volo.Abp.Identity.AspNetCore; + +/// +/// Replaces ASP.NET Identity's default +/// registered under ("Default"). Used by callers such +/// as the IdentityServer / OpenIddict token endpoints to issue short-lived challenge tokens +/// (RequiresTwoFactor, ShouldChangePassword) and consumed back by Account / SendSecurityCode. +/// Enforces, per purpose, a single active token to be valid. +/// +public class AbpDefaultTokenProvider : AbpSingleActiveTokenProvider +{ + public AbpDefaultTokenProvider( + IDataProtectionProvider dataProtectionProvider, + IOptions options, + ILogger> logger, + IIdentityUserRepository userRepository, + ICancellationTokenProvider cancellationTokenProvider) + : base(dataProtectionProvider, options, logger, userRepository, cancellationTokenProvider) + { + } +} diff --git a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpDefaultTokenProviderOptions.cs b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpDefaultTokenProviderOptions.cs new file mode 100644 index 0000000000..42e2fc67e0 --- /dev/null +++ b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpDefaultTokenProviderOptions.cs @@ -0,0 +1,13 @@ +using System; +using Microsoft.AspNetCore.Identity; + +namespace Volo.Abp.Identity.AspNetCore; + +public class AbpDefaultTokenProviderOptions : DataProtectionTokenProviderOptions +{ + public AbpDefaultTokenProviderOptions() + { + Name = TokenOptions.DefaultProvider; + TokenLifespan = TimeSpan.FromMinutes(10); + } +} diff --git a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpIdentityAspNetCoreModule.cs b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpIdentityAspNetCoreModule.cs index eb8a1a7c54..195e82a9bc 100644 --- a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpIdentityAspNetCoreModule.cs +++ b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpIdentityAspNetCoreModule.cs @@ -19,6 +19,7 @@ public class AbpIdentityAspNetCoreModule : AbpModule { builder .AddDefaultTokenProviders() + .AddTokenProvider(TokenOptions.DefaultProvider) .AddTokenProvider(LinkUserTokenProviderConsts.LinkUserTokenProviderName) .AddTokenProvider(AbpPasswordResetTokenProvider.ProviderName) .AddTokenProvider(AbpEmailConfirmationTokenProvider.ProviderName) diff --git a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpDefaultTokenProvider_Tests.cs b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpDefaultTokenProvider_Tests.cs new file mode 100644 index 0000000000..9f719a663e --- /dev/null +++ b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpDefaultTokenProvider_Tests.cs @@ -0,0 +1,122 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; +using Shouldly; +using Volo.Abp.Uow; +using Xunit; + +namespace Volo.Abp.Identity.AspNetCore; + +public class AbpDefaultTokenProvider_Tests : AbpSingleActiveTokenProviderTestBase +{ + private const string TestPurpose = nameof(SignInResult.RequiresTwoFactor); + + protected override Task GenerateTokenAsync(IdentityUser user) + => UserManager.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, TestPurpose); + + protected override Task VerifyTokenAsync(IdentityUser user, string token) + => UserManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, TestPurpose, token); + + protected override string GetProviderName() + => TokenOptions.DefaultProvider; + + protected override string GetPurpose() + => TestPurpose; + + [Fact] + public void AbpDefaultTokenProvider_Should_Override_AspNetCore_Default() + { + var identityOptions = GetRequiredService>().Value; + + identityOptions.Tokens.ProviderMap.ShouldContainKey(TokenOptions.DefaultProvider); + identityOptions.Tokens.ProviderMap[TokenOptions.DefaultProvider].ProviderType + .ShouldBe(typeof(AbpDefaultTokenProvider)); + } + + [Fact] + public void AbpDefaultTokenProviderOptions_Should_Default_To_TenMinutes() + { + var options = GetRequiredService>().Value; + + options.Name.ShouldBe(TokenOptions.DefaultProvider); + options.TokenLifespan.ShouldBe(TimeSpan.FromMinutes(10)); + } + + [Fact] + public async Task Tokens_For_Different_Purposes_Should_Be_Independent() + { + const string changePasswordPurpose = "ShouldChangePasswordOnNextLogin"; + + using (var uow = UnitOfWorkManager.Begin()) + { + var user = await UserRepository.GetAsync(TestData.UserJohnId); + var twoFactorToken = await UserManager.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, TestPurpose); + var changePasswordToken = await UserManager.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, changePasswordPurpose); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + + (await UserManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, TestPurpose, twoFactorToken)).ShouldBeTrue(); + (await UserManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, changePasswordPurpose, changePasswordToken)).ShouldBeTrue(); + + await uow.CompleteAsync(); + } + } + + [Fact] + public async Task Regenerating_Same_Purpose_Should_Invalidate_Only_That_Purpose() + { + const string changePasswordPurpose = "ShouldChangePasswordOnNextLogin"; + + using (var uow = UnitOfWorkManager.Begin()) + { + var user = await UserRepository.GetAsync(TestData.UserJohnId); + var firstTwoFactorToken = await UserManager.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, TestPurpose); + var changePasswordToken = await UserManager.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, changePasswordPurpose); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + var secondTwoFactorToken = await UserManager.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, TestPurpose); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + + (await UserManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, TestPurpose, firstTwoFactorToken)).ShouldBeFalse(); + (await UserManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, TestPurpose, secondTwoFactorToken)).ShouldBeTrue(); + (await UserManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, changePasswordPurpose, changePasswordToken)).ShouldBeTrue(); + + await uow.CompleteAsync(); + } + } + + [Fact] + public async Task Token_Should_Be_Invalid_After_SecurityStamp_Change() + { + using (var uow = UnitOfWorkManager.Begin()) + { + var user = await UserRepository.GetAsync(TestData.UserJohnId); + var token = await GenerateTokenAsync(user); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await UserManager.UpdateSecurityStampAsync(user)).Succeeded.ShouldBeTrue(); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await VerifyTokenAsync(user, token)).ShouldBeFalse(); + + await uow.CompleteAsync(); + } + } + + [Fact] + public async Task Token_Should_Not_Verify_Against_Different_User() + { + using (var uow = UnitOfWorkManager.Begin()) + { + var john = await UserRepository.GetAsync(TestData.UserJohnId); + var johnToken = await GenerateTokenAsync(john); + + var david = await UserRepository.GetAsync(TestData.UserDavidId); + (await VerifyTokenAsync(david, johnToken)).ShouldBeFalse(); + + await uow.CompleteAsync(); + } + } +} From 20acbd1525102f073f9b801255bbf5976a5eecd1 Mon Sep 17 00:00:00 2001 From: maliming Date: Tue, 2 Jun 2026 11:28:26 +0800 Subject: [PATCH 2/2] Address Copilot review on #25525 --- docs/en/modules/identity/token-providers.md | 6 +++--- .../AspNetCore/AbpDefaultTokenProvider.cs | 3 ++- .../AspNetCore/AbpDefaultTokenProvider_Tests.cs | 17 +++++++++-------- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/docs/en/modules/identity/token-providers.md b/docs/en/modules/identity/token-providers.md index 609d376b71..326af4f77c 100644 --- a/docs/en/modules/identity/token-providers.md +++ b/docs/en/modules/identity/token-providers.md @@ -15,7 +15,7 @@ ABP replaces the `Default`, `Email`, and `Phone` provider registrations with sin | Provider key | Provider | Default | Used by | | --- | --- | --- | --- | -| `TokenOptions.DefaultProvider` (`"Default"`) | `AbpDefaultTokenProvider` | 10 minutes | Generic challenge tokens (e.g. `RequiresTwoFactor`, `ShouldChangePassword`) issued by IdentityServer / OpenIddict password flow endpoints | +| `TokenOptions.DefaultProvider` (`"Default"`) | `AbpDefaultTokenProvider` | 10 minutes | Generic challenge tokens (e.g. `RequiresTwoFactor`, `ShouldChangePasswordOnNextLogin`, `PeriodicallyChangePassword`) issued by IdentityServer / OpenIddict password flow endpoints | | `AbpPasswordResetTokenProvider.ProviderName` (`"AbpPasswordReset"`) | `AbpPasswordResetTokenProvider` | 2 hours | `UserManager.GeneratePasswordResetTokenAsync` / `ResetPasswordAsync` | | `AbpEmailConfirmationTokenProvider.ProviderName` (`"AbpEmailConfirmation"`) | `AbpEmailConfirmationTokenProvider` | 2 hours | `UserManager.GenerateEmailConfirmationTokenAsync` / `ConfirmEmailAsync` | | `AbpChangeEmailTokenProvider.ProviderName` (`"AbpChangeEmail"`) | `AbpChangeEmailTokenProvider` | 2 hours | `UserManager.GenerateChangeEmailTokenAsync` / `ChangeEmailAsync` | @@ -54,7 +54,7 @@ The DataProtector-based providers (`AbpDefaultTokenProvider`, `AbpPasswordResetT This has the following effects: - **Generating a new token invalidates the previous one** for the same `(user, provider, purpose)`. Multiple requests in flight will only let the most recent token complete. -- **Per-purpose isolation.** The stored hash key includes the purpose, so a `RequiresTwoFactor` token and a `ShouldChangePassword` token issued under the same `"Default"` provider do not invalidate each other. +- **Per-purpose isolation.** The stored hash key includes the purpose, so a `RequiresTwoFactor` token and a `ShouldChangePasswordOnNextLogin` token issued under the same `"Default"` provider do not invalidate each other. - **`SecurityStamp` rotation invalidates every issued token.** This is inherited from the base `DataProtectorTokenProvider` and is unchanged. - **Validation never throws on data corruption.** A non-hex stored hash returns `false` from `ValidateAsync` instead of propagating a `FormatException`. @@ -104,7 +104,7 @@ await UserManager.RemoveLinkUserTokenAsync(user, customPurpose); Each method removes the stored hash under `"[AbpSingleActiveToken]"` for the corresponding purpose. Validation afterwards returns `false` even if the token blob itself is still within its DataProtector lifespan and the `SecurityStamp` is unchanged. -For tokens issued by `AbpDefaultTokenProvider` (e.g. `RequiresTwoFactor`, `ShouldChangePassword`), call `UserManager.RemoveAuthenticationTokenAsync` directly: +For tokens issued by `AbpDefaultTokenProvider` (e.g. `RequiresTwoFactor`, `ShouldChangePasswordOnNextLogin`, `PeriodicallyChangePassword`), call `UserManager.RemoveAuthenticationTokenAsync` directly: ```csharp await UserManager.RemoveAuthenticationTokenAsync( diff --git a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpDefaultTokenProvider.cs b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpDefaultTokenProvider.cs index 55e874aaaa..e86a258441 100644 --- a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpDefaultTokenProvider.cs +++ b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpDefaultTokenProvider.cs @@ -11,7 +11,8 @@ namespace Volo.Abp.Identity.AspNetCore; /// Replaces ASP.NET Identity's default /// registered under ("Default"). Used by callers such /// as the IdentityServer / OpenIddict token endpoints to issue short-lived challenge tokens -/// (RequiresTwoFactor, ShouldChangePassword) and consumed back by Account / SendSecurityCode. +/// (RequiresTwoFactor, ShouldChangePasswordOnNextLogin, PeriodicallyChangePassword) +/// and consumed back by Account / SendSecurityCode. /// Enforces, per purpose, a single active token to be valid. /// public class AbpDefaultTokenProvider : AbpSingleActiveTokenProvider diff --git a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpDefaultTokenProvider_Tests.cs b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpDefaultTokenProvider_Tests.cs index 9f719a663e..ee39f38456 100644 --- a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpDefaultTokenProvider_Tests.cs +++ b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpDefaultTokenProvider_Tests.cs @@ -12,6 +12,11 @@ public class AbpDefaultTokenProvider_Tests : AbpSingleActiveTokenProviderTestBas { private const string TestPurpose = nameof(SignInResult.RequiresTwoFactor); + // Matches ChangePasswordType.ShouldChangePasswordOnNextLogin.ToString() in + // AbpResourceOwnerPasswordValidator (Volo.Abp.IdentityServer.Domain). Hard-coded + // here to avoid taking a project dependency on the IdentityServer module. + private const string ChangePasswordPurpose = "ShouldChangePasswordOnNextLogin"; + protected override Task GenerateTokenAsync(IdentityUser user) => UserManager.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, TestPurpose); @@ -46,18 +51,16 @@ public class AbpDefaultTokenProvider_Tests : AbpSingleActiveTokenProviderTestBas [Fact] public async Task Tokens_For_Different_Purposes_Should_Be_Independent() { - const string changePasswordPurpose = "ShouldChangePasswordOnNextLogin"; - using (var uow = UnitOfWorkManager.Begin()) { var user = await UserRepository.GetAsync(TestData.UserJohnId); var twoFactorToken = await UserManager.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, TestPurpose); - var changePasswordToken = await UserManager.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, changePasswordPurpose); + var changePasswordToken = await UserManager.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, ChangePasswordPurpose); user = await UserRepository.GetAsync(TestData.UserJohnId); (await UserManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, TestPurpose, twoFactorToken)).ShouldBeTrue(); - (await UserManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, changePasswordPurpose, changePasswordToken)).ShouldBeTrue(); + (await UserManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, ChangePasswordPurpose, changePasswordToken)).ShouldBeTrue(); await uow.CompleteAsync(); } @@ -66,13 +69,11 @@ public class AbpDefaultTokenProvider_Tests : AbpSingleActiveTokenProviderTestBas [Fact] public async Task Regenerating_Same_Purpose_Should_Invalidate_Only_That_Purpose() { - const string changePasswordPurpose = "ShouldChangePasswordOnNextLogin"; - using (var uow = UnitOfWorkManager.Begin()) { var user = await UserRepository.GetAsync(TestData.UserJohnId); var firstTwoFactorToken = await UserManager.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, TestPurpose); - var changePasswordToken = await UserManager.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, changePasswordPurpose); + var changePasswordToken = await UserManager.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, ChangePasswordPurpose); user = await UserRepository.GetAsync(TestData.UserJohnId); var secondTwoFactorToken = await UserManager.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, TestPurpose); @@ -81,7 +82,7 @@ public class AbpDefaultTokenProvider_Tests : AbpSingleActiveTokenProviderTestBas (await UserManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, TestPurpose, firstTwoFactorToken)).ShouldBeFalse(); (await UserManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, TestPurpose, secondTwoFactorToken)).ShouldBeTrue(); - (await UserManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, changePasswordPurpose, changePasswordToken)).ShouldBeTrue(); + (await UserManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, ChangePasswordPurpose, changePasswordToken)).ShouldBeTrue(); await uow.CompleteAsync(); }