Browse Source

Merge pull request #25525 from abpframework/maliming/default-token-provider-single-active

Replace default token provider with single-active variant
pull/25530/head
Engincan VESKE 4 weeks ago
committed by GitHub
parent
commit
6bc06af646
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      docs/en/modules/identity-pro.md
  2. 136
      docs/en/modules/identity/token-providers.md
  3. 6
      docs/en/modules/identity/two-factor-authentication.md
  4. 29
      modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpDefaultTokenProvider.cs
  5. 13
      modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpDefaultTokenProviderOptions.cs
  6. 1
      modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpIdentityAspNetCoreModule.cs
  7. 123
      modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpDefaultTokenProvider_Tests.cs

1
docs/en/modules/identity-pro.md

@ -440,4 +440,5 @@ This module doesn't define any additional distributed event. See the [standard d
* [Two Factor Authentication](./identity/two-factor-authentication.md)
* [Session Management](./identity/session-management.md)
* [Password History](./identity/password-history.md)
* [Identity Token Providers](./identity/token-providers.md)

136
docs/en/modules/identity/token-providers.md

@ -0,0 +1,136 @@
```json
//[doc-seo]
{
"Description": "Learn how ABP Identity replaces the ASP.NET Core Identity built-in token providers with single-active variants, what each provider is used for, and how to configure or replace them."
}
```
# Identity Token Providers
ASP.NET Core Identity uses `IUserTwoFactorTokenProvider<TUser>` 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<TUser>` and the TOTP-based `EmailTokenProvider<TUser>` / `PhoneNumberTokenProvider<TUser>`) are general-purpose: tokens stay valid for the full configured lifespan and are not invalidated when a new token is issued.
ABP replaces the `Default`, `Email`, and `Phone` provider registrations with single-active variants, and redirects `IdentityOptions.Tokens.PasswordResetTokenProvider` / `EmailConfirmationTokenProvider` / `ChangeEmailTokenProvider` to dedicated single-active providers. Generating a new token for the same `(user, provider, purpose)` invalidates the previously issued one, and tokens for the DataProtector-based providers are short-lived by default. The `Authenticator` provider is left as-is because authenticator apps require TOTP. The replacements are wired up in `AbpIdentityAspNetCoreModule.PreConfigureServices`.
## Built-in Providers
| Provider key | Provider | Default | Used by |
| --- | --- | --- | --- |
| `TokenOptions.DefaultProvider` (`"Default"`) | `AbpDefaultTokenProvider` | 10 minutes | Generic challenge tokens (e.g. `RequiresTwoFactor`, `ShouldChangePasswordOnNextLogin`, `PeriodicallyChangePassword`) issued by IdentityServer / OpenIddict password flow endpoints |
| `AbpPasswordResetTokenProvider.ProviderName` (`"AbpPasswordReset"`) | `AbpPasswordResetTokenProvider` | 2 hours | `UserManager.GeneratePasswordResetTokenAsync` / `ResetPasswordAsync` |
| `AbpEmailConfirmationTokenProvider.ProviderName` (`"AbpEmailConfirmation"`) | `AbpEmailConfirmationTokenProvider` | 2 hours | `UserManager.GenerateEmailConfirmationTokenAsync` / `ConfirmEmailAsync` |
| `AbpChangeEmailTokenProvider.ProviderName` (`"AbpChangeEmail"`) | `AbpChangeEmailTokenProvider` | 2 hours | `UserManager.GenerateChangeEmailTokenAsync` / `ChangeEmailAsync` |
| `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<TUser>` | 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<TUser>` 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<IdentityUser>`. On top of the base provider it adds a stored-hash check:
1. **Generation.** The base provider produces the protected token blob as usual. The provider then computes `SHA-256(token)` and stores its hex string in the user-token table under the login provider `"[AbpSingleActiveToken]"` and the name `"<ProviderName>:<purpose>"`. Generating a new token overwrites the same entry, so the previous token's stored hash no longer matches.
2. **Validation.** After the base provider has accepted the token (`SecurityStamp` and `DataProtector` checks), the stored hash is loaded and compared against `SHA-256(submitted token)` using `CryptographicOperations.FixedTimeEquals`. If no hash exists, the token is rejected. A non-hex stored value is treated as invalid rather than thrown.
This has the following effects:
- **Generating a new token invalidates the previous one** for the same `(user, provider, purpose)`. Multiple requests in flight will only let the most recent token complete.
- **Per-purpose isolation.** The stored hash key includes the purpose, so a `RequiresTwoFactor` token and a `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`.
The 2FA OTP providers (`AbpEmailTwoFactorTokenProvider`, `AbpPhoneNumberTwoFactorTokenProvider`) use a different mechanism — see [Two Factor Authentication](./two-factor-authentication.md#how-the-verification-code-is-generated) for the numeric-code single-use design.
## Configuring the Providers
Each DataProtector-based provider exposes an options class deriving from `DataProtectionTokenProviderOptions`, configurable through the standard [options pattern](../../framework/fundamentals/options.md):
| Options class | Default | Used by |
| --- | --- | --- |
| `AbpDefaultTokenProviderOptions` | 10 minutes | Generic challenge tokens (login flow) |
| `AbpPasswordResetTokenProviderOptions` | 2 hours | Password reset links |
| `AbpEmailConfirmationTokenProviderOptions` | 2 hours | Email confirmation links |
| `AbpChangeEmailTokenProviderOptions` | 2 hours | Change-email confirmation links |
| `AbpLinkUserTokenProviderOptions` | 10 minutes | Cross-tenant account linking |
Override them in your module's `ConfigureServices`:
```csharp
Configure<AbpDefaultTokenProviderOptions>(options =>
{
options.TokenLifespan = TimeSpan.FromMinutes(15);
});
Configure<AbpPasswordResetTokenProviderOptions>(options =>
{
options.TokenLifespan = TimeSpan.FromHours(1);
});
```
The `Name` property is set by the constructor of each options class and should not normally be changed — it is the same key that the provider is registered under in `IdentityOptions.Tokens.ProviderMap`.
For OTP-based options see [Configuring the Default Providers](./two-factor-authentication.md#configuring-the-default-providers) in the 2FA document.
## Invalidating a Stored Token
To force a stored single-active token to become invalid before its natural expiration (for example after a security-relevant action), call one of the `IdentityUserManagerSingleActiveTokenExtensions` helpers:
```csharp
await UserManager.RemovePasswordResetTokenAsync(user);
await UserManager.RemoveEmailConfirmationTokenAsync(user);
await UserManager.RemoveChangeEmailTokenAsync(user, newEmail);
await UserManager.RemoveLinkUserTokenAsync(user);
await UserManager.RemoveLinkUserTokenAsync(user, customPurpose);
```
Each method removes the stored hash under `"[AbpSingleActiveToken]"` for the corresponding purpose. Validation afterwards returns `false` even if the token blob itself is still within its DataProtector lifespan and the `SecurityStamp` is unchanged.
For tokens issued by `AbpDefaultTokenProvider` (e.g. `RequiresTwoFactor`, `ShouldChangePasswordOnNextLogin`, `PeriodicallyChangePassword`), call `UserManager.RemoveAuthenticationTokenAsync` directly:
```csharp
await UserManager.RemoveAuthenticationTokenAsync(
user,
AbpSingleActiveTokenProvider.InternalLoginProvider,
TokenOptions.DefaultProvider + ":" + nameof(SignInResult.RequiresTwoFactor));
```
## Replacing a Provider
If the built-in behavior does not match your requirements (different storage backend, different lifespan policy, alphanumeric codes, etc.), register your own implementation under the same key. `IdentityBuilder.AddTokenProvider` writes to `IdentityOptions.Tokens.ProviderMap` and the last registration wins:
```csharp
PreConfigure<IdentityBuilder>(builder =>
{
builder.AddTokenProvider<MyDefaultTokenProvider>(TokenOptions.DefaultProvider);
builder.AddTokenProvider<MyPasswordResetTokenProvider>(AbpPasswordResetTokenProvider.ProviderName);
});
```
The most ergonomic starting point for a single-active variant is to subclass `AbpSingleActiveTokenProvider` and supply your own options class. For a numeric-code provider, subclass `AbpTwoFactorTokenProvider` instead — see the [Two Factor Authentication](./two-factor-authentication.md#replacing-the-verification-code-provider) document.
## Compatibility Notes
- **Tokens issued before the upgrade are rejected after the switch.** The ABP providers look for a stored entry that older tokens (and TOTP 2FA codes) do not have, so they fail validation. Users should request a new password reset link, email confirmation, or 2FA code after the upgrade.
- **Opt out by re-registering the provider key.** If you want the original ASP.NET Core behavior (multi-active, 1 day lifespan) for a specific key, register `DataProtectorTokenProvider<IdentityUser>` (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`.

