feat(auth): add configurable email verification enforcement
Auth:RequireEmailVerification (default off) gates whether local register requires confirming email before login. OAuth new-user paths are untouched -- Google/Microsoft already assert a verified email. Adds verify-email and resend-verification-email endpoints, mirroring the existing reset-password enumeration-avoidance and rate-limiting patterns, plus a login-embedded resend affordance and a verify-email landing page on the frontend. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -163,6 +163,209 @@ public sealed class AuthAndSystemControllerTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Register_sets_EmailConfirmed_false_and_sends_verification_email_when_flag_on()
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Auth:AllowRegistration"] = "true",
|
||||
["Auth:RequireEmailVerification"] = "true",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var userManager = CreateUserManager();
|
||||
userManager.Setup(x => x.FindByEmailAsync("new.user@example.com")).ReturnsAsync((ApplicationUser?)null);
|
||||
ApplicationUser? created = null;
|
||||
userManager
|
||||
.Setup(x => x.CreateAsync(It.IsAny<ApplicationUser>(), "password123"))
|
||||
.Callback<ApplicationUser, string>((u, _) => created = u)
|
||||
.ReturnsAsync(IdentityResult.Success);
|
||||
userManager.Setup(x => x.GenerateEmailConfirmationTokenAsync(It.IsAny<ApplicationUser>())).ReturnsAsync("confirm-token");
|
||||
|
||||
var tokenService = new Mock<ITokenService>();
|
||||
tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny<ApplicationUser>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
|
||||
|
||||
var emailSender = new Mock<IAppEmailSender>();
|
||||
|
||||
var controller = new AuthController(config, userManager.Object, tokenService.Object, emailSender.Object, Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
|
||||
{
|
||||
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
|
||||
};
|
||||
|
||||
var result = await controller.Register(new AuthController.RegisterRequest("new.user@example.com", "password123"), CancellationToken.None);
|
||||
|
||||
Assert.IsType<OkObjectResult>(result);
|
||||
Assert.NotNull(created);
|
||||
Assert.False(created!.EmailConfirmed);
|
||||
emailSender.Verify(x => x.SendAsync("new.user@example.com", It.IsAny<string>(), It.Is<string>(b => b.Contains("verify-email")), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Register_is_unchanged_when_flag_off()
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { ["Auth:AllowRegistration"] = "true" })
|
||||
.Build();
|
||||
|
||||
var userManager = CreateUserManager();
|
||||
userManager.Setup(x => x.FindByEmailAsync("new.user@example.com")).ReturnsAsync((ApplicationUser?)null);
|
||||
ApplicationUser? created = null;
|
||||
userManager
|
||||
.Setup(x => x.CreateAsync(It.IsAny<ApplicationUser>(), "password123"))
|
||||
.Callback<ApplicationUser, string>((u, _) => created = u)
|
||||
.ReturnsAsync(IdentityResult.Success);
|
||||
|
||||
var tokenService = new Mock<ITokenService>();
|
||||
tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny<ApplicationUser>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
|
||||
|
||||
var emailSender = new Mock<IAppEmailSender>();
|
||||
|
||||
var controller = new AuthController(config, userManager.Object, tokenService.Object, emailSender.Object, Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
|
||||
{
|
||||
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
|
||||
};
|
||||
|
||||
var result = await controller.Register(new AuthController.RegisterRequest("new.user@example.com", "password123"), CancellationToken.None);
|
||||
|
||||
Assert.IsType<OkObjectResult>(result);
|
||||
Assert.NotNull(created);
|
||||
Assert.True(created!.EmailConfirmed);
|
||||
emailSender.Verify(x => x.SendAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Login_rejects_unconfirmed_local_account_when_flag_on()
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { ["Auth:RequireEmailVerification"] = "true" })
|
||||
.Build();
|
||||
|
||||
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com", EmailConfirmed = false };
|
||||
var userManager = CreateUserManager();
|
||||
userManager.Setup(x => x.FindByEmailAsync("person@example.com")).ReturnsAsync(user);
|
||||
userManager.Setup(x => x.FindByNameAsync("person@example.com")).ReturnsAsync((ApplicationUser?)null);
|
||||
userManager.Setup(x => x.IsLockedOutAsync(user)).ReturnsAsync(false);
|
||||
userManager.Setup(x => x.CheckPasswordAsync(user, "correct-password")).ReturnsAsync(true);
|
||||
userManager.Setup(x => x.ResetAccessFailedCountAsync(user)).ReturnsAsync(IdentityResult.Success);
|
||||
|
||||
var controller = new AuthController(config, userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
|
||||
{
|
||||
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
|
||||
};
|
||||
|
||||
var result = await controller.Login(new AuthController.LoginRequest("person@example.com", "correct-password"), CancellationToken.None);
|
||||
|
||||
var obj = Assert.IsType<ObjectResult>(result);
|
||||
Assert.Equal(StatusCodes.Status403Forbidden, obj.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Login_allows_unconfirmed_local_account_when_flag_off()
|
||||
{
|
||||
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com", EmailConfirmed = false };
|
||||
var userManager = CreateUserManager();
|
||||
userManager.Setup(x => x.FindByEmailAsync("person@example.com")).ReturnsAsync(user);
|
||||
userManager.Setup(x => x.FindByNameAsync("person@example.com")).ReturnsAsync((ApplicationUser?)null);
|
||||
userManager.Setup(x => x.IsLockedOutAsync(user)).ReturnsAsync(false);
|
||||
userManager.Setup(x => x.CheckPasswordAsync(user, "correct-password")).ReturnsAsync(true);
|
||||
userManager.Setup(x => x.ResetAccessFailedCountAsync(user)).ReturnsAsync(IdentityResult.Success);
|
||||
|
||||
var tokenService = new Mock<ITokenService>();
|
||||
tokenService.Setup(x => x.CreateAccessTokenAsync(user, It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
|
||||
|
||||
var controller = new AuthController(BuildConfig(), userManager.Object, tokenService.Object, Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
|
||||
{
|
||||
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
|
||||
};
|
||||
|
||||
var result = await controller.Login(new AuthController.LoginRequest("person@example.com", "correct-password"), CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result);
|
||||
var session = Assert.IsType<AuthController.AuthSessionResult>(ok.Value);
|
||||
Assert.True(session.Authenticated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyEmail_confirms_account_on_valid_token()
|
||||
{
|
||||
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com", EmailConfirmed = false };
|
||||
var userManager = CreateUserManager();
|
||||
userManager.Setup(x => x.FindByIdAsync("user-1")).ReturnsAsync(user);
|
||||
userManager.Setup(x => x.ConfirmEmailAsync(user, "good-token")).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, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb());
|
||||
|
||||
var result = await controller.VerifyEmail(new AuthController.VerifyEmailRequest("user-1", "good-token"));
|
||||
|
||||
Assert.IsType<NoContentResult>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResendVerificationEmail_returns_identical_response_for_real_and_fake_accounts()
|
||||
{
|
||||
var user = new ApplicationUser { Id = "user-1", Email = "real@example.com", UserName = "real@example.com", EmailConfirmed = false };
|
||||
var userManager = CreateUserManager();
|
||||
userManager.Setup(x => x.FindByEmailAsync("real@example.com")).ReturnsAsync(user);
|
||||
userManager.Setup(x => x.FindByEmailAsync("fake@example.com")).ReturnsAsync((ApplicationUser?)null);
|
||||
userManager.Setup(x => x.HasPasswordAsync(user)).ReturnsAsync(true);
|
||||
userManager.Setup(x => x.GenerateEmailConfirmationTokenAsync(user)).ReturnsAsync("confirm-token");
|
||||
|
||||
var emailSender = new Mock<IAppEmailSender>();
|
||||
|
||||
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), emailSender.Object, Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
|
||||
{
|
||||
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
|
||||
};
|
||||
|
||||
var realResult = await controller.ResendVerificationEmail(new AuthController.ResendVerificationEmailRequest("real@example.com"), CancellationToken.None);
|
||||
var fakeResult = await controller.ResendVerificationEmail(new AuthController.ResendVerificationEmailRequest("fake@example.com"), CancellationToken.None);
|
||||
|
||||
Assert.IsType<NoContentResult>(realResult);
|
||||
Assert.IsType<NoContentResult>(fakeResult);
|
||||
emailSender.Verify(x => x.SendAsync("real@example.com", It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Exchange_google_token_new_user_stays_EmailConfirmed_true_even_when_verification_flag_is_on()
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Auth:AllowRegistration"] = "true",
|
||||
["Auth:RequireEmailVerification"] = "true",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var userManager = CreateUserManager();
|
||||
userManager.Setup(x => x.Users).Returns(new TestAsyncEnumerable<ApplicationUser>(new List<ApplicationUser>()));
|
||||
userManager.Setup(x => x.FindByEmailAsync("new.hire@example.com")).ReturnsAsync((ApplicationUser?)null);
|
||||
ApplicationUser? created = null;
|
||||
userManager
|
||||
.Setup(x => x.CreateAsync(It.IsAny<ApplicationUser>()))
|
||||
.Callback<ApplicationUser>(u => created = u)
|
||||
.ReturnsAsync(IdentityResult.Success);
|
||||
|
||||
var tokenService = new Mock<ITokenService>();
|
||||
tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny<ApplicationUser>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
|
||||
|
||||
var googleValidator = new Mock<IGoogleTokenValidator>();
|
||||
googleValidator
|
||||
.Setup(x => x.ValidateAsync("google-token", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new GoogleTokenPrincipal("google-subject", "new.hire@example.com", true, "New", "Hire", "New Hire"));
|
||||
|
||||
var controller = new AuthController(config, userManager.Object, tokenService.Object, Mock.Of<IAppEmailSender>(), googleValidator.Object, Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
|
||||
{
|
||||
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
|
||||
};
|
||||
|
||||
var result = await controller.ExchangeGoogleToken(new AuthController.GoogleTokenRequest("google-token"), CancellationToken.None);
|
||||
|
||||
Assert.IsType<OkObjectResult>(result);
|
||||
Assert.NotNull(created);
|
||||
Assert.True(created!.EmailConfirmed);
|
||||
}
|
||||
|
||||
private static JobTrackerContext BuildDb(string dbName, string? currentUserId)
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<JobTrackerContext>().UseInMemoryDatabase(dbName).Options;
|
||||
|
||||
Reference in New Issue
Block a user