feat(auth): add per-account lockout and TOTP 2FA with recovery codes
Adds three layers of account-security hardening, all gated behind the existing SignInWithAppSessionAsync-equivalent (now AppSessionIssuer) so every sign-in path -- local, Google, Microsoft -- goes through the same lockout/2FA checks: - Per-account lockout: Identity's built-in lockout store (columns already provisioned, previously unused) is now wired up in AuthController.Login via IsLockedOutAsync/AccessFailedAsync/ResetAccessFailedCountAsync, 5 failed attempts / 15 min, same generic 401 as wrong-password to avoid enumeration. - RFC 6238 TOTP 2FA (Otp.NET) with QR-code setup (QRCoder, fully local/ offline) on a new TwoFactorController: setup requires password re-confirmation and returns a pending (unconfirmed) secret + QR; the secret is only persisted as active once verify-setup checks a real code. Secrets are encrypted at rest via the same IDataProtector pattern already used for Gmail/Microsoft OAuth refresh tokens. - Login/OAuth exchange now checks TwoFactorEnabled before issuing a real session. If enabled, it hands back an opaque, server-side (IMemoryCache) pending token via a new ITwoFactorPendingTokenService -- deliberately NOT a JWT, so it can never be presented as a bearer token to bypass the 2FA check on any other endpoint. Only POST /api/auth/2fa/challenge can redeem it, rate-limited at 5/5min (tighter than password login, since a 6-digit space is far more brute-forceable). - One-time recovery codes (10 per enable/regenerate, SHA-256-hashed at rest, shown once in plaintext) accepted in the same challenge endpoint as an alternative to a TOTP code. Schema: AspNetUsers gains TotpSecretEncrypted / TotpPendingSecretEncrypted / TotpEnabledAtUtc, plus a new TwoFactorRecoveryCodes table, added to both the SQLite and MySQL dialect blocks in the startup schema reconciler. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,43 @@ namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class AuthAndSystemControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Login_locks_account_after_five_failed_attempts_and_rejects_sixth_even_with_correct_password()
|
||||
{
|
||||
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com" };
|
||||
var userManager = CreateUserManager();
|
||||
userManager.Setup(x => x.FindByEmailAsync("person@example.com")).ReturnsAsync(user);
|
||||
userManager.Setup(x => x.FindByNameAsync("person@example.com")).ReturnsAsync((ApplicationUser?)null);
|
||||
|
||||
var failedCount = 0;
|
||||
var lockedOut = false;
|
||||
userManager.Setup(x => x.IsLockedOutAsync(user)).Returns(() => Task.FromResult(lockedOut));
|
||||
userManager.Setup(x => x.AccessFailedAsync(user))
|
||||
.Callback(() =>
|
||||
{
|
||||
failedCount++;
|
||||
if (failedCount >= 5) lockedOut = true;
|
||||
})
|
||||
.ReturnsAsync(IdentityResult.Success);
|
||||
userManager.Setup(x => x.CheckPasswordAsync(user, "wrong-password")).ReturnsAsync(false);
|
||||
userManager.Setup(x => x.CheckPasswordAsync(user, "correct-password")).ReturnsAsync(true);
|
||||
|
||||
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>());
|
||||
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
var attempt = await controller.Login(new AuthController.LoginRequest("person@example.com", "wrong-password"), CancellationToken.None);
|
||||
Assert.IsType<UnauthorizedResult>(attempt);
|
||||
}
|
||||
|
||||
Assert.True(lockedOut);
|
||||
|
||||
var sixthAttempt = await controller.Login(new AuthController.LoginRequest("person@example.com", "correct-password"), CancellationToken.None);
|
||||
|
||||
Assert.IsType<UnauthorizedResult>(sixthAttempt);
|
||||
userManager.Verify(x => x.CheckPasswordAsync(user, "correct-password"), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_profile_applies_trimmed_profile_fields()
|
||||
{
|
||||
@@ -25,7 +62,7 @@ public sealed class AuthAndSystemControllerTests
|
||||
userManager.Setup(x => x.GetUserAsync(It.IsAny<System.Security.Claims.ClaimsPrincipal>())).ReturnsAsync(user);
|
||||
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
|
||||
|
||||
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance);
|
||||
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>());
|
||||
|
||||
var result = await controller.UpdateProfile(new AuthController.UpdateProfileRequest(" new@example.com ", " newuser ", " Ada ", " Lovelace ", " Ada L. ", null, null));
|
||||
|
||||
@@ -50,7 +87,7 @@ public sealed class AuthAndSystemControllerTests
|
||||
.Setup(x => x.SendAsync(user.Email!, It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("SMTP unavailable"));
|
||||
|
||||
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), emailSender.Object, Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance)
|
||||
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), emailSender.Object, Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>())
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
@@ -91,7 +128,7 @@ public sealed class AuthAndSystemControllerTests
|
||||
.Setup(x => x.ValidateAsync("google-token", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new GoogleTokenPrincipal("google-subject", "dj@cesnimda.co.uk", true, "Dan", "Jones", "Dan Jones"));
|
||||
|
||||
var controller = new AuthController(BuildConfig(), userManager.Object, tokenService.Object, Mock.Of<IAppEmailSender>(), googleValidator.Object, Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance)
|
||||
var controller = new AuthController(BuildConfig(), userManager.Object, tokenService.Object, Mock.Of<IAppEmailSender>(), googleValidator.Object, Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>())
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
@@ -101,7 +138,7 @@ public sealed class AuthAndSystemControllerTests
|
||||
|
||||
var result = await controller.ExchangeGoogleToken(new AuthController.GoogleTokenRequest("google-token"), CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var ok = Assert.IsType<OkObjectResult>(result);
|
||||
var payload = Assert.IsType<AuthController.AuthSessionResult>(ok.Value);
|
||||
Assert.True(payload.Authenticated);
|
||||
Assert.Equal("google", payload.Provider);
|
||||
@@ -135,7 +172,7 @@ public sealed class AuthAndSystemControllerTests
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { ["Auth:AllowRegistration"] = "true" })
|
||||
.Build();
|
||||
|
||||
var controller = new AuthController(config, userManager.Object, tokenService.Object, Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), microsoftValidator.Object, NullLogger<AuthController>.Instance)
|
||||
var controller = new AuthController(config, userManager.Object, tokenService.Object, Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), microsoftValidator.Object, NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>())
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
@@ -145,7 +182,7 @@ public sealed class AuthAndSystemControllerTests
|
||||
|
||||
var result = await controller.ExchangeMicrosoftToken(new AuthController.MicrosoftTokenRequest("microsoft-token"), CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var ok = Assert.IsType<OkObjectResult>(result);
|
||||
var payload = Assert.IsType<AuthController.AuthSessionResult>(ok.Value);
|
||||
Assert.True(payload.Authenticated);
|
||||
Assert.Equal("microsoft", payload.Provider);
|
||||
@@ -166,7 +203,7 @@ public sealed class AuthAndSystemControllerTests
|
||||
.Setup(x => x.ValidateAsync("microsoft-token", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new MicrosoftTokenPrincipal("ms-subject", "nobody@example.com", true, null, null, null));
|
||||
|
||||
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), microsoftValidator.Object, NullLogger<AuthController>.Instance)
|
||||
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), microsoftValidator.Object, NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>())
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
@@ -176,7 +213,7 @@ public sealed class AuthAndSystemControllerTests
|
||||
|
||||
var result = await controller.ExchangeMicrosoftToken(new AuthController.MicrosoftTokenRequest("microsoft-token"), CancellationToken.None);
|
||||
|
||||
Assert.IsType<UnauthorizedObjectResult>(result.Result);
|
||||
Assert.IsType<UnauthorizedObjectResult>(result);
|
||||
userManager.Verify(x => x.CreateAsync(It.IsAny<ApplicationUser>()), Times.Never);
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ public sealed class ClientErrorsControllerTests
|
||||
var userManager = TestHostFactory.CreateUserManager();
|
||||
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
||||
|
||||
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), Mock.Of<ILogger<AuthController>>())
|
||||
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), Mock.Of<ILogger<AuthController>>(), Mock.Of<ITwoFactorPendingTokenService>())
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using JobTrackerApi.Tests.TestSupport;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Moq;
|
||||
using OtpNet;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class TwoFactorControllerTests
|
||||
{
|
||||
private static TwoFactorController BuildController(Mock<UserManager<ApplicationUser>> userManager, JobTrackerApi.Data.JobTrackerContext db, ITwoFactorPendingTokenService? pending = null, ApplicationUser? currentUser = null)
|
||||
{
|
||||
if (currentUser is not null)
|
||||
{
|
||||
userManager.Setup(x => x.GetUserAsync(It.IsAny<System.Security.Claims.ClaimsPrincipal>())).ReturnsAsync(currentUser);
|
||||
}
|
||||
|
||||
var tokenService = new Mock<ITokenService>();
|
||||
tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny<ApplicationUser>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
|
||||
|
||||
var controller = new TwoFactorController(
|
||||
userManager.Object,
|
||||
tokenService.Object,
|
||||
db,
|
||||
pending ?? new TwoFactorPendingTokenService(new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions())),
|
||||
new EphemeralDataProtectionProvider())
|
||||
{
|
||||
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
|
||||
};
|
||||
return controller;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Setup_then_verify_with_correct_code_enables_2fa_and_returns_recovery_codes()
|
||||
{
|
||||
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com" };
|
||||
var userManager = TestHostFactory.CreateUserManager();
|
||||
userManager.Setup(x => x.CheckPasswordAsync(user, "correct-password")).ReturnsAsync(true);
|
||||
userManager.Setup(x => x.UpdateAsync(It.IsAny<ApplicationUser>())).ReturnsAsync(IdentityResult.Success);
|
||||
|
||||
using var db = TestHostFactory.CreateInMemoryDb("user-1");
|
||||
var controller = BuildController(userManager, db, currentUser: user);
|
||||
|
||||
var setupResult = Assert.IsType<OkObjectResult>(await controller.Setup(new TwoFactorController.PasswordConfirmRequest("correct-password"), CancellationToken.None));
|
||||
var setup = Assert.IsType<TwoFactorController.SetupResult>(setupResult.Value);
|
||||
Assert.NotNull(user.TotpPendingSecretEncrypted);
|
||||
Assert.False(user.TwoFactorEnabled);
|
||||
|
||||
var code = new Totp(Base32Encoding.ToBytes(setup.ManualEntryKey)).ComputeTotp();
|
||||
|
||||
var verifyResult = Assert.IsType<OkObjectResult>(await controller.VerifySetup(new TwoFactorController.VerifySetupRequest(code), CancellationToken.None));
|
||||
var verify = Assert.IsType<TwoFactorController.VerifySetupResult>(verifyResult.Value);
|
||||
|
||||
Assert.True(verify.Enabled);
|
||||
Assert.Equal(10, verify.RecoveryCodes.Count);
|
||||
Assert.True(user.TwoFactorEnabled);
|
||||
Assert.Null(user.TotpPendingSecretEncrypted);
|
||||
Assert.NotNull(user.TotpSecretEncrypted);
|
||||
Assert.NotNull(user.TotpEnabledAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Verify_setup_with_wrong_code_is_rejected_and_does_not_enable_2fa()
|
||||
{
|
||||
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com" };
|
||||
var userManager = TestHostFactory.CreateUserManager();
|
||||
userManager.Setup(x => x.CheckPasswordAsync(user, "correct-password")).ReturnsAsync(true);
|
||||
userManager.Setup(x => x.UpdateAsync(It.IsAny<ApplicationUser>())).ReturnsAsync(IdentityResult.Success);
|
||||
|
||||
using var db = TestHostFactory.CreateInMemoryDb("user-1");
|
||||
var controller = BuildController(userManager, db, currentUser: user);
|
||||
|
||||
await controller.Setup(new TwoFactorController.PasswordConfirmRequest("correct-password"), CancellationToken.None);
|
||||
|
||||
var result = await controller.VerifySetup(new TwoFactorController.VerifySetupRequest("000000"), CancellationToken.None);
|
||||
|
||||
Assert.IsType<UnauthorizedResult>(result);
|
||||
Assert.False(user.TwoFactorEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disable_with_wrong_password_is_rejected()
|
||||
{
|
||||
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com", TwoFactorEnabled = true, TotpSecretEncrypted = "irrelevant" };
|
||||
var userManager = TestHostFactory.CreateUserManager();
|
||||
userManager.Setup(x => x.CheckPasswordAsync(user, "wrong-password")).ReturnsAsync(false);
|
||||
|
||||
using var db = TestHostFactory.CreateInMemoryDb("user-1");
|
||||
var controller = BuildController(userManager, db, currentUser: user);
|
||||
|
||||
var result = await controller.Disable(new TwoFactorController.PasswordConfirmRequest("wrong-password"), CancellationToken.None);
|
||||
|
||||
Assert.IsType<BadRequestObjectResult>(result);
|
||||
Assert.True(user.TwoFactorEnabled);
|
||||
Assert.NotNull(user.TotpSecretEncrypted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Challenge_with_valid_totp_code_completes_sign_in()
|
||||
{
|
||||
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com" };
|
||||
var userManager = TestHostFactory.CreateUserManager();
|
||||
userManager.Setup(x => x.CheckPasswordAsync(user, "correct-password")).ReturnsAsync(true);
|
||||
userManager.Setup(x => x.UpdateAsync(It.IsAny<ApplicationUser>())).ReturnsAsync(IdentityResult.Success);
|
||||
userManager.Setup(x => x.FindByIdAsync("user-1")).ReturnsAsync(user);
|
||||
|
||||
var pending = new TwoFactorPendingTokenService(new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
|
||||
|
||||
using var db = TestHostFactory.CreateInMemoryDb("user-1");
|
||||
var controller = BuildController(userManager, db, pending, currentUser: user);
|
||||
|
||||
var setupResult = Assert.IsType<OkObjectResult>(await controller.Setup(new TwoFactorController.PasswordConfirmRequest("correct-password"), CancellationToken.None));
|
||||
var setup = Assert.IsType<TwoFactorController.SetupResult>(setupResult.Value);
|
||||
var code = new Totp(Base32Encoding.ToBytes(setup.ManualEntryKey)).ComputeTotp();
|
||||
await controller.VerifySetup(new TwoFactorController.VerifySetupRequest(code), CancellationToken.None);
|
||||
|
||||
var pendingToken = pending.IssuePendingToken("user-1", rememberMe: false);
|
||||
var challengeCode = new Totp(Base32Encoding.ToBytes(setup.ManualEntryKey)).ComputeTotp();
|
||||
|
||||
var challengeResult = Assert.IsType<OkObjectResult>(await controller.Challenge(new TwoFactorController.ChallengeRequest(pendingToken, challengeCode), CancellationToken.None));
|
||||
var session = Assert.IsType<AuthController.AuthSessionResult>(challengeResult.Value);
|
||||
Assert.True(session.Authenticated);
|
||||
|
||||
// The pending token is single-use.
|
||||
var reuse = await controller.Challenge(new TwoFactorController.ChallengeRequest(pendingToken, challengeCode), CancellationToken.None);
|
||||
Assert.IsType<UnauthorizedResult>(reuse);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Challenge_with_recovery_code_consumes_it_and_rejects_reuse()
|
||||
{
|
||||
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com" };
|
||||
var userManager = TestHostFactory.CreateUserManager();
|
||||
userManager.Setup(x => x.CheckPasswordAsync(user, "correct-password")).ReturnsAsync(true);
|
||||
userManager.Setup(x => x.UpdateAsync(It.IsAny<ApplicationUser>())).ReturnsAsync(IdentityResult.Success);
|
||||
userManager.Setup(x => x.FindByIdAsync("user-1")).ReturnsAsync(user);
|
||||
|
||||
var pending = new TwoFactorPendingTokenService(new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
|
||||
|
||||
using var db = TestHostFactory.CreateInMemoryDb("user-1");
|
||||
var controller = BuildController(userManager, db, pending, currentUser: user);
|
||||
|
||||
var setupResult = Assert.IsType<OkObjectResult>(await controller.Setup(new TwoFactorController.PasswordConfirmRequest("correct-password"), CancellationToken.None));
|
||||
var setup = Assert.IsType<TwoFactorController.SetupResult>(setupResult.Value);
|
||||
var code = new Totp(Base32Encoding.ToBytes(setup.ManualEntryKey)).ComputeTotp();
|
||||
var verifyResult = Assert.IsType<OkObjectResult>(await controller.VerifySetup(new TwoFactorController.VerifySetupRequest(code), CancellationToken.None));
|
||||
var verify = Assert.IsType<TwoFactorController.VerifySetupResult>(verifyResult.Value);
|
||||
var recoveryCode = verify.RecoveryCodes[0];
|
||||
|
||||
var pendingToken1 = pending.IssuePendingToken("user-1", rememberMe: false);
|
||||
var challengeResult = Assert.IsType<OkObjectResult>(await controller.Challenge(new TwoFactorController.ChallengeRequest(pendingToken1, recoveryCode), CancellationToken.None));
|
||||
Assert.True(Assert.IsType<AuthController.AuthSessionResult>(challengeResult.Value).Authenticated);
|
||||
|
||||
// Same recovery code can't be used a second time, even against a fresh pending token.
|
||||
var pendingToken2 = pending.IssuePendingToken("user-1", rememberMe: false);
|
||||
var reuse = await controller.Challenge(new TwoFactorController.ChallengeRequest(pendingToken2, recoveryCode), CancellationToken.None);
|
||||
Assert.IsType<UnauthorizedResult>(reuse);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Challenge_with_unknown_pending_token_is_rejected()
|
||||
{
|
||||
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com" };
|
||||
var userManager = TestHostFactory.CreateUserManager();
|
||||
|
||||
using var db = TestHostFactory.CreateInMemoryDb("user-1");
|
||||
var controller = BuildController(userManager, db, currentUser: user);
|
||||
|
||||
var result = await controller.Challenge(new TwoFactorController.ChallengeRequest("not-a-real-token", "123456"), CancellationToken.None);
|
||||
|
||||
Assert.IsType<UnauthorizedResult>(result);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user