6
docs/en/modules/identity/two-factor-authentication.md

@ -129,11 +129,11 @@ The codes delivered by the **Email** and **SMS** verification providers are prod
- `AbpEmailTwoFactorTokenProvider` is registered under `TokenOptions.DefaultEmailProvider` and replaces ASP.NET Core Identity's TOTP-based `EmailTokenProvider<TUser>`.
- `AbpPhoneNumberTwoFactorTokenProvider` is registered under `TokenOptions.DefaultPhoneProvider` and replaces ASP.NET Core Identity's TOTP-based `PhoneNumberTokenProvider<TUser>`.
Both derive from the abstract `AbpTwoFactorTokenProvider`. The `Authenticator` provider is unaffected: it is overridden by `AbpAuthenticatorTokenProvider`, which still relies on TOTP ([RFC 6238](https://datatracker.ietf.org/doc/html/rfc6238)) because authenticator apps require it.
Both derive from the abstract `AbpTwoFactorTokenProvider`. The `Authenticator` provider remains ASP.NET Core Identity's built-in `AuthenticatorTokenProvider<TUser>`, because authenticator apps require TOTP ([RFC 6238](https://datatracker.ietf.org/doc/html/rfc6238)).
On generation, the provider produces a cryptographically-random numeric code (default 6 digits), encrypts it together with an absolute UTC expiration via `IDataProtector`, and persists the resulting blob in the user tokens table. The plaintext code is sent to the user via email/SMS and is never stored. Validation reloads the persisted entry, verifies it has not expired, decrypts and compares constant-time against the submitted input, and — on success — removes the entry so it cannot be replayed.
On generation, the provider produces a cryptographically-random numeric code (default 6 digits), encrypts the code with `IDataProtector`, appends the absolute expiration as a Unix-seconds value, and persists the combined string in the user tokens table. The plaintext code is sent to the user via email/SMS and is never stored. Validation reloads the persisted entry, verifies it has not expired, decrypts and compares constant-time against the submitted input, and — on success — removes the entry so it cannot be replayed.
This persisted, single-use design has a few properties worth being explicit about:
This persisted, single-use design has the following effects:
1. **A generated code is single-use.** Successful verification removes the stored entry. Re-submitting the same code from a concurrent session fails.
2. **Generating a new code invalidates the previous one.** `SetToken` overwrites the same `(provider, name)` row, so at most one code is valid at any time. Re-issuing a code (e.g. when the user requests a new one) replaces the stored entry and the previously delivered code stops working.

29
modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpDefaultTokenProvider.cs

@ -0,0 +1,29 @@
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Volo.Abp.Identity;
using Volo.Abp.Threading;
namespace Volo.Abp.Identity.AspNetCore;
/// <summary>
/// Replaces ASP.NET Identity's default <see cref="DataProtectorTokenProvider{IdentityUser}"/>
/// registered under <see cref="TokenOptions.DefaultProvider"/> ("Default"). Used by callers such
/// as the IdentityServer / OpenIddict token endpoints to issue short-lived challenge tokens
/// (RequiresTwoFactor, ShouldChangePasswordOnNextLogin, PeriodicallyChangePassword)
/// and consumed back by Account / SendSecurityCode.
/// Enforces, per purpose, a single active token to be valid.
/// </summary>
public class AbpDefaultTokenProvider : AbpSingleActiveTokenProvider
{
public AbpDefaultTokenProvider(
IDataProtectionProvider dataProtectionProvider,
IOptions<AbpDefaultTokenProviderOptions> options,
ILogger<DataProtectorTokenProvider<IdentityUser>> logger,
IIdentityUserRepository userRepository,
ICancellationTokenProvider cancellationTokenProvider)
: base(dataProtectionProvider, options, logger, userRepository, cancellationTokenProvider)
{
}
}

13
modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpDefaultTokenProviderOptions.cs

@ -0,0 +1,13 @@
using System;
using Microsoft.AspNetCore.Identity;
namespace Volo.Abp.Identity.AspNetCore;
public class AbpDefaultTokenProviderOptions : DataProtectionTokenProviderOptions
{
public AbpDefaultTokenProviderOptions()
{
Name = TokenOptions.DefaultProvider;
TokenLifespan = TimeSpan.FromMinutes(10);
}
}

1
modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpIdentityAspNetCoreModule.cs

@ -19,6 +19,7 @@ public class AbpIdentityAspNetCoreModule : AbpModule
{
builder
.AddDefaultTokenProviders()
.AddTokenProvider<AbpDefaultTokenProvider>(TokenOptions.DefaultProvider)
.AddTokenProvider<LinkUserTokenProvider>(LinkUserTokenProviderConsts.LinkUserTokenProviderName)
.AddTokenProvider<AbpPasswordResetTokenProvider>(AbpPasswordResetTokenProvider.ProviderName)
.AddTokenProvider<AbpEmailConfirmationTokenProvider>(AbpEmailConfirmationTokenProvider.ProviderName)

123
modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpDefaultTokenProvider_Tests.cs

@ -0,0 +1,123 @@
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using Shouldly;
using Volo.Abp.Uow;
using Xunit;
namespace Volo.Abp.Identity.AspNetCore;
public class AbpDefaultTokenProvider_Tests : AbpSingleActiveTokenProviderTestBase
{
private const string TestPurpose = nameof(SignInResult.RequiresTwoFactor);
// Matches ChangePasswordType.ShouldChangePasswordOnNextLogin.ToString() in
// AbpResourceOwnerPasswordValidator (Volo.Abp.IdentityServer.Domain). Hard-coded
// here to avoid taking a project dependency on the IdentityServer module.
private const string ChangePasswordPurpose = "ShouldChangePasswordOnNextLogin";
protected override Task<string> GenerateTokenAsync(IdentityUser user)
=> UserManager.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, TestPurpose);
protected override Task<bool> VerifyTokenAsync(IdentityUser user, string token)
=> UserManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, TestPurpose, token);
protected override string GetProviderName()
=> TokenOptions.DefaultProvider;
protected override string GetPurpose()
=> TestPurpose;
[Fact]
public void AbpDefaultTokenProvider_Should_Override_AspNetCore_Default()
{
var identityOptions = GetRequiredService<IOptions<IdentityOptions>>().Value;
identityOptions.Tokens.ProviderMap.ShouldContainKey(TokenOptions.DefaultProvider);
identityOptions.Tokens.ProviderMap[TokenOptions.DefaultProvider].ProviderType
.ShouldBe(typeof(AbpDefaultTokenProvider));
}
[Fact]
public void AbpDefaultTokenProviderOptions_Should_Default_To_TenMinutes()
{
var options = GetRequiredService<IOptions<AbpDefaultTokenProviderOptions>>().Value;
options.Name.ShouldBe(TokenOptions.DefaultProvider);
options.TokenLifespan.ShouldBe(TimeSpan.FromMinutes(10));
}
[Fact]
public async Task Tokens_For_Different_Purposes_Should_Be_Independent()
{
using (var uow = UnitOfWorkManager.Begin())
{
var user = await UserRepository.GetAsync(TestData.UserJohnId);
var twoFactorToken = await UserManager.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, TestPurpose);
var changePasswordToken = await UserManager.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, ChangePasswordPurpose);
user = await UserRepository.GetAsync(TestData.UserJohnId);
(await UserManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, TestPurpose, twoFactorToken)).ShouldBeTrue();
(await UserManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, ChangePasswordPurpose, changePasswordToken)).ShouldBeTrue();
await uow.CompleteAsync();
}
}
[Fact]
public async Task Regenerating_Same_Purpose_Should_Invalidate_Only_That_Purpose()
{
using (var uow = UnitOfWorkManager.Begin())
{
var user = await UserRepository.GetAsync(TestData.UserJohnId);
var firstTwoFactorToken = await UserManager.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, TestPurpose);
var changePasswordToken = await UserManager.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, ChangePasswordPurpose);
user = await UserRepository.GetAsync(TestData.UserJohnId);
var secondTwoFactorToken = await UserManager.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, TestPurpose);
user = await UserRepository.GetAsync(TestData.UserJohnId);
(await UserManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, TestPurpose, firstTwoFactorToken)).ShouldBeFalse();
(await UserManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, TestPurpose, secondTwoFactorToken)).ShouldBeTrue();
(await UserManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, ChangePasswordPurpose, changePasswordToken)).ShouldBeTrue();
await uow.CompleteAsync();
}
}
[Fact]
public async Task Token_Should_Be_Invalid_After_SecurityStamp_Change()
{
using (var uow = UnitOfWorkManager.Begin())
{
var user = await UserRepository.GetAsync(TestData.UserJohnId);
var token = await GenerateTokenAsync(user);
user = await UserRepository.GetAsync(TestData.UserJohnId);
(await UserManager.UpdateSecurityStampAsync(user)).Succeeded.ShouldBeTrue();
user = await UserRepository.GetAsync(TestData.UserJohnId);
(await VerifyTokenAsync(user, token)).ShouldBeFalse();
await uow.CompleteAsync();
}
}
[Fact]
public async Task Token_Should_Not_Verify_Against_Different_User()
{
using (var uow = UnitOfWorkManager.Begin())
{
var john = await UserRepository.GetAsync(TestData.UserJohnId);
var johnToken = await GenerateTokenAsync(john);
var david = await UserRepository.GetAsync(TestData.UserDavidId);
(await VerifyTokenAsync(david, johnToken)).ShouldBeFalse();
await uow.CompleteAsync();
}
}
}
Loading…
Cancel
Save