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 =>
{

View File

@@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection;
using Tiku.Application.Security;
using Tiku.Domain.Identity;
using Tiku.Infrastructure.Bootstrap;
using Tiku.Infrastructure.Auth;
using Tiku.Infrastructure.Persistence;
namespace Tiku.UnitTests.Bootstrap;
@@ -28,7 +29,6 @@ public sealed class PlatformAdminBootstrapperTests
var context = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
var user = await context.Users.SingleAsync(item => item.Id == result.UserId);
Assert.True(user.ForcePasswordChange);
Assert.False(user.TwoFactorEnabled);
Assert.True(user.EmailConfirmed);
Assert.Equal(UserStatus.Active, user.Status);
var role = await context.PlatformBackendRoles.SingleAsync(item => item.Id == result.RoleId);
@@ -97,7 +97,6 @@ public sealed class PlatformAdminBootstrapperTests
var user = await context.Users.SingleAsync();
Assert.Equal(DevelopmentPlatformAdminSeeder.Email, user.Email);
Assert.True(user.ForcePasswordChange);
Assert.False(user.TwoFactorEnabled);
Assert.Equal(BackendPermissions.Platform.Count, await context.PlatformBackendRolePermissions.CountAsync());
Assert.Single(context.PlatformBackendUserRoles);
Assert.Single(context.AuditLogs);
@@ -127,14 +126,15 @@ public sealed class PlatformAdminBootstrapperTests
options.UseInMemoryDatabase(Guid.NewGuid().ToString()));
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;
})
.AddEntityFrameworkStores<TikuDbContext>()
.AddDefaultTokenProviders();
.AddDefaultTokenProviders()
.AddPasswordValidator<LetterAndDigitPasswordValidator<User>>();
services.AddDataProtection().UseEphemeralDataProtectionProvider();
return services.BuildServiceProvider();
}