forked from gongxuegit/tiku-backend.net
feat(auth): replace TOTP with phone-first login
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
24
Tiku.Infrastructure/Auth/LetterAndDigitPasswordValidator.cs
Normal file
24
Tiku.Infrastructure/Auth/LetterAndDigitPasswordValidator.cs
Normal 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."
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user