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

@@ -192,99 +192,6 @@ public sealed class AuthService(
await sessionStore.RevokeAllAsync(userId, "logout_all", cancellationToken);
}
public async Task<MfaSetupResult> SetupTotpAsync(
MfaChallengeRequest request,
CancellationToken cancellationToken = default)
{
var challenge = await FindChallengeAsync(
request.ChallengeToken, AuthChallengePurpose.MfaEnrollment, cancellationToken);
var user = await userManager.FindByIdAsync(challenge.UserId.ToString())
?? throw new InvalidAuthChallengeException();
var reset = await userManager.ResetAuthenticatorKeyAsync(user);
if (!reset.Succeeded)
{
throw new InvalidOperationException("Unable to initialize the authenticator key.");
}
var key = await userManager.GetAuthenticatorKeyAsync(user)
?? throw new InvalidOperationException("Authenticator key was not generated.");
challenge.SecurityStamp = user.SecurityStamp ?? string.Empty;
var account = user.Email ?? user.Phone ?? user.Id.ToString();
var uri = $"otpauth://totp/{Uri.EscapeDataString("TIKU:" + account)}" +
$"?secret={Uri.EscapeDataString(key)}&issuer={Uri.EscapeDataString("TIKU")}&digits=6";
await AddSecurityAuditAsync(
user.Id, challenge.TenantId, "auth.mfa.enrollment_setup", null,
request.IpAddress, request.UserAgent, cancellationToken);
return new MfaSetupResult(key, uri);
}
public async Task<MfaConfirmResult> ConfirmTotpAsync(
MfaChallengeRequest request,
CancellationToken cancellationToken = default)
{
var challenge = await FindChallengeAsync(
request.ChallengeToken, AuthChallengePurpose.MfaEnrollment, cancellationToken);
var user = await userManager.FindByIdAsync(challenge.UserId.ToString())
?? throw new InvalidAuthChallengeException();
if (string.IsNullOrWhiteSpace(request.Code) ||
!await userManager.VerifyTwoFactorTokenAsync(
user, TokenOptions.DefaultAuthenticatorProvider, NormalizeTotp(request.Code)))
{
await AddSecurityAuditAsync(
user.Id, challenge.TenantId, "auth.mfa.enrollment_denied", "invalid_code",
request.IpAddress, request.UserAgent, cancellationToken);
throw new InvalidCredentialsException("invalid_mfa_code");
}
var enabled = await userManager.SetTwoFactorEnabledAsync(user, true);
if (!enabled.Succeeded)
{
throw new InvalidOperationException("Unable to enable two-factor authentication.");
}
await ConsumeChallengeAsync(challenge, cancellationToken);
await AddSecurityAuditAsync(
user.Id, challenge.TenantId, "auth.mfa.enrollment_confirmed", null,
request.IpAddress, request.UserAgent, cancellationToken);
var recoveryCodes = (await userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10))?.ToArray() ?? [];
var authentication = await IssueFromChallengeAsync(
challenge, user, request.IpAddress, request.UserAgent, cancellationToken);
return new MfaConfirmResult(authentication, recoveryCodes);
}
public async Task<AuthenticationResult> VerifyTotpAsync(
MfaChallengeRequest request,
CancellationToken cancellationToken = default)
{
var challenge = await FindChallengeAsync(
request.ChallengeToken, AuthChallengePurpose.MfaVerification, cancellationToken);
var user = await userManager.FindByIdAsync(challenge.UserId.ToString())
?? throw new InvalidAuthChallengeException();
var recoveryCode = request.Code?.Trim();
var totpCode = NormalizeTotp(request.Code);
var verifiedByTotp = !string.IsNullOrWhiteSpace(totpCode) &&
await userManager.VerifyTwoFactorTokenAsync(
user, TokenOptions.DefaultAuthenticatorProvider, totpCode);
var verifiedByRecoveryCode = !verifiedByTotp &&
!string.IsNullOrWhiteSpace(recoveryCode) &&
(await userManager.RedeemTwoFactorRecoveryCodeAsync(user, recoveryCode)).Succeeded;
if (!verifiedByTotp && !verifiedByRecoveryCode)
{
await AddSecurityAuditAsync(
user.Id, challenge.TenantId, "auth.mfa.verification_denied", "invalid_code",
request.IpAddress, request.UserAgent, cancellationToken);
throw new InvalidCredentialsException("invalid_mfa_code");
}
await ConsumeChallengeAsync(challenge, cancellationToken);
await AddSecurityAuditAsync(
user.Id, challenge.TenantId, "auth.mfa.verified",
verifiedByRecoveryCode ? "recovery_code" : "totp",
request.IpAddress, request.UserAgent, cancellationToken);
return await IssueFromChallengeAsync(
challenge, user, request.IpAddress, request.UserAgent, cancellationToken);
}
public async Task<AuthenticationResult> ChangeRequiredPasswordAsync(
PasswordChangeChallengeRequest request,
CancellationToken cancellationToken = default)
@@ -317,37 +224,6 @@ public sealed class AuthService(
request.IpAddress, request.UserAgent, cancellationToken);
}
private async Task<AuthenticationResult> IssueFromChallengeAsync(
AuthChallenge challenge,
User user,
string? ipAddress,
string? userAgent,
CancellationToken cancellationToken)
{
if (!await HasBackendPermissionsAsync(
challenge.Realm, challenge.TenantId, user.Id, cancellationToken))
{
throw new InvalidAuthChallengeException("backend_access_revoked");
}
Tenant? tenant = null;
TenantMembership? membership = null;
if (challenge.Realm == AuthRealm.Tenant && challenge.TenantId.HasValue)
{
tenant = await dbContext.Tenants.SingleOrDefaultAsync(
item => item.Id == challenge.TenantId.Value && item.Status == TenantStatus.Active, cancellationToken);
membership = await FindActiveMembershipAsync(challenge.TenantId.Value, user.Id, cancellationToken);
if (tenant is null || membership is null)
{
throw new TenantAccessDeniedException();
}
}
return await IssueAuthenticatedResultAsync(
user, challenge.Realm, tenant, membership, challenge.Provider,
mfaSatisfied: true, null, ipAddress, userAgent, cancellationToken);
}
private async Task<AuthChallenge> FindChallengeAsync(
string token,
AuthChallengePurpose purpose,
@@ -431,21 +307,8 @@ public sealed class AuthService(
AuthenticationStatus.PasswordChangeRequired, ipAddress, userAgent, cancellationToken);
}
var requiresMfa = await HasBackendPermissionsAsync(realm, tenantId, user.Id, cancellationToken);
if (requiresMfa)
{
var hasAuthenticator = user.TwoFactorEnabled &&
!string.IsNullOrWhiteSpace(await userManager.GetAuthenticatorKeyAsync(user));
return await CreateChallengeResultAsync(
user, realm, tenantId,
hasAuthenticator ? AuthChallengePurpose.MfaVerification : AuthChallengePurpose.MfaEnrollment,
provider,
hasAuthenticator ? AuthenticationStatus.MfaRequired : AuthenticationStatus.MfaEnrollmentRequired,
ipAddress, userAgent, cancellationToken);
}
return await IssueAuthenticatedResultAsync(
user, realm, tenant, membership, provider, mfaSatisfied: false,
user, realm, tenant, membership, provider,
identifier, ipAddress, userAgent, cancellationToken);
}
@@ -455,7 +318,6 @@ public sealed class AuthService(
Tenant? tenant,
TenantMembership? membership,
string provider,
bool mfaSatisfied,
string? identifier,
string? ipAddress,
string? userAgent,
@@ -470,7 +332,6 @@ public sealed class AuthService(
realm,
tenant?.Id,
provider,
mfaSatisfied,
ipAddress,
userAgent),
cancellationToken);
@@ -799,10 +660,6 @@ public sealed class AuthService(
private static string HashChallengeToken(string token) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token ?? string.Empty))).ToLowerInvariant();
private static string NormalizeTotp(string? code) =>
(code ?? string.Empty).Replace(" ", string.Empty, StringComparison.Ordinal)
.Replace("-", string.Empty, StringComparison.Ordinal);
private async Task AddSecurityAuditAsync(
Guid userId,
Guid? tenantId,

View File

@@ -109,7 +109,7 @@ public sealed class AuthSessionStore(
try
{
await AssertRealmAccessAsync(
current.Realm, current.TenantId, current.UserId, current.MfaSatisfied, cancellationToken);
current.Realm, current.TenantId, current.UserId, cancellationToken);
}
catch (TenantAccessDeniedException)
{
@@ -133,7 +133,7 @@ public sealed class AuthSessionStore(
var request = new AuthSessionIssueRequest(
user.Id, user.Phone, user.Email, user.SecurityStamp ?? string.Empty,
current.Realm, current.TenantId, "refresh", current.MfaSatisfied,
current.Realm, current.TenantId, "refresh",
ipAddress, userAgent, current.TokenFamilyId, current.Id);
var next = CreateSession(request, nextId);
var nextToken = GenerateRefreshToken(next.Realm, next.TenantId, next.Id);
@@ -171,14 +171,14 @@ public sealed class AuthSessionStore(
try
{
await AssertRealmAccessAsync(
realm, tenantId, userId, session.MfaSatisfied, cancellationToken);
realm, tenantId, userId, cancellationToken);
}
catch (TenantAccessDeniedException)
{
return null;
}
return new AuthSessionValidationResult(userId, realm, tenantId, session.MfaSatisfied);
return new AuthSessionValidationResult(userId, realm, tenantId);
}
public async Task RevokeFamilyAsync(string refreshToken, string reason, CancellationToken cancellationToken = default)
@@ -256,7 +256,6 @@ public sealed class AuthSessionStore(
TokenFamilyId = request.TokenFamilyId ?? sessionId,
ParentSessionId = request.ParentSessionId,
SecurityStamp = request.SecurityStamp,
MfaSatisfied = request.MfaSatisfied,
Provider = request.Provider,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(options.RefreshTokenDays),
IpAddress = request.IpAddress,
@@ -267,7 +266,7 @@ public sealed class AuthSessionStore(
{
var access = tokenService.CreateAccessToken(
request.UserId, session.Id, request.Phone, request.Email,
request.Realm, request.TenantId, request.MfaSatisfied);
request.Realm, request.TenantId);
return new AuthTokenPair(access.Token, refreshToken, access.ExpiresAt, session.ExpiresAt);
}
@@ -275,14 +274,13 @@ public sealed class AuthSessionStore(
AuthRealm realm,
Guid? tenantId,
Guid userId,
bool mfaSatisfied,
CancellationToken cancellationToken)
{
if (realm == AuthRealm.Tenant && tenantId.HasValue)
{
var active = await dbContext.Tenants.AnyAsync(item => item.Id == tenantId && item.Status == TenantStatus.Active, cancellationToken) &&
await dbContext.TenantMemberships.AnyAsync(item => item.TenantId == tenantId && item.UserId == userId && item.Status == MembershipStatus.Active, cancellationToken);
if (active && (!mfaSatisfied || await HasTenantBackendPermissionAsync(tenantId.Value, userId, cancellationToken)))
if (active)
{
return;
}
@@ -303,19 +301,6 @@ public sealed class AuthSessionStore(
throw new TenantAccessDeniedException();
}
private Task<bool> HasTenantBackendPermissionAsync(
Guid tenantId,
Guid userId,
CancellationToken cancellationToken) =>
(from userRole in dbContext.TenantBackendUserRoles
join role in dbContext.TenantBackendRoles on userRole.RoleId equals role.Id
join binding in dbContext.TenantBackendRolePermissions on role.Id equals binding.RoleId
join permission in dbContext.BackendPermissions on binding.PermissionCode equals permission.Code
where userRole.TenantId == tenantId && userRole.UserId == userId &&
binding.TenantId == tenantId && role.Status == BackendRoleStatus.Active &&
(permission.Area == BackendPermissionArea.Tenant || permission.Area == BackendPermissionArea.Both)
select permission.Id).AnyAsync(cancellationToken);
private async Task<int> RevokeFamilyCoreAsync(Guid familyId, string reason, DateTimeOffset now, CancellationToken cancellationToken)
{
var owner = await dbContext.AuthSessions.AsNoTracking()

View File

@@ -0,0 +1,24 @@
using Microsoft.AspNetCore.Identity;
namespace Tiku.Infrastructure.Auth;
public sealed class LetterAndDigitPasswordValidator<TUser> : IPasswordValidator<TUser>
where TUser : class
{
public Task<IdentityResult> ValidateAsync(
UserManager<TUser> manager,
TUser user,
string? password)
{
var valid = password is { Length: >= 8 } &&
password.Any(char.IsLetter) &&
password.Any(char.IsDigit);
return Task.FromResult(valid
? IdentityResult.Success
: IdentityResult.Failed(new IdentityError
{
Code = "PasswordRequiresLetterAndDigit",
Description = "Password must be at least 8 characters and contain both letters and digits."
}));
}
}

View File

@@ -17,8 +17,7 @@ public sealed class TokenService(IOptions<JwtOptions> options, IJwtKeyRing keyRi
string? phone,
string? email,
AuthRealm realm,
Guid? tenantId,
bool mfaSatisfied)
Guid? tenantId)
{
var expiresAt = DateTimeOffset.UtcNow.AddMinutes(options.AccessTokenMinutes);
var claims = new List<Claim>
@@ -35,11 +34,6 @@ public sealed class TokenService(IOptions<JwtOptions> options, IJwtKeyRing keyRi
claims.Add(new Claim(TikuClaimTypes.TenantId, tenantId.Value.ToString()));
}
if (mfaSatisfied)
{
claims.Add(new Claim(TikuClaimTypes.Mfa, "mfa"));
}
if (!string.IsNullOrWhiteSpace(phone))
{
claims.Add(new Claim(TikuClaimTypes.Phone, phone));

View File

@@ -92,8 +92,7 @@ public static class DevelopmentPlatformAdminSeeder
Name = "Local Platform Administrator",
EmailConfirmed = true,
Status = UserStatus.Active,
ForcePasswordChange = true,
TwoFactorEnabled = false
ForcePasswordChange = true
};
var passwordHasher = new PasswordHasher<User>(Options.Create(new PasswordHasherOptions
{
@@ -145,7 +144,6 @@ public static class DevelopmentPlatformAdminSeeder
user.Email,
RoleCode,
ForcePasswordChange = true,
MfaEnrollmentRequired = true,
Source = "ef_core_use_seeding"
})
});
@@ -187,6 +185,6 @@ public static class DevelopmentPlatformAdminSeeder
Console.WriteLine("Development platform administrator created by EF Core data seeding.");
Console.WriteLine($" Account: {Email}");
Console.WriteLine($" Temporary password: {temporaryPassword}");
Console.WriteLine(" Change the password and enroll TOTP MFA at first sign-in. This password is shown only once.");
Console.WriteLine(" Change the temporary password at first sign-in. This password is shown only once.");
}
}

View File

@@ -78,8 +78,7 @@ public sealed class PlatformAdminBootstrapper(
Name = string.IsNullOrWhiteSpace(options.DisplayName) ? "Platform Administrator" : options.DisplayName.Trim(),
EmailConfirmed = true,
Status = UserStatus.Active,
ForcePasswordChange = true,
TwoFactorEnabled = false
ForcePasswordChange = true
};
var createResult = await userManager.CreateAsync(user, options.TemporaryPassword);
if (!createResult.Succeeded)
@@ -140,7 +139,7 @@ public sealed class PlatformAdminBootstrapper(
user.Email,
RoleCode = SuperAdminRoleCode,
ForcePasswordChange = true,
MfaEnrollmentRequired = true
LoginMethod = "account_password"
})
});

View File

@@ -68,9 +68,9 @@ public static class DependencyInjection
});
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;
@@ -79,7 +79,8 @@ public static class DependencyInjection
})
.AddEntityFrameworkStores<TikuDbContext>()
.AddSignInManager()
.AddDefaultTokenProviders();
.AddDefaultTokenProviders()
.AddPasswordValidator<LetterAndDigitPasswordValidator<User>>();
services.Configure<PasswordHasherOptions>(options => options.IterationCount = 210_000);
services.AddScoped<ITenantDirectory, TenantDirectory>();
services.AddMemoryCache();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Tiku.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class RemoveMfaAuthentication : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "mfa_satisfied",
table: "auth_sessions");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "mfa_satisfied",
table: "auth_sessions",
type: "boolean",
nullable: false,
defaultValue: false);
}
}
}

View File

@@ -13318,10 +13318,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasColumnName("metadata")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<bool>("MfaSatisfied")
.HasColumnType("boolean")
.HasColumnName("mfa_satisfied");
b.Property<Guid?>("ParentSessionId")
.HasColumnType("uuid")
.HasColumnName("parent_session_id");