diff --git a/Directory.Packages.props b/Directory.Packages.props
index bda9794bab..e13c3de526 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -70,6 +70,7 @@
+
diff --git a/docs/en/modules/identity-pro.md b/docs/en/modules/identity-pro.md
index f916849dd9..a82fa864f7 100644
--- a/docs/en/modules/identity-pro.md
+++ b/docs/en/modules/identity-pro.md
@@ -191,10 +191,10 @@ Just like the password, you can also set the admin email (use the `AdminEmail` k
### AbpIdentityAspNetCoreOptions
-`AbpIdentityAspNetCoreOptions` can be configured in the UI layer, in the `ConfigureServices` method of your [module](../framework/architecture/modularity/basics.md). Example:
+`AbpIdentityAspNetCoreOptions` is read while the services are being registered, so it has to be set in the `PreConfigureServices` method of your [module](../framework/architecture/modularity/basics.md). Example:
````csharp
-Configure(options =>
+PreConfigure(options =>
{
//Set options here...
});
diff --git a/docs/en/modules/identity/token-providers.md b/docs/en/modules/identity/token-providers.md
index 326af4f77c..7ffed9f99a 100644
--- a/docs/en/modules/identity/token-providers.md
+++ b/docs/en/modules/identity/token-providers.md
@@ -1,15 +1,15 @@
```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."
+ "Description": "Learn how the ABP Identity token providers work, what each of them is used for, and how to configure, extend 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.
+ASP.NET Core Identity uses `IUserTwoFactorTokenProvider` to issue and validate the one-off tokens behind password reset, email confirmation, change email, two-factor codes and similar flows.
-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`.
+ABP registers its own providers for these keys. They are single-active: generating a token for the same `(user, provider, purpose)` invalidates the previously issued one, and a two-factor code is consumed the moment it verifies. Lifespans are set per use case rather than shared, and a stored token can be revoked without rotating the user's `SecurityStamp`. The `Authenticator` key keeps ASP.NET Core's provider, because authenticator apps require TOTP. `AbpIdentityDomainModule` does the registration, so every host that loads it resolves the same providers.
## Built-in Providers
@@ -22,47 +22,30 @@ ABP replaces the `Default`, `Email`, and `Phone` provider registrations with sin
| `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) |
+| `TokenOptions.DefaultAuthenticatorProvider` (`"Authenticator"`) | ASP.NET Core's built-in `AuthenticatorTokenProvider`, unless another module replaces it | 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:
+The DataProtector-based providers (`AbpDefaultTokenProvider`, `AbpPasswordResetTokenProvider`, `AbpEmailConfirmationTokenProvider`, `AbpChangeEmailTokenProvider`, `LinkUserTokenProvider`) all derive from the abstract `AbpSingleActiveTokenProvider`. It protects a payload carrying the user id, purpose, `SecurityStamp` and a creation timestamp with the Data Protection purpose taken from the options `Name`, and adds a stored-hash check on top:
-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.
+1. **Generation**: the provider produces the protected token blob. The provider then computes `SHA-256(token)` and stores its hex string as a user token 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 protected blob has been accepted (`DataProtector` unprotect, lifespan, user id, purpose and `SecurityStamp` 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 `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`.
+- **Single active token**: generating one invalidates the previous token for the same `(user, provider, purpose)`. Multiple requests in flight will only let the most recent one complete.
+- **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**: the stamp is part of the protected payload and is compared on validation. The numeric 2FA codes do not carry it and are not affected by rotating it.
+- **Validation never throws on data corruption**: a non-hex stored hash returns `false` from `ValidateAsync` instead of propagating a `FormatException`.
+- **The stored hash and the key ring are shared state**: validating a token means reading the hash back from the same database, and the same tenant database when the solution keeps one per tenant, and unprotecting the payload with the same Data Protection key ring and `SetApplicationName` value. Generating one writes the hash, so that side needs write access and its transaction has to commit before the token is used.
-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.
+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):
+Each DataProtector-based provider exposes an options class deriving from `AbpDataProtectionTokenProviderOptions`, configurable through the standard [options pattern](../../framework/fundamentals/options.md):
| Options class | Default | Used by |
| --- | --- | --- |
@@ -86,10 +69,35 @@ Configure(options =>
});
```
-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`.
+The `Name` property is set by the constructor of each options class and defaults to the key the provider is registered under. It is the Data Protection purpose the token is protected with and the prefix of its stored hash, so changing it invalidates every outstanding token and every host that validates one has to be given the same value. It does not move the provider: the key in `IdentityOptions.Tokens.ProviderMap` is fixed when the provider is registered.
+
+Expiration is checked when a token is validated, so the lifespan has to be configured wherever that happens.
For OTP-based options see [Configuring the Default Providers](./two-factor-authentication.md#configuring-the-default-providers) in the 2FA document.
+## Disabling the ABP Token Providers
+
+To take the token providers into your own hands, turn the ABP ones off and register what you want instead:
+
+```csharp
+public override void PreConfigureServices(ServiceConfigurationContext context)
+{
+ PreConfigure(options =>
+ {
+ options.UseAbpTokenProviders = false;
+ });
+
+ PreConfigure(builder =>
+ {
+ builder.AddDefaultTokenProviders();
+ });
+}
+```
+
+Both have to be set with `PreConfigure`, not `Configure` — the registration reads the pre-configured actions while the services are still being registered. Apply the same configuration on **every** host that generates or validates a token, because a token is bound to the provider that produced it.
+
+The flag turns off the registration itself, not only ABP's choice of provider: the Identity module then registers no token provider at all, and a flow whose key has no provider throws a `NotSupportedException` on the first call. `AddDefaultTokenProviders()` is the ASP.NET Core registration and covers the `Default`, `Email`, `Phone` and `Authenticator` keys. Nothing is registered for the `AbpLinkUser` key, so a solution that links accounts has to provide one for it either way. Other modules keep whatever they register themselves.
+
## 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:
@@ -102,9 +110,9 @@ 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.
+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. They throw an `AbpException` when they cannot see an `AbpSingleActiveTokenProvider` on the key: with the ABP providers turned off there is no stored hash to remove, and a token that was never single-active cannot be revoked this way. A key that also carries a provider registered for a second user type reads as the same case, because the one `IdentityUserManager` picks out of it is not visible to these helpers.
-For tokens issued by `AbpDefaultTokenProvider` (e.g. `RequiresTwoFactor`, `ShouldChangePasswordOnNextLogin`, `PeriodicallyChangePassword`), call `UserManager.RemoveAuthenticationTokenAsync` directly:
+For tokens issued by `AbpDefaultTokenProvider` (e.g. `RequiresTwoFactor`, `ShouldChangePasswordOnNextLogin`, `PeriodicallyChangePassword`), call `UserManager.RemoveAuthenticationTokenAsync` directly. The name is built from the provider's options `Name`, which is `TokenOptions.DefaultProvider` unless you changed it:
```csharp
await UserManager.RemoveAuthenticationTokenAsync(
@@ -125,12 +133,11 @@ PreConfigure(builder =>
});
```
-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.
+The most ergonomic starting point for a single-active variant is to subclass `AbpSingleActiveTokenProvider` and supply your own options class deriving from `AbpDataProtectionTokenProviderOptions`. Give it a `Name` of its own in the constructor, the way the built-in options classes do: the inherited default is a shared one, and two providers under the same name share a Data Protection purpose and a stored-hash key. 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
+## Behavioral 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`.
+- **A single key can be taken over on its own**: registering another provider under one key leaves the rest on the ABP ones, see [Replacing a Provider](#replacing-a-provider). Use `AbpIdentityTokenProviderOptions.UseAbpTokenProviders` to take over all of them at once, see [Disabling the ABP Token Providers](#disabling-the-abp-token-providers).
+- **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 b1998e87c4..a35140c3a9 100644
--- a/docs/en/modules/identity/two-factor-authentication.md
+++ b/docs/en/modules/identity/two-factor-authentication.md
@@ -124,7 +124,7 @@ Configure(IdentityConstants.TwoFactorRememberMeSche
## How the Verification Code Is Generated
-The codes delivered by the **Email** and **SMS** verification providers are produced by ABP's built-in single-use token providers, registered in `AbpIdentityAspNetCoreModule`:
+The codes delivered by the **Email** and **SMS** verification providers are produced by ABP's built-in single-use token providers, registered in `AbpIdentityDomainModule`:
- `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`.
@@ -137,7 +137,7 @@ 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.
-3. **The validity window is exactly the configured lifespan (3 minutes by default).** Expiration is captured as an absolute Unix-seconds value at generation time and is not extended at validation — in contrast to TOTP-based providers, which accept the previous timestep as well and effectively give a 3–6 minute window.
+3. **The validity window is exactly the configured lifespan (3 minutes by default).** Expiration is captured as an absolute Unix-seconds value at generation time and is not extended at validation — in contrast to TOTP-based providers, which accept two timesteps on each side of the current one and so keep a fresh code usable for roughly 6 to 9 minutes.
4. **Failed verification keeps the stored entry in place** so the user can retry until expiration. Rate-limiting incorrect attempts is delegated to ASP.NET Core Identity's lockout settings.
5. **Concurrent successful verification returns `false` instead of throwing.** Two requests racing to consume the same code go through the user row's `ConcurrencyStamp`; the loser surfaces as a normal validation failure rather than a 500.
6. **Expired or undecryptable entries are cleaned up on next access.** A stale entry encountered during validation is removed before returning `false`, so the next `GenerateAsync` starts from a clean slate.
diff --git a/docs/en/release-info/migration-guides/abp-10-2.md b/docs/en/release-info/migration-guides/abp-10-2.md
index 217c4cfa54..62a8b13b1c 100644
--- a/docs/en/release-info/migration-guides/abp-10-2.md
+++ b/docs/en/release-info/migration-guides/abp-10-2.md
@@ -23,6 +23,23 @@ In this version, we increased the maximum length limits for entity and property
> See [#24846](https://github.com/abpframework/abp/pull/24846) for more details.
+### Password Reset, Email Confirmation and Change Email Use Single-Active Token Providers
+
+In this version, `AbpIdentityAspNetCoreModule` registers `AbpPasswordResetTokenProvider`, `AbpEmailConfirmationTokenProvider` and `AbpChangeEmailTokenProvider`, and points `IdentityOptions.Tokens.PasswordResetTokenProvider`, `EmailConfirmationTokenProvider` and `ChangeEmailTokenProvider` at them. Tokens issued by these providers are single-active per user and purpose: generating a new token invalidates the previous one. Each provider reads its own options class, so `Configure` no longer applies to these flows, and the default lifespan for all three is 2 hours instead of the 1 day it was.
+
+**Action required:** Tokens issued before the upgrade are rejected, so users with an outstanding link have to request a new one. If you configured a custom lifespan through `DataProtectionTokenProviderOptions`, move it to the new options classes, on every host that validates a token, because the validating host is the one that decides whether a token is expired:
+
+```csharp
+Configure(options =>
+{
+ options.TokenLifespan = TimeSpan.FromDays(30);
+});
+```
+
+The provider name is also the Data Protection purpose, so a token generated under one provider name cannot be validated under another one, even when both hosts share the same key ring. If a host generates one of these tokens without loading `AbpIdentityAspNetCoreModule` while another host validates it, the two ends resolve different providers and every link is rejected. This affects v10.2 to v10.6. Upgrade to v10.7, where `AbpIdentityDomainModule` registers the providers and both ends agree: see [Identity Token Providers Moved to the Domain Layer](abp-10-7.md#identity-token-providers-moved-to-the-domain-layer).
+
+> See [#24926](https://github.com/abpframework/abp/pull/24926) and the [Identity Token Providers](../../modules/identity/token-providers.md) document for more details.
+
### Ambient Auditing Disable/Enable Support
In this version, we added ambient auditing disable/enable support to the ABP Framework. The `IAuditingHelper` interface now includes `DisableAuditing()` and `IsAuditingEnabled()` methods, allowing you to temporarily disable auditing for specific code blocks using a disposable scope pattern.
diff --git a/docs/en/release-info/migration-guides/abp-10-7.md b/docs/en/release-info/migration-guides/abp-10-7.md
index 7337697f4d..eb2f019d93 100644
--- a/docs/en/release-info/migration-guides/abp-10-7.md
+++ b/docs/en/release-info/migration-guides/abp-10-7.md
@@ -134,6 +134,56 @@ Add a navigation property to the principal entity if you rely on the previous be
> See [#25937](https://github.com/abpframework/abp/pull/25937) for details.
+### Identity Token Providers Moved to the Domain Layer
+
+**Who is affected**
+
+- Solutions where password reset, email confirmation or change email tokens are generated on one host and validated on another, such as a separate authentication server or a microservice solution.
+- Hosts that load only the Identity domain layer and relied on the ASP.NET Core Identity providers for the `Default`, `Email`, `Phone` or `AbpLinkUser` keys.
+- Applications that derive from `AbpSingleActiveTokenProvider`, `AbpTwoFactorTokenProvider` or from one of the ABP token providers, or that call `IdentityUserManagerSingleActiveTokenExtensions`.
+- Applications that treat one of the ABP token provider options classes as a `DataProtectionTokenProviderOptions`.
+
+**What changed**
+
+- The ABP token providers and their options moved from the `Volo.Abp.Identity.AspNetCore` assembly to `Volo.Abp.Identity.Domain`, and are now registered by `AbpIdentityDomainModule` instead of `AbpIdentityAspNetCoreModule`. An assembly compiled against an earlier version and not rebuilt cannot resolve them.
+- Every host that loads `AbpIdentityDomainModule` therefore resolves the same providers, unless it registers something else itself. Previously the providers were only registered on hosts loading `AbpIdentityAspNetCoreModule`, so a host that generated a token could end up on a different provider than the host that validated it, and the link was rejected as an invalid token.
+- `AbpSingleActiveTokenProvider` no longer derives from ASP.NET Core's `DataProtectorTokenProvider`; it implements `IUserTwoFactorTokenProvider` and re-implements the same protected payload, so the token format is unchanged. Code that casts a provider to `DataProtectorTokenProvider` or uses it as a generic constraint no longer compiles, and the `Logger` property the old base class exposed publicly is now protected.
+- The provider options classes derive from `AbpDataProtectionTokenProviderOptions` instead of `DataProtectionTokenProviderOptions`. The `Name` and `TokenLifespan` properties are unchanged, but code that assigns one of them to `DataProtectionTokenProviderOptions`, passes it to a method taking that type, returns it, or uses it as a generic constraint no longer compiles.
+- `IdentityUserManagerSingleActiveTokenExtensions` moved with the providers, and its `Remove*TokenAsync` helpers changed in two ways. They now follow the provider's options `Name` instead of the key it is registered under, so an application that renamed a provider gets the hash it actually wrote removed. And they throw an `AbpException` instead of reporting success when they cannot see an `AbpSingleActiveTokenProvider` on the key. That is the case once the ABP providers are turned off, where there is no stored hash to remove and a token that was never single-active cannot be revoked this way. It is also the case when the key carries a provider for a second user type, which `IdentityUserManager` skips and these helpers cannot: they read the key's public `ProviderType`, while the one the manager picks is only reachable through an internal ASP.NET Core API.
+- The constructors changed accordingly. `AbpSingleActiveTokenProvider` takes `IOptions`, which each provider satisfies with its own concrete options class, and `ILogger` instead of `IOptions` and `ILogger>`. The five DataProtector-based providers (`AbpDefaultTokenProvider`, `AbpPasswordResetTokenProvider`, `AbpEmailConfirmationTokenProvider`, `AbpChangeEmailTokenProvider`, `LinkUserTokenProvider`) take the new logger type as well. The email and phone 2FA providers moved unchanged. Constructing a provider by hand also behaves differently at the edges: a null options now throws instead of falling back to the ASP.NET Core defaults, which carry the wrong provider name, and a null logger falls back to `NullLogger` instead of throwing.
+- `AbpIdentityDomainModule` calls `AddDataProtection()`, because `UserManager` instantiates every provider in `Tokens.ProviderMap` when it is resolved and the DataProtector-based providers need `IDataProtectionProvider`. Hosts that never issue a token, such as a DbMigrator console application, do not register it themselves, and now load the key ring on startup and create one if the store is empty. That is a side effect for such a host, not a reason to configure it: only a host that generates or validates a token needs the same key ring and `SetApplicationName` as the rest of the solution.
+
+**What to do**
+
+- Rebuild the solution. No further action is required for the common case: configuring the options and the provider names works exactly as before, and the compiler points at the source level changes below. The assembly move is not one of them, so a package you depend on that was built against the old assembly has to be rebuilt and republished against v10.7 as well.
+- If you derive from one of the five DataProtector-based providers, change the logger parameter to `ILogger`.
+- If you derive from `AbpSingleActiveTokenProvider` directly, give your provider its own options class deriving from `AbpDataProtectionTokenProviderOptions` and inject `IOptions`, the way the built-in providers do. `IOptions` is covariant, so it satisfies the base constructor. Do not inject `IOptions`: the base options class is abstract and the options system cannot create it.
+- If a host of yours registered these providers by hand, you can drop that code. `IdentityBuilder.AddAbpTokenProviders()` is public if you want to call it explicitly.
+- Remove an `AddDefaultTokenProviders()` call a domain-only host made for itself, unless you turn the ABP providers off everywhere. Application actions run after the framework registration, so that call now takes the `Default`, `Email` and `Phone` keys back to the ASP.NET Core providers while the other keys stay on the ABP ones, and a host that keeps it no longer agrees with one that does not.
+- Tell the users of a domain-only host to request a new link. The tokens it issued through the stock providers before the upgrade are rejected afterwards.
+- Move a `Configure` a domain-only host relied on to the corresponding ABP options class, on every host that validates a token. It stops applying there, the way it stopped applying on hosts loading `AbpIdentityAspNetCoreModule` in v10.2.
+- Give a domain-only host write access to the shared identity database. It now stores a user token for every token it generates through these providers. The `Authenticator` provider is unchanged and stores nothing.
+- To keep the ASP.NET Core Identity providers instead, turn the ABP ones off and register them yourself, on **every** host that generates or validates a token:
+
+```csharp
+public override void PreConfigureServices(ServiceConfigurationContext context)
+{
+ PreConfigure(options =>
+ {
+ options.UseAbpTokenProviders = false;
+ });
+
+ PreConfigure(builder =>
+ {
+ builder.AddDefaultTokenProviders();
+ });
+}
+```
+
+Both have to be `PreConfigure`, not `Configure`. The flag turns off the registration itself on every host alike, so the Identity module registers no token provider at all and a flow whose key has no provider throws `NotSupportedException` on the first call. `AddDefaultTokenProviders()` covers the `Default`, `Email`, `Phone` and `Authenticator` keys, and nothing covers the `AbpLinkUser` key either way.
+
+> See the [Identity Token Providers](../../modules/identity/token-providers.md) document for details.
+
### Dependency Updates
**Who is affected**
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 91453af613..0c007d4117 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,14 +19,6 @@ public class AbpIdentityAspNetCoreModule : AbpModule
PreConfigure(builder =>
{
builder
- .AddDefaultTokenProviders()
- .AddTokenProvider(TokenOptions.DefaultProvider)
- .AddTokenProvider(LinkUserTokenProviderConsts.LinkUserTokenProviderName)
- .AddTokenProvider(AbpPasswordResetTokenProvider.ProviderName)
- .AddTokenProvider(AbpEmailConfirmationTokenProvider.ProviderName)
- .AddTokenProvider(AbpChangeEmailTokenProvider.ProviderName)
- .AddTokenProvider(TokenOptions.DefaultEmailProvider)
- .AddTokenProvider(TokenOptions.DefaultPhoneProvider)
.AddSignInManager()
.AddUserValidator();
});
@@ -36,13 +28,6 @@ public class AbpIdentityAspNetCoreModule : AbpModule
{
context.Services.AddHttpContextAccessor();
- Configure(options =>
- {
- options.Tokens.PasswordResetTokenProvider = AbpPasswordResetTokenProvider.ProviderName;
- options.Tokens.EmailConfirmationTokenProvider = AbpEmailConfirmationTokenProvider.ProviderName;
- options.Tokens.ChangeEmailTokenProvider = AbpChangeEmailTokenProvider.ProviderName;
- });
-
//(TODO: Extract an extension method like IdentityBuilder.AddAbpSecurityStampValidator())
context.Services.AddScoped();
context.Services.AddScoped(typeof(SecurityStampValidator), provider => provider.GetService(typeof(AbpSecurityStampValidator)));
diff --git a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpSingleActiveTokenProvider.cs b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpSingleActiveTokenProvider.cs
deleted file mode 100644
index 835b52b3b0..0000000000
--- a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpSingleActiveTokenProvider.cs
+++ /dev/null
@@ -1,94 +0,0 @@
-using System;
-using System.Security.Cryptography;
-using System.Text;
-using System.Threading.Tasks;
-using Microsoft.AspNetCore.DataProtection;
-using Microsoft.AspNetCore.Identity;
-using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Options;
-using Volo.Abp.Domain.Repositories;
-using Volo.Abp.Identity;
-using Volo.Abp.Threading;
-
-namespace Volo.Abp.Identity.AspNetCore;
-
-///
-/// Base class for ABP token providers that enforce a "single active token" policy:
-/// generating a new token automatically invalidates all previously issued tokens.
-/// Token validity is enforced by SecurityStamp verification (via the base class) and
-/// by the stored hash, which is overwritten each time a new token is generated.
-///
-public abstract class AbpSingleActiveTokenProvider : DataProtectorTokenProvider
-{
- ///
- /// The internal login provider name used to store token hashes in the user token table.
- /// Using a bracketed name clearly distinguishes these internal entries from real external
- /// login providers (e.g. Google, GitHub) stored in the same table.
- ///
- public const string InternalLoginProvider = "[AbpSingleActiveToken]";
-
- protected IIdentityUserRepository UserRepository { get; }
-
- protected ICancellationTokenProvider CancellationTokenProvider { get; }
-
- protected AbpSingleActiveTokenProvider(
- IDataProtectionProvider dataProtectionProvider,
- IOptions options,
- ILogger> logger,
- IIdentityUserRepository userRepository,
- ICancellationTokenProvider cancellationTokenProvider)
- : base(dataProtectionProvider, options, logger)
- {
- UserRepository = userRepository;
- CancellationTokenProvider = cancellationTokenProvider;
- }
-
- public override async Task GenerateAsync(string purpose, UserManager manager, IdentityUser user)
- {
- var token = await base.GenerateAsync(purpose, manager, user);
-
- await UserRepository.EnsureCollectionLoadedAsync(user, u => u.Tokens, CancellationTokenProvider.Token);
- var tokenHash = ComputeSha256Hash(token);
- user.SetToken(InternalLoginProvider, Options.Name + ":" + purpose, tokenHash);
-
- (await manager.UpdateAsync(user)).CheckErrors();
-
- return token;
- }
-
- public override async Task ValidateAsync(string purpose, string token, UserManager manager, IdentityUser user)
- {
- if (!await base.ValidateAsync(purpose, token, manager, user))
- {
- return false;
- }
-
- await UserRepository.EnsureCollectionLoadedAsync(user, u => u.Tokens, CancellationTokenProvider.Token);
-
- var storedHash = user.FindToken(InternalLoginProvider, Options.Name + ":" + purpose)?.Value;
- if (storedHash == null)
- {
- return false;
- }
-
- var inputHash = ComputeSha256Hash(token);
- try
- {
- var storedHashBytes = Convert.FromHexString(storedHash);
- var inputHashBytes = Convert.FromHexString(inputHash);
- return CryptographicOperations.FixedTimeEquals(storedHashBytes, inputHashBytes);
- }
- catch (FormatException)
- {
- // In case the stored hash is corrupted or not a valid hex string,
- // treat the token as invalid rather than throwing.
- return false;
- }
- }
-
- protected virtual string ComputeSha256Hash(string input)
- {
- var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
- return Convert.ToHexString(bytes);
- }
-}
diff --git a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/IdentityUserManagerSingleActiveTokenExtensions.cs b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/IdentityUserManagerSingleActiveTokenExtensions.cs
deleted file mode 100644
index 59d8bd4690..0000000000
--- a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/IdentityUserManagerSingleActiveTokenExtensions.cs
+++ /dev/null
@@ -1,62 +0,0 @@
-using System.Threading.Tasks;
-using Microsoft.AspNetCore.Identity;
-
-namespace Volo.Abp.Identity.AspNetCore;
-
-///
-/// Provides extension methods on for invalidating
-/// single-active tokens managed by .
-/// These helpers live in the AspNetCore layer because they depend on
-/// .
-///
-public static class IdentityUserManagerSingleActiveTokenExtensions
-{
- ///
- /// Removes the stored password-reset token hash for ,
- /// immediately invalidating any previously issued password-reset token.
- ///
- public static Task RemovePasswordResetTokenAsync(this IdentityUserManager manager, IdentityUser user)
- {
- var name = manager.Options.Tokens.PasswordResetTokenProvider + ":" + UserManager.ResetPasswordTokenPurpose;
- return manager.RemoveAuthenticationTokenAsync(user, AbpSingleActiveTokenProvider.InternalLoginProvider, name);
- }
-
- ///
- /// Removes the stored email-confirmation token hash for ,
- /// immediately invalidating any previously issued email-confirmation token.
- ///
- public static Task RemoveEmailConfirmationTokenAsync(this IdentityUserManager manager, IdentityUser user)
- {
- var name = manager.Options.Tokens.EmailConfirmationTokenProvider + ":" + UserManager.ConfirmEmailTokenPurpose;
- return manager.RemoveAuthenticationTokenAsync(user, AbpSingleActiveTokenProvider.InternalLoginProvider, name);
- }
-
- ///
- /// Removes the stored change-email token hash for ,
- /// immediately invalidating any previously issued change-email token for .
- ///
- public static Task RemoveChangeEmailTokenAsync(this IdentityUserManager manager, IdentityUser user, string newEmail)
- {
- var name = manager.Options.Tokens.ChangeEmailTokenProvider + ":" + UserManager.GetChangeEmailTokenPurpose(newEmail);
- return manager.RemoveAuthenticationTokenAsync(user, AbpSingleActiveTokenProvider.InternalLoginProvider, name);
- }
-
- ///
- /// Removes the stored link-user token hash for ,
- /// immediately invalidating any previously issued link-user token.
- ///
- public static Task RemoveLinkUserTokenAsync(this IdentityUserManager manager, IdentityUser user)
- {
- return RemoveLinkUserTokenAsync(manager, user, LinkUserTokenProviderConsts.LinkUserTokenPurpose);
- }
-
- ///
- /// Removes the stored link-user token hash for and the given ,
- /// immediately invalidating any previously issued link-user token for that purpose.
- ///
- public static Task RemoveLinkUserTokenAsync(this IdentityUserManager manager, IdentityUser user, string purpose)
- {
- var name = LinkUserTokenProviderConsts.LinkUserTokenProviderName + ":" + purpose;
- return manager.RemoveAuthenticationTokenAsync(user, AbpSingleActiveTokenProvider.InternalLoginProvider, name);
- }
-}
diff --git a/modules/identity/src/Volo.Abp.Identity.Domain/Microsoft/Extensions/DependencyInjection/AbpIdentityBuilderExtensions.cs b/modules/identity/src/Volo.Abp.Identity.Domain/Microsoft/Extensions/DependencyInjection/AbpIdentityBuilderExtensions.cs
new file mode 100644
index 0000000000..3f251a42c3
--- /dev/null
+++ b/modules/identity/src/Volo.Abp.Identity.Domain/Microsoft/Extensions/DependencyInjection/AbpIdentityBuilderExtensions.cs
@@ -0,0 +1,34 @@
+using Microsoft.AspNetCore.Identity;
+using Volo.Abp.Identity;
+using Volo.Abp.Identity.AspNetCore;
+
+namespace Microsoft.Extensions.DependencyInjection;
+
+public static class AbpIdentityBuilderExtensions
+{
+ ///
+ /// The providers are written for , so the builder has to be one for that
+ /// user type.
+ ///
+ public static IdentityBuilder AddAbpTokenProviders(this IdentityBuilder builder)
+ {
+ builder
+ .AddTokenProvider(TokenOptions.DefaultProvider)
+ .AddTokenProvider(TokenOptions.DefaultEmailProvider)
+ .AddTokenProvider(TokenOptions.DefaultPhoneProvider)
+ .AddTokenProvider>(TokenOptions.DefaultAuthenticatorProvider)
+ .AddTokenProvider(AbpPasswordResetTokenProvider.ProviderName)
+ .AddTokenProvider(AbpEmailConfirmationTokenProvider.ProviderName)
+ .AddTokenProvider(AbpChangeEmailTokenProvider.ProviderName)
+ .AddTokenProvider(LinkUserTokenProviderConsts.LinkUserTokenProviderName);
+
+ builder.Services.Configure(options =>
+ {
+ options.Tokens.PasswordResetTokenProvider = AbpPasswordResetTokenProvider.ProviderName;
+ options.Tokens.EmailConfirmationTokenProvider = AbpEmailConfirmationTokenProvider.ProviderName;
+ options.Tokens.ChangeEmailTokenProvider = AbpChangeEmailTokenProvider.ProviderName;
+ });
+
+ return builder;
+ }
+}
diff --git a/modules/identity/src/Volo.Abp.Identity.Domain/Volo.Abp.Identity.Domain.csproj b/modules/identity/src/Volo.Abp.Identity.Domain/Volo.Abp.Identity.Domain.csproj
index 39822ebb83..7ef89b8da1 100644
--- a/modules/identity/src/Volo.Abp.Identity.Domain/Volo.Abp.Identity.Domain.csproj
+++ b/modules/identity/src/Volo.Abp.Identity.Domain/Volo.Abp.Identity.Domain.csproj
@@ -29,6 +29,7 @@
+
diff --git a/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AbpIdentityDomainModule.cs b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AbpIdentityDomainModule.cs
index 590beb8e09..682b323437 100644
--- a/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AbpIdentityDomainModule.cs
+++ b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AbpIdentityDomainModule.cs
@@ -54,6 +54,15 @@ public class AbpIdentityDomainModule : AbpModule
});
context.Services.AddObjectAccessor(identityBuilder);
+
+ context.Services.AddDataProtection();
+
+ var tokenProviderOptions = context.Services.ExecutePreConfiguredActions(new AbpIdentityTokenProviderOptions());
+ if (tokenProviderOptions.UseAbpTokenProviders)
+ {
+ identityBuilder.AddAbpTokenProviders();
+ }
+
context.Services.ExecutePreConfiguredActions(identityBuilder);
Configure(options =>
diff --git a/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AbpIdentityTokenProviderOptions.cs b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AbpIdentityTokenProviderOptions.cs
new file mode 100644
index 0000000000..031f903b59
--- /dev/null
+++ b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AbpIdentityTokenProviderOptions.cs
@@ -0,0 +1,9 @@
+namespace Volo.Abp.Identity;
+
+public class AbpIdentityTokenProviderOptions
+{
+ ///
+ /// Has to be set with PreConfigure: it is read while the token providers are being registered.
+ ///
+ public bool UseAbpTokenProviders { get; set; } = true;
+}
diff --git a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpChangeEmailTokenProvider.cs b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/AbpChangeEmailTokenProvider.cs
similarity index 93%
rename from modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpChangeEmailTokenProvider.cs
rename to modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/AbpChangeEmailTokenProvider.cs
index 344c188264..9a7d204205 100644
--- a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpChangeEmailTokenProvider.cs
+++ b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/AbpChangeEmailTokenProvider.cs
@@ -18,7 +18,7 @@ public class AbpChangeEmailTokenProvider : AbpSingleActiveTokenProvider
public AbpChangeEmailTokenProvider(
IDataProtectionProvider dataProtectionProvider,
IOptions options,
- ILogger> logger,
+ 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/AbpChangeEmailTokenProviderOptions.cs b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/AbpChangeEmailTokenProviderOptions.cs
similarity index 64%
rename from modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpChangeEmailTokenProviderOptions.cs
rename to modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/AbpChangeEmailTokenProviderOptions.cs
index f1e201d240..395143b5a6 100644
--- a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpChangeEmailTokenProviderOptions.cs
+++ b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/AbpChangeEmailTokenProviderOptions.cs
@@ -1,9 +1,8 @@
using System;
-using Microsoft.AspNetCore.Identity;
namespace Volo.Abp.Identity.AspNetCore;
-public class AbpChangeEmailTokenProviderOptions : DataProtectionTokenProviderOptions
+public class AbpChangeEmailTokenProviderOptions : AbpDataProtectionTokenProviderOptions
{
public AbpChangeEmailTokenProviderOptions()
{
diff --git a/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/AbpDataProtectionTokenProviderOptions.cs b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/AbpDataProtectionTokenProviderOptions.cs
new file mode 100644
index 0000000000..b2b5e76ee1
--- /dev/null
+++ b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/AbpDataProtectionTokenProviderOptions.cs
@@ -0,0 +1,18 @@
+using System;
+
+namespace Volo.Abp.Identity.AspNetCore;
+
+///
+/// Stands in for ASP.NET Core's DataProtectionTokenProviderOptions, which is only available
+/// through the ASP.NET Core shared framework.
+///
+public abstract class AbpDataProtectionTokenProviderOptions
+{
+ ///
+ /// Also the DataProtection purpose, so two providers with different names cannot validate each
+ /// other's tokens.
+ ///
+ public string Name { get; set; } = "DataProtectorTokenProvider";
+
+ public TimeSpan TokenLifespan { get; set; } = TimeSpan.FromDays(1);
+}
diff --git a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpDefaultTokenProvider.cs b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/AbpDefaultTokenProvider.cs
similarity index 87%
rename from modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpDefaultTokenProvider.cs
rename to modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/AbpDefaultTokenProvider.cs
index e86a258441..9b08a079bd 100644
--- a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpDefaultTokenProvider.cs
+++ b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/AbpDefaultTokenProvider.cs
@@ -8,7 +8,7 @@ using Volo.Abp.Threading;
namespace Volo.Abp.Identity.AspNetCore;
///
-/// Replaces ASP.NET Identity's default
+/// Replaces ASP.NET Core Identity's default DataProtectorTokenProvider<IdentityUser>
/// registered under ("Default"). Used by callers such
/// as the IdentityServer / OpenIddict token endpoints to issue short-lived challenge tokens
/// (RequiresTwoFactor, ShouldChangePasswordOnNextLogin, PeriodicallyChangePassword)
@@ -20,7 +20,7 @@ public class AbpDefaultTokenProvider : AbpSingleActiveTokenProvider
public AbpDefaultTokenProvider(
IDataProtectionProvider dataProtectionProvider,
IOptions options,
- ILogger> logger,
+ 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.Domain/Volo/Abp/Identity/AspNetCore/AbpDefaultTokenProviderOptions.cs
similarity index 74%
rename from modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpDefaultTokenProviderOptions.cs
rename to modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/AbpDefaultTokenProviderOptions.cs
index 42e2fc67e0..81564d2129 100644
--- a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpDefaultTokenProviderOptions.cs
+++ b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/AbpDefaultTokenProviderOptions.cs
@@ -3,7 +3,7 @@ using Microsoft.AspNetCore.Identity;
namespace Volo.Abp.Identity.AspNetCore;
-public class AbpDefaultTokenProviderOptions : DataProtectionTokenProviderOptions
+public class AbpDefaultTokenProviderOptions : AbpDataProtectionTokenProviderOptions
{
public AbpDefaultTokenProviderOptions()
{
diff --git a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpEmailConfirmationTokenProvider.cs b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/AbpEmailConfirmationTokenProvider.cs
similarity index 96%
rename from modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpEmailConfirmationTokenProvider.cs
rename to modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/AbpEmailConfirmationTokenProvider.cs
index 1f99543f8f..5843e5346d 100644
--- a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpEmailConfirmationTokenProvider.cs
+++ b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/AbpEmailConfirmationTokenProvider.cs
@@ -31,7 +31,7 @@ public class AbpEmailConfirmationTokenProvider : AbpSingleActiveTokenProvider
public AbpEmailConfirmationTokenProvider(
IDataProtectionProvider dataProtectionProvider,
IOptions options,
- ILogger> logger,
+ 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/AbpEmailConfirmationTokenProviderOptions.cs b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/AbpEmailConfirmationTokenProviderOptions.cs
similarity index 64%
rename from modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpEmailConfirmationTokenProviderOptions.cs
rename to modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/AbpEmailConfirmationTokenProviderOptions.cs
index 34909360f2..b0844b1385 100644
--- a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpEmailConfirmationTokenProviderOptions.cs
+++ b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/AbpEmailConfirmationTokenProviderOptions.cs
@@ -1,9 +1,8 @@
using System;
-using Microsoft.AspNetCore.Identity;
namespace Volo.Abp.Identity.AspNetCore;
-public class AbpEmailConfirmationTokenProviderOptions : DataProtectionTokenProviderOptions
+public class AbpEmailConfirmationTokenProviderOptions : AbpDataProtectionTokenProviderOptions
{
public AbpEmailConfirmationTokenProviderOptions()
{
diff --git a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpEmailTwoFactorTokenProvider.cs b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/AbpEmailTwoFactorTokenProvider.cs
similarity index 100%
rename from modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpEmailTwoFactorTokenProvider.cs
rename to modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/AbpEmailTwoFactorTokenProvider.cs
diff --git a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpEmailTwoFactorTokenProviderOptions.cs b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/AbpEmailTwoFactorTokenProviderOptions.cs
similarity index 100%
rename from modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpEmailTwoFactorTokenProviderOptions.cs
rename to modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/AbpEmailTwoFactorTokenProviderOptions.cs
diff --git a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpLinkUserTokenProviderOptions.cs b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/AbpLinkUserTokenProviderOptions.cs
similarity index 66%
rename from modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpLinkUserTokenProviderOptions.cs
rename to modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/AbpLinkUserTokenProviderOptions.cs
index ec06cf0156..e86b3d85a7 100644
--- a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpLinkUserTokenProviderOptions.cs
+++ b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/AbpLinkUserTokenProviderOptions.cs
@@ -1,9 +1,8 @@
using System;
-using Microsoft.AspNetCore.Identity;
namespace Volo.Abp.Identity.AspNetCore;
-public class AbpLinkUserTokenProviderOptions : DataProtectionTokenProviderOptions
+public class AbpLinkUserTokenProviderOptions : AbpDataProtectionTokenProviderOptions
{
public AbpLinkUserTokenProviderOptions()
{
diff --git a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpPasswordResetTokenProvider.cs b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/AbpPasswordResetTokenProvider.cs
similarity index 93%
rename from modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpPasswordResetTokenProvider.cs
rename to modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/AbpPasswordResetTokenProvider.cs
index cc3c960804..3359b0f594 100644
--- a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpPasswordResetTokenProvider.cs
+++ b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/AbpPasswordResetTokenProvider.cs
@@ -18,7 +18,7 @@ public class AbpPasswordResetTokenProvider : AbpSingleActiveTokenProvider
public AbpPasswordResetTokenProvider(
IDataProtectionProvider dataProtectionProvider,
IOptions options,
- ILogger> logger,
+ 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/AbpPasswordResetTokenProviderOptions.cs b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/AbpPasswordResetTokenProviderOptions.cs
similarity index 64%
rename from modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpPasswordResetTokenProviderOptions.cs
rename to modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/AbpPasswordResetTokenProviderOptions.cs
index 15b42acad5..cf76fb5604 100644
--- a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpPasswordResetTokenProviderOptions.cs
+++ b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/AbpPasswordResetTokenProviderOptions.cs
@@ -1,9 +1,8 @@
using System;
-using Microsoft.AspNetCore.Identity;
namespace Volo.Abp.Identity.AspNetCore;
-public class AbpPasswordResetTokenProviderOptions : DataProtectionTokenProviderOptions
+public class AbpPasswordResetTokenProviderOptions : AbpDataProtectionTokenProviderOptions
{
public AbpPasswordResetTokenProviderOptions()
{
diff --git a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpPhoneNumberTwoFactorTokenProvider.cs b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/AbpPhoneNumberTwoFactorTokenProvider.cs
similarity index 100%
rename from modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpPhoneNumberTwoFactorTokenProvider.cs
rename to modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/AbpPhoneNumberTwoFactorTokenProvider.cs
diff --git a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpPhoneNumberTwoFactorTokenProviderOptions.cs b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/AbpPhoneNumberTwoFactorTokenProviderOptions.cs
similarity index 100%
rename from modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpPhoneNumberTwoFactorTokenProviderOptions.cs
rename to modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/AbpPhoneNumberTwoFactorTokenProviderOptions.cs
diff --git a/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/AbpSingleActiveTokenProvider.cs b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/AbpSingleActiveTokenProvider.cs
new file mode 100644
index 0000000000..303f6d84b0
--- /dev/null
+++ b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/AbpSingleActiveTokenProvider.cs
@@ -0,0 +1,200 @@
+using System;
+using System.IO;
+using System.Security.Cryptography;
+using System.Text;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.DataProtection;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using Volo.Abp.Domain.Repositories;
+using Volo.Abp.Threading;
+
+namespace Volo.Abp.Identity.AspNetCore;
+
+///
+/// Base class for ABP token providers that enforce a "single active token" policy: generating a token
+/// invalidates the one before it for the same user, provider and purpose.
+///
+/// Re-implements ASP.NET Core's DataProtectorTokenProvider, which is only available through the
+/// ASP.NET Core shared framework, and stays byte compatible with it.
+///
+///
+public abstract class AbpSingleActiveTokenProvider : IUserTwoFactorTokenProvider
+{
+ ///
+ /// The internal login provider name used to store token hashes among the user's tokens.
+ /// Using a bracketed name clearly distinguishes these internal entries from real external
+ /// login providers (e.g. Google, GitHub) stored in the same table.
+ ///
+ public const string InternalLoginProvider = "[AbpSingleActiveToken]";
+
+ protected static readonly Encoding PayloadEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
+
+ protected AbpDataProtectionTokenProviderOptions Options { get; }
+
+ protected IDataProtector Protector { get; }
+
+ protected ILogger Logger { get; }
+
+ protected IIdentityUserRepository UserRepository { get; }
+
+ protected ICancellationTokenProvider CancellationTokenProvider { get; }
+
+ public string Name => Options.Name;
+
+ protected AbpSingleActiveTokenProvider(
+ IDataProtectionProvider dataProtectionProvider,
+ IOptions options,
+ ILogger logger,
+ IIdentityUserRepository userRepository,
+ ICancellationTokenProvider cancellationTokenProvider)
+ {
+ Check.NotNull(dataProtectionProvider, nameof(dataProtectionProvider));
+ Check.NotNull(options, nameof(options));
+
+ Options = options.Value;
+ Protector = dataProtectionProvider.CreateProtector(Options.Name ?? "DataProtectorTokenProvider");
+ Logger = logger ?? NullLogger.Instance;
+ UserRepository = userRepository;
+ CancellationTokenProvider = cancellationTokenProvider;
+ }
+
+ public virtual async Task GenerateAsync(string purpose, UserManager manager, IdentityUser user)
+ {
+ Check.NotNull(user, nameof(user));
+
+ var token = await ProtectAsync(purpose, manager, user);
+
+ await UserRepository.EnsureCollectionLoadedAsync(user, u => u.Tokens, CancellationTokenProvider.Token);
+ var tokenHash = ComputeSha256Hash(token);
+ user.SetToken(InternalLoginProvider, Options.Name + ":" + purpose, tokenHash);
+
+ (await manager.UpdateAsync(user)).CheckErrors();
+
+ return token;
+ }
+
+ public virtual async Task ValidateAsync(string purpose, string token, UserManager manager, IdentityUser user)
+ {
+ if (!await UnprotectAsync(purpose, token, manager, user))
+ {
+ return false;
+ }
+
+ await UserRepository.EnsureCollectionLoadedAsync(user, u => u.Tokens, CancellationTokenProvider.Token);
+
+ var storedHash = user.FindToken(InternalLoginProvider, Options.Name + ":" + purpose)?.Value;
+ if (storedHash == null)
+ {
+ Logger.LogDebug("No stored hash for the '{ProviderName}' token and the '{Purpose}' purpose. It was never issued, it was removed, or the token came from a provider that does not store one.", Options.Name, purpose);
+ return false;
+ }
+
+ var inputHash = ComputeSha256Hash(token);
+ try
+ {
+ var storedHashBytes = Convert.FromHexString(storedHash);
+ var inputHashBytes = Convert.FromHexString(inputHash);
+ return CryptographicOperations.FixedTimeEquals(storedHashBytes, inputHashBytes);
+ }
+ catch (FormatException)
+ {
+ return false;
+ }
+ }
+
+ public virtual Task CanGenerateTwoFactorTokenAsync(UserManager manager, IdentityUser user)
+ {
+ return Task.FromResult(false);
+ }
+
+ ///
+ /// Keep in sync with ASP.NET Core's DataProtectorTokenProvider: the two have to stay
+ /// interchangeable under the same provider name.
+ ///
+ protected virtual async Task ProtectAsync(string purpose, UserManager manager, IdentityUser user)
+ {
+ var stream = new MemoryStream();
+ var userId = await manager.GetUserIdAsync(user);
+
+ using (var writer = new BinaryWriter(stream, PayloadEncoding, leaveOpen: true))
+ {
+ // Not IClock: the payload has to stay byte compatible with ASP.NET Core's.
+ writer.Write(DateTimeOffset.UtcNow.UtcTicks);
+ writer.Write(userId);
+ writer.Write(purpose ?? "");
+ writer.Write(manager.SupportsUserSecurityStamp ? await manager.GetSecurityStampAsync(user) ?? "" : "");
+ }
+
+ return Convert.ToBase64String(Protector.Protect(stream.ToArray()));
+ }
+
+ protected virtual async Task UnprotectAsync(string purpose, string token, UserManager manager, IdentityUser user)
+ {
+ try
+ {
+ var stream = new MemoryStream(Protector.Unprotect(Convert.FromBase64String(token)));
+ using (var reader = new BinaryReader(stream, PayloadEncoding, leaveOpen: true))
+ {
+ var creationTime = new DateTimeOffset(reader.ReadInt64(), TimeSpan.Zero);
+ if (creationTime + Options.TokenLifespan < DateTimeOffset.UtcNow)
+ {
+ Logger.LogDebug("Invalid expiration time for the '{ProviderName}' token.", Options.Name);
+ return false;
+ }
+
+ if (reader.ReadString() != await manager.GetUserIdAsync(user))
+ {
+ Logger.LogDebug("User ID of the '{ProviderName}' token does not match the current user.", Options.Name);
+ return false;
+ }
+
+ var tokenPurpose = reader.ReadString();
+ if (!string.Equals(tokenPurpose, purpose))
+ {
+ Logger.LogDebug("Purpose of the '{ProviderName}' token is '{TokenPurpose}' but '{Purpose}' was expected.", Options.Name, tokenPurpose, purpose);
+ return false;
+ }
+
+ var stamp = reader.ReadString();
+ if (reader.PeekChar() != -1)
+ {
+ Logger.LogDebug("Unexpected data after the end of the '{ProviderName}' token payload.", Options.Name);
+ return false;
+ }
+
+ if (manager.SupportsUserSecurityStamp)
+ {
+ if (stamp == await manager.GetSecurityStampAsync(user))
+ {
+ return true;
+ }
+
+ Logger.LogDebug("Security stamp of the '{ProviderName}' token does not match the current one.", Options.Name);
+ return false;
+ }
+
+ if (stamp == "")
+ {
+ return true;
+ }
+
+ Logger.LogDebug("Security stamp of the '{ProviderName}' token is not empty.", Options.Name);
+ return false;
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.LogDebug(ex, "Could not read the '{ProviderName}' token. It was protected under another provider name, key ring or application name, or the payload is not a token this provider produced.", Options.Name);
+ return false;
+ }
+ }
+
+ protected virtual string ComputeSha256Hash(string input)
+ {
+ var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
+ return Convert.ToHexString(bytes);
+ }
+}
diff --git a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpTwoFactorTokenProvider.cs b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/AbpTwoFactorTokenProvider.cs
similarity index 100%
rename from modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpTwoFactorTokenProvider.cs
rename to modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/AbpTwoFactorTokenProvider.cs
diff --git a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpTwoFactorTokenProviderOptions.cs b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/AbpTwoFactorTokenProviderOptions.cs
similarity index 100%
rename from modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpTwoFactorTokenProviderOptions.cs
rename to modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/AbpTwoFactorTokenProviderOptions.cs
diff --git a/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/IdentityUserManagerSingleActiveTokenExtensions.cs b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/IdentityUserManagerSingleActiveTokenExtensions.cs
new file mode 100644
index 0000000000..7186d5e883
--- /dev/null
+++ b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/IdentityUserManagerSingleActiveTokenExtensions.cs
@@ -0,0 +1,110 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Identity;
+
+namespace Volo.Abp.Identity.AspNetCore;
+
+///
+/// Provides extension methods on for invalidating
+/// single-active tokens managed by .
+///
+public static class IdentityUserManagerSingleActiveTokenExtensions
+{
+ ///
+ /// Removes the stored password-reset token hash for ,
+ /// immediately invalidating any previously issued password-reset token.
+ ///
+ public static Task RemovePasswordResetTokenAsync(this IdentityUserManager manager, IdentityUser user)
+ {
+ return RemoveStoredTokenAsync(
+ manager,
+ user,
+ manager.Options.Tokens.PasswordResetTokenProvider,
+ UserManager.ResetPasswordTokenPurpose);
+ }
+
+ ///
+ /// Removes the stored email-confirmation token hash for ,
+ /// immediately invalidating any previously issued email-confirmation token.
+ ///
+ public static Task RemoveEmailConfirmationTokenAsync(this IdentityUserManager manager, IdentityUser user)
+ {
+ return RemoveStoredTokenAsync(
+ manager,
+ user,
+ manager.Options.Tokens.EmailConfirmationTokenProvider,
+ UserManager.ConfirmEmailTokenPurpose);
+ }
+
+ ///
+ /// Removes the stored change-email token hash for ,
+ /// immediately invalidating any previously issued change-email token for .
+ ///
+ public static Task RemoveChangeEmailTokenAsync(this IdentityUserManager manager, IdentityUser user, string newEmail)
+ {
+ return RemoveStoredTokenAsync(
+ manager,
+ user,
+ manager.Options.Tokens.ChangeEmailTokenProvider,
+ UserManager.GetChangeEmailTokenPurpose(newEmail));
+ }
+
+ ///
+ /// Removes the stored link-user token hash for ,
+ /// immediately invalidating any previously issued link-user token.
+ ///
+ public static Task RemoveLinkUserTokenAsync(this IdentityUserManager manager, IdentityUser user)
+ {
+ return RemoveLinkUserTokenAsync(manager, user, LinkUserTokenProviderConsts.LinkUserTokenPurpose);
+ }
+
+ ///
+ /// Removes the stored link-user token hash for and the given ,
+ /// immediately invalidating any previously issued link-user token for that purpose.
+ ///
+ public static Task RemoveLinkUserTokenAsync(this IdentityUserManager manager, IdentityUser user, string purpose)
+ {
+ return RemoveStoredTokenAsync(
+ manager,
+ user,
+ LinkUserTokenProviderConsts.LinkUserTokenProviderName,
+ purpose);
+ }
+
+ private static Task RemoveStoredTokenAsync(
+ IdentityUserManager manager,
+ IdentityUser user,
+ string providerKey,
+ string purpose)
+ {
+ return manager.RemoveAuthenticationTokenAsync(
+ user,
+ AbpSingleActiveTokenProvider.InternalLoginProvider,
+ GetStoredTokenName(manager, providerKey, purpose));
+ }
+
+ ///
+ /// The hash is stored under the provider's options Name, which is configurable and therefore
+ /// not necessarily the key the provider is registered under.
+ ///
+ private static string GetStoredTokenName(IdentityUserManager manager, string providerKey, string purpose)
+ {
+ var descriptor = manager.Options.Tokens.ProviderMap.GetOrDefault(providerKey);
+ var provider = descriptor?.ProviderInstance ?? (descriptor != null
+ ? manager.ServiceProvider.GetService(descriptor.ProviderType)
+ : null);
+
+ if (provider is not AbpSingleActiveTokenProvider singleActiveTokenProvider)
+ {
+ throw new AbpException(
+ $"The '{providerKey}' token provider is not an {nameof(AbpSingleActiveTokenProvider)}, so it does not " +
+ $"store a token hash that can be removed. This happens when the key has no provider at all, when " +
+ $"the ABP token providers are turned off " +
+ $"through {nameof(AbpIdentityTokenProviderOptions)}.{nameof(AbpIdentityTokenProviderOptions.UseAbpTokenProviders)} " +
+ $"or when the key was re-registered with another provider, including one registered for a " +
+ $"second user type.");
+ }
+
+ return singleActiveTokenProvider.Name + ":" + purpose;
+ }
+}
diff --git a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/LinkUserTokenProvider.cs b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/LinkUserTokenProvider.cs
similarity index 92%
rename from modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/LinkUserTokenProvider.cs
rename to modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/LinkUserTokenProvider.cs
index 47607abe7e..7203fdb243 100644
--- a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/LinkUserTokenProvider.cs
+++ b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/LinkUserTokenProvider.cs
@@ -16,7 +16,7 @@ public class LinkUserTokenProvider : AbpSingleActiveTokenProvider
public LinkUserTokenProvider(
IDataProtectionProvider dataProtectionProvider,
IOptions options,
- ILogger> logger,
+ ILogger logger,
IIdentityUserRepository userRepository,
ICancellationTokenProvider cancellationTokenProvider)
: base(dataProtectionProvider, options, logger, userRepository, cancellationTokenProvider)
diff --git a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpIdentityTokenProviderOptions_Tests.cs b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpIdentityTokenProviderOptions_Tests.cs
new file mode 100644
index 0000000000..9a48bc7e9c
--- /dev/null
+++ b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpIdentityTokenProviderOptions_Tests.cs
@@ -0,0 +1,217 @@
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.DataProtection;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Shouldly;
+using Volo.Abp.Modularity;
+using Xunit;
+
+namespace Volo.Abp.Identity.AspNetCore;
+
+public class AbpIdentityTokenProviderOptions_Tests
+{
+ [Fact]
+ public async Task Should_Register_Abp_Token_Providers_Without_The_AspNetCore_Module()
+ {
+ var tokens = await GetTokenOptionsAsync();
+
+ tokens.PasswordResetTokenProvider.ShouldBe(AbpPasswordResetTokenProvider.ProviderName);
+ tokens.EmailConfirmationTokenProvider.ShouldBe(AbpEmailConfirmationTokenProvider.ProviderName);
+ tokens.ChangeEmailTokenProvider.ShouldBe(AbpChangeEmailTokenProvider.ProviderName);
+
+ tokens.ProviderMap[TokenOptions.DefaultProvider].ProviderType.ShouldBe(typeof(AbpDefaultTokenProvider));
+ tokens.ProviderMap[TokenOptions.DefaultEmailProvider].ProviderType.ShouldBe(typeof(AbpEmailTwoFactorTokenProvider));
+ tokens.ProviderMap[TokenOptions.DefaultPhoneProvider].ProviderType.ShouldBe(typeof(AbpPhoneNumberTwoFactorTokenProvider));
+ tokens.ProviderMap[LinkUserTokenProviderConsts.LinkUserTokenProviderName].ProviderType.ShouldBe(typeof(LinkUserTokenProvider));
+
+ // Not replaced by ABP, but still has to be registered: the providers are listed one by one
+ // instead of calling AddDefaultTokenProviders(), so a missing key would go unnoticed.
+ tokens.ProviderMap[TokenOptions.DefaultAuthenticatorProvider].ProviderType
+ .ShouldBe(typeof(AuthenticatorTokenProvider));
+ }
+
+ [Fact]
+ public async Task Should_Resolve_The_Same_Providers_With_And_Without_The_AspNetCore_Module()
+ {
+ var domainOnly = await GetTokenOptionsAsync();
+ var withAspNetCore = await GetTokenOptionsAsync();
+
+ withAspNetCore.PasswordResetTokenProvider.ShouldBe(domainOnly.PasswordResetTokenProvider);
+ withAspNetCore.EmailConfirmationTokenProvider.ShouldBe(domainOnly.EmailConfirmationTokenProvider);
+ withAspNetCore.ChangeEmailTokenProvider.ShouldBe(domainOnly.ChangeEmailTokenProvider);
+
+ // Both directions: iterating one map alone would pass while the other side has extra keys.
+ withAspNetCore.ProviderMap.Keys.OrderBy(x => x).ShouldBe(domainOnly.ProviderMap.Keys.OrderBy(x => x));
+
+ foreach (var (name, descriptor) in domainOnly.ProviderMap)
+ {
+ withAspNetCore.ProviderMap[name].ProviderType.ShouldBe(descriptor.ProviderType);
+ }
+ }
+
+ [Theory]
+ [InlineData(typeof(NoAbpTokenProvidersModule))]
+ [InlineData(typeof(DomainOnlyNoAbpTokenProvidersModule))]
+ public async Task Should_Register_No_Token_Provider_When_Opted_Out(Type moduleType)
+ {
+ // Registering something here for one host shape and nothing for the other would put the two
+ // ends of a link back on different providers, which is the failure the flag has to avoid.
+ var tokens = await GetTokenOptionsAsync(moduleType);
+
+ tokens.ProviderMap.ShouldBeEmpty();
+
+ tokens.PasswordResetTokenProvider.ShouldBe(TokenOptions.DefaultProvider);
+ tokens.EmailConfirmationTokenProvider.ShouldBe(TokenOptions.DefaultProvider);
+ tokens.ChangeEmailTokenProvider.ShouldBe(TokenOptions.DefaultProvider);
+ }
+
+ [Theory]
+ [InlineData(typeof(AspNetCoreStockTokenProvidersModule))]
+ [InlineData(typeof(DomainOnlyStockTokenProvidersModule))]
+ public async Task Should_Let_The_Application_Register_The_AspNetCore_Providers_When_Opted_Out(Type moduleType)
+ {
+ var tokens = await GetTokenOptionsAsync(moduleType);
+
+ tokens.ProviderMap[TokenOptions.DefaultProvider].ProviderType.ShouldBe(typeof(DataProtectorTokenProvider));
+ tokens.ProviderMap[TokenOptions.DefaultEmailProvider].ProviderType.ShouldBe(typeof(EmailTokenProvider));
+ tokens.ProviderMap[TokenOptions.DefaultPhoneProvider].ProviderType.ShouldBe(typeof(PhoneNumberTokenProvider));
+ tokens.ProviderMap[TokenOptions.DefaultAuthenticatorProvider].ProviderType.ShouldBe(typeof(AuthenticatorTokenProvider));
+ }
+
+ [Fact]
+ public async Task Should_Let_The_Application_Override_A_Provider()
+ {
+ var tokens = await GetTokenOptionsAsync();
+
+ tokens.ProviderMap[AbpPasswordResetTokenProvider.ProviderName].ProviderType
+ .ShouldBe(typeof(DataProtectorTokenProvider));
+ }
+
+ [Fact]
+ public async Task Should_Register_DataProtection_For_The_Token_Providers()
+ {
+ // UserManager instantiates every provider in Tokens.ProviderMap when it is resolved, and the
+ // DataProtection based ones need IDataProtectionProvider. Hosts that never issue a token, such
+ // as a DbMigrator console application, do not register it themselves.
+ using var application = await AbpApplicationFactory.CreateAsync();
+ await application.InitializeAsync();
+
+ application.ServiceProvider.GetService().ShouldNotBeNull();
+ }
+
+ [Fact]
+ public async Task Should_Register_DataProtection_When_Opted_Out()
+ {
+ // Opting out is not a reason to take it away: the providers the application registers instead
+ // are typically the ASP.NET Core ones, and DataProtectorTokenProvider needs it just as much.
+ using var application = await AbpApplicationFactory.CreateAsync();
+ await application.InitializeAsync();
+
+ application.ServiceProvider.GetService().ShouldNotBeNull();
+ }
+
+ [Fact]
+ public async Task A_Leftover_AddDefaultTokenProviders_Should_Override_Only_The_Keys_It_Registers()
+ {
+ // Application actions run after the framework registration, so a host that also calls
+ // AddDefaultTokenProviders() ends up with the stock providers on the keys that call covers
+ // and the ABP ones everywhere else.
+ var tokens = await GetTokenOptionsAsync();
+
+ tokens.ProviderMap[TokenOptions.DefaultProvider].ProviderType.ShouldBe(typeof(DataProtectorTokenProvider));
+ tokens.ProviderMap[TokenOptions.DefaultEmailProvider].ProviderType.ShouldBe(typeof(EmailTokenProvider));
+ tokens.ProviderMap[TokenOptions.DefaultPhoneProvider].ProviderType.ShouldBe(typeof(PhoneNumberTokenProvider));
+
+ tokens.PasswordResetTokenProvider.ShouldBe(AbpPasswordResetTokenProvider.ProviderName);
+ tokens.ProviderMap[AbpPasswordResetTokenProvider.ProviderName].ProviderType.ShouldBe(typeof(AbpPasswordResetTokenProvider));
+ }
+
+ private static Task GetTokenOptionsAsync()
+ where TModule : IAbpModule
+ {
+ return GetTokenOptionsAsync(typeof(TModule));
+ }
+
+ private static async Task GetTokenOptionsAsync(Type moduleType)
+ {
+ using var application = await AbpApplicationFactory.CreateAsync(moduleType);
+ await application.InitializeAsync();
+ return application.ServiceProvider.GetRequiredService>().Value.Tokens;
+ }
+}
+
+[DependsOn(typeof(AbpIdentityDomainModule))]
+public class DomainOnlyModule : AbpModule
+{
+}
+
+[DependsOn(typeof(AbpIdentityAspNetCoreModule))]
+public class AspNetCoreModule : AbpModule
+{
+ public override void PreConfigureServices(ServiceConfigurationContext context)
+ {
+ PreConfigure(options => options.ConfigureAuthentication = false);
+ }
+}
+
+[DependsOn(typeof(AbpIdentityDomainModule))]
+public class LeftoverDefaultTokenProvidersModule : AbpModule
+{
+ public override void PreConfigureServices(ServiceConfigurationContext context)
+ {
+ PreConfigure(builder => builder.AddDefaultTokenProviders());
+ }
+}
+
+[DependsOn(typeof(AbpIdentityDomainModule))]
+public class DomainOnlyNoAbpTokenProvidersModule : AbpModule
+{
+ public override void PreConfigureServices(ServiceConfigurationContext context)
+ {
+ PreConfigure(options => options.UseAbpTokenProviders = false);
+ }
+}
+
+[DependsOn(typeof(AbpIdentityAspNetCoreModule))]
+public class NoAbpTokenProvidersModule : AbpModule
+{
+ public override void PreConfigureServices(ServiceConfigurationContext context)
+ {
+ PreConfigure(options => options.ConfigureAuthentication = false);
+ PreConfigure(options => options.UseAbpTokenProviders = false);
+ }
+}
+
+[DependsOn(typeof(AbpIdentityAspNetCoreModule))]
+public class AspNetCoreStockTokenProvidersModule : AbpModule
+{
+ public override void PreConfigureServices(ServiceConfigurationContext context)
+ {
+ PreConfigure(options => options.ConfigureAuthentication = false);
+ PreConfigure(options => options.UseAbpTokenProviders = false);
+ PreConfigure(builder => builder.AddDefaultTokenProviders());
+ }
+}
+
+[DependsOn(typeof(AbpIdentityDomainModule))]
+public class DomainOnlyStockTokenProvidersModule : AbpModule
+{
+ public override void PreConfigureServices(ServiceConfigurationContext context)
+ {
+ PreConfigure(options => options.UseAbpTokenProviders = false);
+ PreConfigure(builder => builder.AddDefaultTokenProviders());
+ }
+}
+
+[DependsOn(typeof(AbpIdentityDomainModule))]
+public class CustomPasswordResetProviderModule : AbpModule
+{
+ public override void PreConfigureServices(ServiceConfigurationContext context)
+ {
+ PreConfigure(builder =>
+ builder.AddTokenProvider>(AbpPasswordResetTokenProvider.ProviderName));
+ }
+}
diff --git a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpSingleActiveTokenProvider_Compatibility_Tests.cs b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpSingleActiveTokenProvider_Compatibility_Tests.cs
new file mode 100644
index 0000000000..0d750b8e2a
--- /dev/null
+++ b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpSingleActiveTokenProvider_Compatibility_Tests.cs
@@ -0,0 +1,89 @@
+using System;
+using System.Security.Cryptography;
+using System.Text;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.DataProtection;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Shouldly;
+using Volo.Abp.Uow;
+using Xunit;
+
+namespace Volo.Abp.Identity.AspNetCore;
+
+///
+/// Pins the payload of to ASP.NET Core's
+/// , which it re-implements rather than derives from:
+/// a token produced by one has to be accepted by the other under the same provider name.
+///
+public class AbpSingleActiveTokenProvider_Compatibility_Tests : AbpIdentityAspNetCoreTestBase
+{
+ private const string Purpose = "ResetPassword";
+
+ private readonly IIdentityUserRepository _userRepository;
+ private readonly IdentityUserManager _userManager;
+ private readonly IdentityTestData _testData;
+ private readonly IUnitOfWorkManager _unitOfWorkManager;
+
+ public AbpSingleActiveTokenProvider_Compatibility_Tests()
+ {
+ _userRepository = GetRequiredService();
+ _userManager = GetRequiredService();
+ _testData = GetRequiredService();
+ _unitOfWorkManager = GetRequiredService();
+ }
+
+ [Fact]
+ public async Task AspNetCore_Provider_Should_Validate_A_Token_Generated_By_Abp()
+ {
+ using var uow = _unitOfWorkManager.Begin();
+
+ var user = await _userRepository.GetAsync(_testData.UserJohnId);
+ var token = await _userManager.GeneratePasswordResetTokenAsync(user);
+
+ user = await _userRepository.GetAsync(_testData.UserJohnId);
+ (await CreateAspNetCoreProvider().ValidateAsync(Purpose, token, _userManager, user)).ShouldBeTrue();
+
+ await uow.CompleteAsync();
+ }
+
+ [Fact]
+ public async Task Abp_Provider_Should_Validate_A_Token_Generated_By_AspNetCore()
+ {
+ using var uow = _unitOfWorkManager.Begin();
+
+ var user = await _userRepository.GetAsync(_testData.UserJohnId);
+ var token = await CreateAspNetCoreProvider().GenerateAsync(Purpose, _userManager, user);
+
+ // The ABP provider additionally requires the stored hash the generating side would have written.
+ (await _userManager.SetAuthenticationTokenAsync(
+ user,
+ AbpSingleActiveTokenProvider.InternalLoginProvider,
+ AbpPasswordResetTokenProvider.ProviderName + ":" + Purpose,
+ Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))))).Succeeded.ShouldBeTrue();
+
+ user = await _userRepository.GetAsync(_testData.UserJohnId);
+ (await _userManager.VerifyUserTokenAsync(
+ user,
+ AbpPasswordResetTokenProvider.ProviderName,
+ Purpose,
+ token)).ShouldBeTrue();
+
+ await uow.CompleteAsync();
+ }
+
+ private DataProtectorTokenProvider CreateAspNetCoreProvider()
+ {
+ var abpOptions = GetRequiredService>().Value;
+
+ return new DataProtectorTokenProvider(
+ GetRequiredService(),
+ Microsoft.Extensions.Options.Options.Create(new DataProtectionTokenProviderOptions
+ {
+ Name = abpOptions.Name,
+ TokenLifespan = abpOptions.TokenLifespan
+ }),
+ GetRequiredService>>());
+ }
+}
diff --git a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/TokenProviderLifespan_Tests.cs b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/TokenProviderLifespan_Tests.cs
new file mode 100644
index 0000000000..860e158c77
--- /dev/null
+++ b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/TokenProviderLifespan_Tests.cs
@@ -0,0 +1,52 @@
+using System;
+using System.Threading.Tasks;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Shouldly;
+using Xunit;
+
+namespace Volo.Abp.Identity.AspNetCore;
+
+///
+/// The validating host decides whether a token is expired, so a changed default silently shortens or
+/// extends every outstanding link across the solution. Pinned here, resolved through the options
+/// pipeline so that a stray Configure in the module is caught as well.
+///
+public class TokenProviderLifespan_Tests
+{
+ [Fact]
+ public async Task Default_Lifespans_Should_Not_Change()
+ {
+ using var application = await AbpApplicationFactory.CreateAsync();
+ await application.InitializeAsync();
+
+ LifespanOf(application).ShouldBe(TimeSpan.FromHours(2));
+ LifespanOf(application).ShouldBe(TimeSpan.FromHours(2));
+ LifespanOf(application).ShouldBe(TimeSpan.FromHours(2));
+ LifespanOf(application).ShouldBe(TimeSpan.FromMinutes(10));
+ LifespanOf(application).ShouldBe(TimeSpan.FromMinutes(10));
+
+ application.ServiceProvider.GetRequiredService>()
+ .Value.TokenLifespan.ShouldBe(TimeSpan.FromMinutes(3));
+ application.ServiceProvider.GetRequiredService>()
+ .Value.TokenLifespan.ShouldBe(TimeSpan.FromMinutes(3));
+ }
+
+ [Fact]
+ public void The_Shared_Default_Should_Match_The_AspNetCore_One()
+ {
+ // A provider that does not set its own lifespan has to land on the ASP.NET Core defaults.
+ new UnconfiguredTokenProviderOptions().Name.ShouldBe("DataProtectorTokenProvider");
+ new UnconfiguredTokenProviderOptions().TokenLifespan.ShouldBe(TimeSpan.FromDays(1));
+ }
+
+ private static TimeSpan LifespanOf(IAbpApplication application)
+ where TOptions : AbpDataProtectionTokenProviderOptions, new()
+ {
+ return application.ServiceProvider.GetRequiredService>().Value.TokenLifespan;
+ }
+
+ private class UnconfiguredTokenProviderOptions : AbpDataProtectionTokenProviderOptions
+ {
+ }
+}
diff --git a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/TokenProviderOptionsName_Tests.cs b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/TokenProviderOptionsName_Tests.cs
new file mode 100644
index 0000000000..08265bbdfa
--- /dev/null
+++ b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/TokenProviderOptionsName_Tests.cs
@@ -0,0 +1,32 @@
+using Microsoft.AspNetCore.Identity;
+using Shouldly;
+using Xunit;
+
+namespace Volo.Abp.Identity.AspNetCore;
+
+///
+/// The options Name is the Data Protection purpose and the stored-token key prefix, so changing
+/// it silently invalidates every token issued before the change, on every host at once. Pinned here.
+///
+public class TokenProviderOptionsName_Tests
+{
+ [Fact]
+ public void Provider_Names_Should_Not_Change()
+ {
+ new AbpDefaultTokenProviderOptions().Name.ShouldBe(TokenOptions.DefaultProvider);
+ new AbpPasswordResetTokenProviderOptions().Name.ShouldBe(AbpPasswordResetTokenProvider.ProviderName);
+ new AbpEmailConfirmationTokenProviderOptions().Name.ShouldBe(AbpEmailConfirmationTokenProvider.ProviderName);
+ new AbpChangeEmailTokenProviderOptions().Name.ShouldBe(AbpChangeEmailTokenProvider.ProviderName);
+ new AbpLinkUserTokenProviderOptions().Name.ShouldBe(LinkUserTokenProviderConsts.LinkUserTokenProviderName);
+ }
+
+ [Fact]
+ public void Provider_Name_Constants_Should_Not_Change()
+ {
+ AbpPasswordResetTokenProvider.ProviderName.ShouldBe("AbpPasswordReset");
+ AbpEmailConfirmationTokenProvider.ProviderName.ShouldBe("AbpEmailConfirmation");
+ AbpChangeEmailTokenProvider.ProviderName.ShouldBe("AbpChangeEmail");
+ AbpSingleActiveTokenProvider.InternalLoginProvider.ShouldBe("[AbpSingleActiveToken]");
+ LinkUserTokenProviderConsts.LinkUserTokenProviderName.ShouldBe("AbpLinkUser");
+ }
+}
diff --git a/modules/identity/test/Volo.Abp.Identity.EntityFrameworkCore.Tests/Volo.Abp.Identity.EntityFrameworkCore.Tests.csproj b/modules/identity/test/Volo.Abp.Identity.EntityFrameworkCore.Tests/Volo.Abp.Identity.EntityFrameworkCore.Tests.csproj
index c5c9676306..d976deb4dc 100644
--- a/modules/identity/test/Volo.Abp.Identity.EntityFrameworkCore.Tests/Volo.Abp.Identity.EntityFrameworkCore.Tests.csproj
+++ b/modules/identity/test/Volo.Abp.Identity.EntityFrameworkCore.Tests/Volo.Abp.Identity.EntityFrameworkCore.Tests.csproj
@@ -13,6 +13,7 @@
+
diff --git a/modules/identity/test/Volo.Abp.Identity.EntityFrameworkCore.Tests/Volo/Abp/Identity/EntityFrameworkCore/CrossHostTokenProvider_Tests.cs b/modules/identity/test/Volo.Abp.Identity.EntityFrameworkCore.Tests/Volo/Abp/Identity/EntityFrameworkCore/CrossHostTokenProvider_Tests.cs
new file mode 100644
index 0000000000..c202cac733
--- /dev/null
+++ b/modules/identity/test/Volo.Abp.Identity.EntityFrameworkCore.Tests/Volo/Abp/Identity/EntityFrameworkCore/CrossHostTokenProvider_Tests.cs
@@ -0,0 +1,388 @@
+using System;
+using System.Security.Cryptography;
+using System.Text;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Shouldly;
+using Volo.Abp.Autofac;
+using Volo.Abp.Data;
+using Volo.Abp.Domain.Repositories;
+using Volo.Abp.EntityFrameworkCore;
+using Volo.Abp.EntityFrameworkCore.Sqlite;
+using Volo.Abp.Identity.AspNetCore;
+using Volo.Abp.Modularity;
+using Volo.Abp.Uow;
+using Xunit;
+
+namespace Volo.Abp.Identity.EntityFrameworkCore;
+
+///
+/// The Entity Framework Core run of the shared cross-host suite, plus the cases that do not depend on
+/// the persistence provider and therefore only need to run once.
+///
+public class CrossHostTokenProvider_Tests
+ : CrossHostTokenProvider_Tests
+{
+ protected override Type ValidatorOnlyModuleType => typeof(AbpIdentityAspNetCoreModule);
+
+ protected override IDisposable CreateSharedDatabase()
+ {
+ var database = new AbpUnitTestSqliteDatabase();
+ database.CreateTables(
+ new IdentityDbContext(new DbContextOptionsBuilder().UseSqlite(database.ConnectionString).Options));
+
+ CrossHostTokenProviderTestModuleBase.ConnectionString = database.ConnectionString;
+ return database;
+ }
+
+ [Fact]
+ public async Task A_Token_Generated_By_The_AspNetCore_Providers_Should_Not_Validate_Against_The_Abp_Ones()
+ {
+ // A different options Name means a different Data Protection purpose, so the validating side
+ // cannot even unprotect the payload.
+ var userId = await CreateUserAsync();
+
+ using var optedOutHost = await CreateHostAsync();
+ try
+ {
+ var token = await WithUowAsync(optedOutHost, async sp =>
+ {
+ var userManager = sp.GetRequiredService();
+ var identityOptions = sp.GetRequiredService>().Value;
+ identityOptions.Tokens.PasswordResetTokenProvider.ShouldBe(TokenOptions.DefaultProvider);
+ return await userManager.GeneratePasswordResetTokenAsync(await userManager.GetByIdAsync(userId));
+ });
+
+ var verified = await WithUowAsync(ValidatorHost, async sp =>
+ {
+ var userManager = sp.GetRequiredService();
+ var identityOptions = sp.GetRequiredService>().Value;
+ var user = await userManager.GetByIdAsync(userId);
+
+ // Store the hash the ABP provider looks for, so a missing hash cannot be the reason
+ // this fails. What is left is the protector purpose, which differs with the options Name.
+ var storedName = identityOptions.Tokens.PasswordResetTokenProvider + ":" + ResetPasswordPurpose;
+ (await userManager.SetAuthenticationTokenAsync(
+ user,
+ AbpSingleActiveTokenProvider.InternalLoginProvider,
+ storedName,
+ Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))))).Succeeded.ShouldBeTrue();
+
+ user = await userManager.GetByIdAsync(userId);
+ (await userManager.GetAuthenticationTokenAsync(
+ user,
+ AbpSingleActiveTokenProvider.InternalLoginProvider,
+ storedName)).ShouldNotBeNull();
+
+ return await userManager.VerifyUserTokenAsync(
+ user,
+ identityOptions.Tokens.PasswordResetTokenProvider,
+ ResetPasswordPurpose,
+ token);
+ });
+
+ verified.ShouldBeFalse();
+ }
+ finally
+ {
+ await optedOutHost.ShutdownAsync();
+ }
+ }
+
+ [Fact]
+ public async Task A_Token_From_A_Host_That_Registers_The_AspNetCore_Providers_Itself_Should_Not_Validate()
+ {
+ // A host that never loads the ASP.NET Core integration and wires up the stock providers itself.
+ var userId = await CreateUserAsync();
+
+ using var stockHost = await CreateHostAsync();
+ try
+ {
+ var token = await WithUowAsync(stockHost, async sp =>
+ {
+ var userManager = sp.GetRequiredService();
+ var identityOptions = sp.GetRequiredService>().Value;
+ identityOptions.Tokens.PasswordResetTokenProvider.ShouldBe(TokenOptions.DefaultProvider);
+ identityOptions.Tokens.ProviderMap[TokenOptions.DefaultProvider].ProviderType
+ .ShouldBe(typeof(DataProtectorTokenProvider));
+ return await userManager.GeneratePasswordResetTokenAsync(await userManager.GetByIdAsync(userId));
+ });
+
+ var verified = await WithUowAsync(ValidatorHost, async sp =>
+ {
+ var userManager = sp.GetRequiredService();
+ var identityOptions = sp.GetRequiredService>().Value;
+ var user = await userManager.GetByIdAsync(userId);
+
+ // Store the hash the ABP provider looks for, so a missing hash cannot be the reason
+ // this fails. What is left is the protector purpose, which differs with the options Name.
+ var storedName = identityOptions.Tokens.PasswordResetTokenProvider + ":" + ResetPasswordPurpose;
+ (await userManager.SetAuthenticationTokenAsync(
+ user,
+ AbpSingleActiveTokenProvider.InternalLoginProvider,
+ storedName,
+ Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))))).Succeeded.ShouldBeTrue();
+
+ user = await userManager.GetByIdAsync(userId);
+ (await userManager.GetAuthenticationTokenAsync(
+ user,
+ AbpSingleActiveTokenProvider.InternalLoginProvider,
+ storedName)).ShouldNotBeNull();
+
+ return await userManager.VerifyUserTokenAsync(
+ user,
+ identityOptions.Tokens.PasswordResetTokenProvider,
+ ResetPasswordPurpose,
+ token);
+ });
+
+ verified.ShouldBeFalse();
+ }
+ finally
+ {
+ await stockHost.ShutdownAsync();
+ }
+ }
+
+ [Fact]
+ public async Task Removing_A_Stored_Token_Should_Follow_A_Customized_Provider_Name()
+ {
+ var userId = await CreateUserAsync();
+
+ using var renamedHost = await CreateHostAsync();
+ try
+ {
+ var stillValid = await WithUowAsync(renamedHost, async sp =>
+ {
+ var userManager = sp.GetRequiredService();
+ var identityOptions = sp.GetRequiredService>().Value;
+
+ var token = await userManager.GeneratePasswordResetTokenAsync(await userManager.GetByIdAsync(userId));
+
+ // The hash follows the options Name, while the provider stays on its registration key.
+ // Without this the test would also pass if the renaming never took effect.
+ var user = await userManager.GetByIdAsync(userId);
+ await sp.GetRequiredService().EnsureCollectionLoadedAsync(user, u => u.Tokens);
+ identityOptions.Tokens.PasswordResetTokenProvider.ShouldBe(AbpPasswordResetTokenProvider.ProviderName);
+ user.FindToken(
+ AbpSingleActiveTokenProvider.InternalLoginProvider,
+ CustomProviderNameHostModule.CustomName + ":" + ResetPasswordPurpose).ShouldNotBeNull();
+
+ (await userManager.RemovePasswordResetTokenAsync(await userManager.GetByIdAsync(userId)))
+ .Succeeded.ShouldBeTrue();
+
+ return await userManager.VerifyUserTokenAsync(
+ await userManager.GetByIdAsync(userId),
+ identityOptions.Tokens.PasswordResetTokenProvider,
+ ResetPasswordPurpose,
+ token);
+ });
+
+ stillValid.ShouldBeFalse();
+ }
+ finally
+ {
+ await renamedHost.ShutdownAsync();
+ }
+ }
+
+ [Fact]
+ public async Task Removing_A_Stored_Token_Should_Fail_Loudly_When_The_Provider_Is_Not_Abps()
+ {
+ // Reporting success here would leave the caller believing a token was revoked while the stock
+ // provider, which keeps no server side state, happily keeps validating it.
+ var userId = await CreateUserAsync();
+
+ using var stockHost = await CreateHostAsync();
+ try
+ {
+ await WithUowAsync(stockHost, async sp =>
+ {
+ var userManager = sp.GetRequiredService();
+ var user = await userManager.GetByIdAsync(userId);
+
+ await Should.ThrowAsync(async () => await userManager.RemovePasswordResetTokenAsync(user));
+ return true;
+ });
+ }
+ finally
+ {
+ await stockHost.ShutdownAsync();
+ }
+ }
+
+ [Fact]
+ public async Task The_Validating_Host_Should_Decide_Whether_A_Token_Expired()
+ {
+ var userId = await CreateUserAsync();
+
+ var token = await WithUowAsync(GeneratorHost, async sp =>
+ {
+ var userManager = sp.GetRequiredService();
+ return await userManager.GeneratePasswordResetTokenAsync(await userManager.GetByIdAsync(userId));
+ });
+
+ // The generating host used the default two hours; this one expires everything immediately.
+ using var shortLivedValidator = await CreateHostAsync();
+ try
+ {
+ var verified = await WithUowAsync(shortLivedValidator, async sp =>
+ {
+ sp.GetRequiredService>().Value.TokenLifespan
+ .ShouldBe(TimeSpan.Zero);
+ sp.GetRequiredService>().Value.TokenLifespan
+ .ShouldBe(TimeSpan.FromDays(30));
+
+ var userManager = sp.GetRequiredService();
+ var identityOptions = sp.GetRequiredService>().Value;
+ return await userManager.VerifyUserTokenAsync(
+ await userManager.GetByIdAsync(userId),
+ identityOptions.Tokens.PasswordResetTokenProvider,
+ ResetPasswordPurpose,
+ token);
+ });
+
+ verified.ShouldBeFalse();
+ }
+ finally
+ {
+ await shortLivedValidator.ShutdownAsync();
+ }
+ }
+
+ [Fact]
+ public async Task The_Generating_Host_Lifespan_Should_Not_Decide_Whether_A_Token_Expired()
+ {
+ var userId = await CreateUserAsync();
+
+ using var shortLivedGenerator = await CreateHostAsync();
+ string token;
+ try
+ {
+ token = await WithUowAsync(shortLivedGenerator, async sp =>
+ {
+ // Without this the test would also pass if the zero lifespan never took effect.
+ sp.GetRequiredService>().Value.TokenLifespan
+ .ShouldBe(TimeSpan.Zero);
+
+ var userManager = sp.GetRequiredService();
+ return await userManager.GeneratePasswordResetTokenAsync(await userManager.GetByIdAsync(userId));
+ });
+ }
+ finally
+ {
+ await shortLivedGenerator.ShutdownAsync();
+ }
+
+ var verified = await WithUowAsync(ValidatorHost, async sp =>
+ {
+ var userManager = sp.GetRequiredService();
+ var identityOptions = sp.GetRequiredService>().Value;
+ return await userManager.VerifyUserTokenAsync(
+ await userManager.GetByIdAsync(userId),
+ identityOptions.Tokens.PasswordResetTokenProvider,
+ ResetPasswordPurpose,
+ token);
+ });
+
+ verified.ShouldBeTrue();
+ }
+}
+
+public abstract class EfCoreCrossHostTestModuleBase : CrossHostTokenProviderTestModuleBase
+{
+ public override void ConfigureServices(ServiceConfigurationContext context)
+ {
+ base.ConfigureServices(context);
+
+ Configure(options => options.Configure(c => c.UseSqlite()));
+ context.Services.AddAlwaysDisableUnitOfWorkTransaction();
+ }
+}
+
+[DependsOn(typeof(AbpAutofacModule), typeof(AbpIdentityEntityFrameworkCoreModule), typeof(AbpEntityFrameworkCoreSqliteModule))]
+public class CrossHostGeneratorHostModule : EfCoreCrossHostTestModuleBase
+{
+}
+
+[DependsOn(typeof(AbpAutofacModule), typeof(AbpIdentityEntityFrameworkCoreModule), typeof(AbpIdentityAspNetCoreModule), typeof(AbpEntityFrameworkCoreSqliteModule))]
+public class CrossHostValidatorHostModule : EfCoreCrossHostTestModuleBase
+{
+ public override void PreConfigureServices(ServiceConfigurationContext context)
+ {
+ PreConfigure(options => options.ConfigureAuthentication = false);
+ }
+}
+
+/// A host that opted out of the ABP providers and registered the ASP.NET Core ones instead.
+[DependsOn(typeof(AbpAutofacModule), typeof(AbpIdentityEntityFrameworkCoreModule), typeof(AbpIdentityAspNetCoreModule), typeof(AbpEntityFrameworkCoreSqliteModule))]
+public class OptedOutGeneratorHostModule : EfCoreCrossHostTestModuleBase
+{
+ public override void PreConfigureServices(ServiceConfigurationContext context)
+ {
+ PreConfigure(options => options.ConfigureAuthentication = false);
+ PreConfigure(options => options.UseAbpTokenProviders = false);
+ PreConfigure(builder => builder.AddDefaultTokenProviders());
+ }
+}
+
+/// Validator whose password-reset lifespan is zero. It also sets the ASP.NET Core
+/// DataProtectionTokenProviderOptions to 30 days, which must not rescue the token: the ABP
+/// providers read their own options type.
+[DependsOn(typeof(AbpAutofacModule), typeof(AbpIdentityEntityFrameworkCoreModule), typeof(AbpIdentityAspNetCoreModule), typeof(AbpEntityFrameworkCoreSqliteModule))]
+public class ExpiredOnArrivalValidatorHostModule : EfCoreCrossHostTestModuleBase
+{
+ public override void PreConfigureServices(ServiceConfigurationContext context)
+ {
+ PreConfigure(options => options.ConfigureAuthentication = false);
+ }
+
+ public override void ConfigureServices(ServiceConfigurationContext context)
+ {
+ base.ConfigureServices(context);
+
+ Configure(options => options.TokenLifespan = TimeSpan.Zero);
+ Configure(options => options.TokenLifespan = TimeSpan.FromDays(30));
+ }
+}
+
+/// Generator whose password-reset lifespan is zero. The validating host still decides.
+[DependsOn(typeof(AbpAutofacModule), typeof(AbpIdentityEntityFrameworkCoreModule), typeof(AbpEntityFrameworkCoreSqliteModule))]
+public class ExpiredOnArrivalGeneratorHostModule : EfCoreCrossHostTestModuleBase
+{
+ public override void ConfigureServices(ServiceConfigurationContext context)
+ {
+ base.ConfigureServices(context);
+
+ Configure(options => options.TokenLifespan = TimeSpan.Zero);
+ }
+}
+
+/// A host that never loads the ASP.NET Core integration and registers the stock providers itself.
+[DependsOn(typeof(AbpAutofacModule), typeof(AbpIdentityEntityFrameworkCoreModule), typeof(AbpEntityFrameworkCoreSqliteModule))]
+public class StockProviderGeneratorHostModule : EfCoreCrossHostTestModuleBase
+{
+ public override void PreConfigureServices(ServiceConfigurationContext context)
+ {
+ PreConfigure(options => options.UseAbpTokenProviders = false);
+ PreConfigure(builder => builder.AddDefaultTokenProviders());
+ }
+}
+
+/// A host that renamed the password-reset provider. The stored hash then lives under that name and
+/// no longer matches the key the provider is registered under.
+[DependsOn(typeof(AbpAutofacModule), typeof(AbpIdentityEntityFrameworkCoreModule), typeof(AbpEntityFrameworkCoreSqliteModule))]
+public class CustomProviderNameHostModule : EfCoreCrossHostTestModuleBase
+{
+ public const string CustomName = "CustomPasswordReset";
+
+ public override void ConfigureServices(ServiceConfigurationContext context)
+ {
+ base.ConfigureServices(context);
+
+ Configure(options => options.Name = CustomName);
+ }
+}
diff --git a/modules/identity/test/Volo.Abp.Identity.MongoDB.Tests/Volo.Abp.Identity.MongoDB.Tests.csproj b/modules/identity/test/Volo.Abp.Identity.MongoDB.Tests/Volo.Abp.Identity.MongoDB.Tests.csproj
index 772d80a492..ea7ee963f9 100644
--- a/modules/identity/test/Volo.Abp.Identity.MongoDB.Tests/Volo.Abp.Identity.MongoDB.Tests.csproj
+++ b/modules/identity/test/Volo.Abp.Identity.MongoDB.Tests/Volo.Abp.Identity.MongoDB.Tests.csproj
@@ -13,6 +13,7 @@
+
diff --git a/modules/identity/test/Volo.Abp.Identity.MongoDB.Tests/Volo/Abp/Identity/MongoDB/CrossHostTokenProvider_Tests.cs b/modules/identity/test/Volo.Abp.Identity.MongoDB.Tests/Volo/Abp/Identity/MongoDB/CrossHostTokenProvider_Tests.cs
new file mode 100644
index 0000000000..44ee540010
--- /dev/null
+++ b/modules/identity/test/Volo.Abp.Identity.MongoDB.Tests/Volo/Abp/Identity/MongoDB/CrossHostTokenProvider_Tests.cs
@@ -0,0 +1,52 @@
+using System;
+using Volo.Abp.Autofac;
+using Volo.Abp.Identity.AspNetCore;
+using Volo.Abp.Modularity;
+using Volo.Abp.Uow;
+using Xunit;
+
+namespace Volo.Abp.Identity.MongoDB;
+
+[Collection(MongoTestCollection.Name)]
+public class CrossHostTokenProvider_Tests
+ : CrossHostTokenProvider_Tests
+{
+ protected override Type ValidatorOnlyModuleType => typeof(AbpIdentityAspNetCoreModule);
+
+ protected override IDisposable CreateSharedDatabase()
+ {
+ CrossHostTokenProviderTestModuleBase.ConnectionString = MongoDbFixture.GetRandomConnectionString();
+ return new NullDisposable();
+ }
+
+ private sealed class NullDisposable : IDisposable
+ {
+ public void Dispose()
+ {
+ }
+ }
+}
+
+public abstract class MongoCrossHostTestModuleBase : CrossHostTokenProviderTestModuleBase
+{
+ public override void ConfigureServices(ServiceConfigurationContext context)
+ {
+ base.ConfigureServices(context);
+
+ Configure(options => options.TransactionBehavior = UnitOfWorkTransactionBehavior.Disabled);
+ }
+}
+
+[DependsOn(typeof(AbpAutofacModule), typeof(AbpIdentityMongoDbModule))]
+public class MongoCrossHostGeneratorHostModule : MongoCrossHostTestModuleBase
+{
+}
+
+[DependsOn(typeof(AbpAutofacModule), typeof(AbpIdentityMongoDbModule), typeof(AbpIdentityAspNetCoreModule))]
+public class MongoCrossHostValidatorHostModule : MongoCrossHostTestModuleBase
+{
+ public override void PreConfigureServices(ServiceConfigurationContext context)
+ {
+ PreConfigure(options => options.ConfigureAuthentication = false);
+ }
+}
diff --git a/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/CrossHostTokenProvider_Tests.cs b/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/CrossHostTokenProvider_Tests.cs
new file mode 100644
index 0000000000..c7add3c454
--- /dev/null
+++ b/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/CrossHostTokenProvider_Tests.cs
@@ -0,0 +1,417 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using System.Xml.Linq;
+using Microsoft.AspNetCore.DataProtection;
+using Microsoft.AspNetCore.DataProtection.KeyManagement;
+using Microsoft.AspNetCore.DataProtection.Repositories;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Shouldly;
+using Volo.Abp.Autofac;
+using Volo.Abp.Data;
+using Volo.Abp.Domain.Repositories;
+using Volo.Abp.Identity.AspNetCore;
+using Volo.Abp.Modularity;
+using Volo.Abp.MultiTenancy;
+using Volo.Abp.Uow;
+using Xunit;
+
+namespace Volo.Abp.Identity;
+
+///
+/// Two independent ABP applications over one shared database and one shared key ring, only one of them
+/// loading the ASP.NET Core integration: a token generated on either has to validate on the other. The
+/// single-active hash goes through , so every persistence provider
+/// derives from this class and runs the same flows against its own database.
+///
+public abstract class CrossHostTokenProvider_Tests : IAsyncLifetime
+ where TGeneratorModule : IAbpModule
+ where TValidatorModule : IAbpModule
+{
+ protected const string UserName = "cross-host";
+ protected const string TenantUserName = "cross-host-tenant";
+ protected const string ResetPasswordPurpose = "ResetPassword";
+
+ protected static readonly Guid TenantId = Guid.Parse("6f6e7b6a-2c1e-4a2f-9d5a-8f2f0f2b7c11");
+
+ protected IAbpApplicationWithInternalServiceProvider GeneratorHost { get; private set; }
+
+ protected IAbpApplicationWithInternalServiceProvider ValidatorHost { get; private set; }
+
+ private IDisposable _database;
+
+ ///
+ /// Creates the database both hosts share and points
+ /// at it, before the hosts are built.
+ ///
+ protected abstract IDisposable CreateSharedDatabase();
+
+ ///
+ /// The module only the validating host loads. Asserted on both sides so the suite cannot quietly
+ /// degrade into two identical hosts, which is not the topology under test.
+ ///
+ protected abstract Type ValidatorOnlyModuleType { get; }
+
+ public virtual async Task InitializeAsync()
+ {
+ _database = CreateSharedDatabase();
+ CrossHostTokenProviderTestModuleBase.KeyRepository = new CrossHostInMemoryXmlRepository();
+
+ GeneratorHost = await CreateHostAsync();
+ ValidatorHost = await CreateHostAsync();
+ }
+
+ public virtual async Task DisposeAsync()
+ {
+ if (ValidatorHost != null)
+ {
+ await ValidatorHost.ShutdownAsync();
+ ValidatorHost.Dispose();
+ }
+
+ if (GeneratorHost != null)
+ {
+ await GeneratorHost.ShutdownAsync();
+ GeneratorHost.Dispose();
+ }
+
+ _database?.Dispose();
+ }
+
+ [Fact]
+ public void The_Two_Hosts_Should_Be_Independent_Applications()
+ {
+ GeneratorHost.ServiceProvider.ShouldNotBeSameAs(ValidatorHost.ServiceProvider);
+
+ GeneratorHost.Services.GetSingletonInstance().Modules
+ .ShouldNotContain(m => m.Type == ValidatorOnlyModuleType);
+ ValidatorHost.Services.GetSingletonInstance().Modules
+ .ShouldContain(m => m.Type == ValidatorOnlyModuleType);
+ }
+
+ [Fact]
+ public void Both_Hosts_Should_Resolve_The_Same_Token_Providers()
+ {
+ var generatorTokens = GetTokenOptions(GeneratorHost);
+ var validatorTokens = GetTokenOptions(ValidatorHost);
+
+ generatorTokens.PasswordResetTokenProvider.ShouldBe(validatorTokens.PasswordResetTokenProvider);
+ generatorTokens.EmailConfirmationTokenProvider.ShouldBe(validatorTokens.EmailConfirmationTokenProvider);
+ generatorTokens.ChangeEmailTokenProvider.ShouldBe(validatorTokens.ChangeEmailTokenProvider);
+
+ // Both directions: iterating one map alone would pass while the other side has extra keys.
+ generatorTokens.ProviderMap.Keys.OrderBy(x => x).ShouldBe(validatorTokens.ProviderMap.Keys.OrderBy(x => x));
+
+ foreach (var (name, descriptor) in validatorTokens.ProviderMap)
+ {
+ generatorTokens.ProviderMap[name].ProviderType.ShouldBe(descriptor.ProviderType);
+ }
+ }
+
+ [Fact]
+ public async Task A_Token_Generated_On_The_Other_Host_Should_Verify()
+ {
+ var userId = await CreateUserAsync();
+
+ var token = await WithUowAsync(GeneratorHost, async sp =>
+ {
+ var userManager = sp.GetRequiredService();
+ return await userManager.GeneratePasswordResetTokenAsync(await userManager.GetByIdAsync(userId));
+ });
+
+ var verified = await WithUowAsync(ValidatorHost, async sp =>
+ {
+ var userManager = sp.GetRequiredService();
+ var identityOptions = sp.GetRequiredService>().Value;
+ return await userManager.VerifyUserTokenAsync(
+ await userManager.GetByIdAsync(userId),
+ identityOptions.Tokens.PasswordResetTokenProvider,
+ ResetPasswordPurpose,
+ token);
+ });
+
+ verified.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task A_Token_Generated_On_This_Host_Should_Verify_On_The_Other_One()
+ {
+ var userId = await CreateUserAsync();
+
+ var token = await WithUowAsync(ValidatorHost, async sp =>
+ {
+ var userManager = sp.GetRequiredService();
+ return await userManager.GeneratePasswordResetTokenAsync(await userManager.GetByIdAsync(userId));
+ });
+
+ var verified = await WithUowAsync(GeneratorHost, async sp =>
+ {
+ var userManager = sp.GetRequiredService();
+ var identityOptions = sp.GetRequiredService>().Value;
+ return await userManager.VerifyUserTokenAsync(
+ await userManager.GetByIdAsync(userId),
+ identityOptions.Tokens.PasswordResetTokenProvider,
+ ResetPasswordPurpose,
+ token);
+ });
+
+ verified.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task A_Password_Reset_Should_Complete_Across_The_Two_Hosts()
+ {
+ var userId = await CreateUserAsync();
+
+ var token = await WithUowAsync(GeneratorHost, async sp =>
+ {
+ var userManager = sp.GetRequiredService();
+ return await userManager.GeneratePasswordResetTokenAsync(await userManager.GetByIdAsync(userId));
+ });
+
+ var result = await WithUowAsync(ValidatorHost, async sp =>
+ {
+ var userManager = sp.GetRequiredService();
+ return await userManager.ResetPasswordAsync(await userManager.GetByIdAsync(userId), token, "1q2w3E*NEW");
+ });
+
+ result.Succeeded.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task An_Email_Confirmation_Token_Should_Complete_Across_The_Two_Hosts()
+ {
+ var userId = await CreateUserAsync();
+
+ var token = await WithUowAsync(GeneratorHost, async sp =>
+ {
+ var userManager = sp.GetRequiredService();
+ return await userManager.GenerateEmailConfirmationTokenAsync(await userManager.GetByIdAsync(userId));
+ });
+
+ var result = await WithUowAsync(ValidatorHost, async sp =>
+ {
+ var userManager = sp.GetRequiredService();
+ return await userManager.ConfirmEmailAsync(await userManager.GetByIdAsync(userId), token);
+ });
+
+ result.Succeeded.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task A_Change_Email_Token_Should_Complete_Across_The_Two_Hosts()
+ {
+ const string newEmail = "changed@abp.io";
+ var userId = await CreateUserAsync();
+
+ var token = await WithUowAsync(GeneratorHost, async sp =>
+ {
+ var userManager = sp.GetRequiredService();
+ return await userManager.GenerateChangeEmailTokenAsync(await userManager.GetByIdAsync(userId), newEmail);
+ });
+
+ var result = await WithUowAsync(ValidatorHost, async sp =>
+ {
+ var userManager = sp.GetRequiredService();
+ return await userManager.ChangeEmailAsync(await userManager.GetByIdAsync(userId), newEmail, token);
+ });
+
+ result.Succeeded.ShouldBeTrue();
+ }
+
+ public static TheoryData OtherSingleActiveProviders => new()
+ {
+ { TokenOptions.DefaultProvider, "TestPurpose" },
+ { LinkUserTokenProviderConsts.LinkUserTokenProviderName, LinkUserTokenProviderConsts.LinkUserTokenPurpose },
+ };
+
+ public static TheoryData TwoFactorProviders => new()
+ {
+ TokenOptions.DefaultEmailProvider,
+ TokenOptions.DefaultPhoneProvider,
+ };
+
+ [Theory]
+ [MemberData(nameof(OtherSingleActiveProviders))]
+ public async Task A_Token_Of_The_Other_Single_Active_Providers_Should_Verify_Across_The_Two_Hosts(
+ string providerKey,
+ string purpose)
+ {
+ // The password reset flows above go through IdentityOptions.Tokens, which leaves the keys that
+ // are addressed directly. They are registered by the same call and break the same way.
+ var userId = await CreateUserAsync();
+
+ var token = await WithUowAsync(GeneratorHost, async sp =>
+ {
+ var userManager = sp.GetRequiredService();
+ return await userManager.GenerateUserTokenAsync(await userManager.GetByIdAsync(userId), providerKey, purpose);
+ });
+
+ var verified = await WithUowAsync(ValidatorHost, async sp =>
+ {
+ var userManager = sp.GetRequiredService();
+ return await userManager.VerifyUserTokenAsync(await userManager.GetByIdAsync(userId), providerKey, purpose, token);
+ });
+
+ verified.ShouldBeTrue();
+ }
+
+ [Theory]
+ [MemberData(nameof(TwoFactorProviders))]
+ public async Task A_Two_Factor_Code_Should_Verify_Across_The_Two_Hosts(string providerKey)
+ {
+ // These keep the code in the user token table too, protected under a purpose built from the
+ // provider name, so they need both hosts to agree just as much as the DataProtector ones.
+ var userId = await CreateUserAsync();
+
+ var code = await WithUowAsync(GeneratorHost, async sp =>
+ {
+ var userManager = sp.GetRequiredService();
+ return await userManager.GenerateTwoFactorTokenAsync(await userManager.GetByIdAsync(userId), providerKey);
+ });
+
+ var verified = await WithUowAsync(ValidatorHost, async sp =>
+ {
+ var userManager = sp.GetRequiredService();
+ return await userManager.VerifyTwoFactorTokenAsync(await userManager.GetByIdAsync(userId), providerKey, code);
+ });
+
+ verified.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task A_Token_Generated_For_A_Tenant_User_Should_Validate_On_The_Other_Host()
+ {
+ var userId = await CreateUserAsync(TenantId);
+
+ var token = await WithUowAsync(GeneratorHost, async sp =>
+ {
+ var userManager = sp.GetRequiredService();
+ return await userManager.GeneratePasswordResetTokenAsync(await userManager.GetByIdAsync(userId));
+ }, TenantId);
+
+ var result = await WithUowAsync(ValidatorHost, async sp =>
+ {
+ var userManager = sp.GetRequiredService();
+ return await userManager.ResetPasswordAsync(await userManager.GetByIdAsync(userId), token, "1q2w3E*NEW");
+ }, TenantId);
+
+ result.Succeeded.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task The_Stored_Hash_Of_A_Tenant_Users_Token_Should_Carry_The_Tenant_Id()
+ {
+ // The hash is stored with the user, which is tenant scoped. A host side entry would be
+ // invisible to the tenant and the token would be rejected on the validating side.
+ var userId = await CreateUserAsync(TenantId);
+
+ await WithUowAsync(GeneratorHost, async sp =>
+ {
+ var userManager = sp.GetRequiredService();
+ return await userManager.GeneratePasswordResetTokenAsync(await userManager.GetByIdAsync(userId));
+ }, TenantId);
+
+ var storedTenantId = await WithUowAsync(ValidatorHost, async sp =>
+ {
+ var userManager = sp.GetRequiredService();
+ var user = await userManager.GetByIdAsync(userId);
+ await sp.GetRequiredService().EnsureCollectionLoadedAsync(user, u => u.Tokens);
+
+ return user.FindToken(
+ AbpSingleActiveTokenProvider.InternalLoginProvider,
+ AbpPasswordResetTokenProvider.ProviderName + ":" + ResetPasswordPurpose).TenantId;
+ }, TenantId);
+
+ storedTenantId.ShouldBe(TenantId);
+ }
+
+ protected async Task CreateUserAsync(Guid? tenantId = null)
+ {
+ var userName = tenantId == null ? UserName : TenantUserName;
+
+ return await WithUowAsync(GeneratorHost, async sp =>
+ {
+ var userManager = sp.GetRequiredService();
+ var existing = await userManager.FindByNameAsync(userName);
+ if (existing != null)
+ {
+ return existing.Id;
+ }
+
+ var user = new IdentityUser(Guid.NewGuid(), userName, userName + "@abp.io", tenantId);
+ (await userManager.CreateAsync(user, "1q2w3E*")).CheckErrors();
+ return user.Id;
+ }, tenantId);
+ }
+
+ protected static TokenOptions GetTokenOptions(IAbpApplicationWithInternalServiceProvider host)
+ {
+ return host.ServiceProvider.GetRequiredService>().Value.Tokens;
+ }
+
+ protected static async Task CreateHostAsync()
+ where TModule : IAbpModule
+ {
+ var host = await AbpApplicationFactory.CreateAsync(options => options.UseAutofac());
+ await host.InitializeAsync();
+ return host;
+ }
+
+ protected static async Task WithUowAsync(
+ IAbpApplicationWithInternalServiceProvider host,
+ Func