feat(auth): replace TOTP with phone-first login

This commit is contained in:
2026-07-28 17:39:29 +08:00
parent e7d350ec3d
commit c7f9a4e3c9
43 changed files with 18386 additions and 882 deletions

View File

@@ -31,6 +31,23 @@ public sealed class AuthServiceTests
Assert.Equal(PasswordVerificationResult.Failed, hasher.VerifyHashedPassword(user, hash, "wrong"));
}
[Theory]
[InlineData("abc12345", true)]
[InlineData("ABC12345", true)]
[InlineData("abcdefgh", false)]
[InlineData("12345678", false)]
[InlineData("abc1234", false)]
public async Task Default_password_policy_requires_eight_characters_letters_and_digits(
string password,
bool expectedSuccess)
{
var validator = new LetterAndDigitPasswordValidator<User>();
var result = await validator.ValidateAsync(null!, new User(), password);
Assert.Equal(expectedSuccess, result.Succeeded);
}
[Fact]
public async Task Password_login_issues_tenant_session_for_regular_member()
{
@@ -70,48 +87,17 @@ public sealed class AuthServiceTests
}
[Fact]
public async Task Backend_permission_requires_one_time_mfa_enrollment_challenge()
public async Task Backend_permission_user_authenticates_without_an_additional_challenge()
{
await using var fixture = await AuthFixture.CreateAsync(includeBackendPermission: true);
var result = await fixture.AuthService.LoginWithPasswordAsync(new PasswordLoginRequest(
AuthRealm.Tenant, fixture.TenantId, AuthFixture.Phone, AuthFixture.Password, null, null));
Assert.Equal(AuthenticationStatus.MfaEnrollmentRequired, result.Status);
Assert.Null(result.User);
Assert.False(string.IsNullOrWhiteSpace(result.ChallengeToken));
Assert.Empty(await fixture.DbContext.AuthSessions.ToArrayAsync());
Assert.Single(await fixture.DbContext.AuthChallenges.ToArrayAsync());
}
[Fact]
public async Task Incomplete_authenticator_setup_still_requires_enrollment()
{
await using var fixture = await AuthFixture.CreateAsync(includeBackendPermission: true);
var user = await fixture.UserManager.FindByIdAsync(fixture.UserId.ToString());
Assert.True((await fixture.UserManager.ResetAuthenticatorKeyAsync(user!)).Succeeded);
Assert.False(user!.TwoFactorEnabled);
var result = await fixture.AuthService.LoginWithPasswordAsync(new PasswordLoginRequest(
AuthRealm.Tenant, fixture.TenantId, AuthFixture.Phone, AuthFixture.Password, null, null));
Assert.Equal(AuthenticationStatus.MfaEnrollmentRequired, result.Status);
}
[Fact]
public async Task Mfa_setup_audit_captures_request_origin()
{
await using var fixture = await AuthFixture.CreateAsync(includeBackendPermission: true);
var login = await fixture.AuthService.LoginWithPasswordAsync(new PasswordLoginRequest(
AuthRealm.Tenant, fixture.TenantId, AuthFixture.Phone, AuthFixture.Password, null, null));
await fixture.AuthService.SetupTotpAsync(new MfaChallengeRequest(
login.ChallengeToken!, null, "127.0.0.9", "mfa-audit-test"));
var audit = await fixture.DbContext.AuditLogs.SingleAsync(item =>
item.Action == "auth.mfa.enrollment_setup");
Assert.Equal("127.0.0.9", audit.IpAddress);
Assert.Equal("mfa-audit-test", audit.UserAgent);
Assert.Equal(AuthenticationStatus.Authenticated, result.Status);
Assert.NotNull(result.User);
Assert.Single(await fixture.DbContext.AuthSessions.ToArrayAsync());
Assert.Empty(await fixture.DbContext.AuthChallenges.ToArrayAsync());
}
private sealed class AuthFixture : IAsyncDisposable
@@ -146,9 +132,9 @@ public sealed class AuthServiceTests
options.UseInMemoryDatabase(Guid.NewGuid().ToString("N")));
services.AddIdentityCore<User>(options =>
{
options.Password.RequiredLength = 10;
options.Password.RequireDigit = true;
options.Password.RequireLowercase = true;
options.Password.RequiredLength = 8;
options.Password.RequireDigit = false;
options.Password.RequireLowercase = false;
options.Password.RequireUppercase = false;
options.Password.RequireNonAlphanumeric = false;
options.Lockout.MaxFailedAccessAttempts = 5;
@@ -156,7 +142,8 @@ public sealed class AuthServiceTests
})
.AddEntityFrameworkStores<TikuDbContext>()
.AddSignInManager()
.AddDefaultTokenProviders();
.AddDefaultTokenProviders()
.AddPasswordValidator<LetterAndDigitPasswordValidator<User>>();
services.Configure<PasswordHasherOptions>(options => options.IterationCount = 210_000);
services.Configure<JwtOptions>(options =>
{