feat: strengthen P0 security and operations

This commit is contained in:
2026-08-01 11:20:02 +08:00
parent f776056834
commit 84c2b0b21d
77 changed files with 24185 additions and 355 deletions

View File

@@ -226,6 +226,127 @@ public sealed class AuthService(
request.IpAddress, request.UserAgent, cancellationToken);
}
public async Task<SmsSendResult> RequestPasswordResetAsync(
PasswordResetCodeRequest request,
CancellationToken cancellationToken = default)
{
var phone = SmsCodeHashing.NormalizePhone(request.Phone);
var userId = await dbContext.Users.AsNoTracking()
.Where(user => user.Phone == phone && user.Status == UserStatus.Active)
.Select(user => (Guid?)user.Id)
.SingleOrDefaultAsync(cancellationToken);
var eligible = userId.HasValue && await dbContext.TenantMemberships.AsNoTracking().AnyAsync(
membership => membership.TenantId == request.TenantId && membership.UserId == userId.Value &&
membership.Status == MembershipStatus.Active,
cancellationToken);
if (!eligible)
{
return new SmsSendResult(Guid.NewGuid(), DateTimeOffset.UtcNow.AddMinutes(5));
}
return await smsVerificationService.CreateCodeAsync(
new SendSmsCodeRequest(
request.TenantId,
phone,
SmsPurpose.ResetPassword,
request.IpAddress,
request.UserAgent,
request.DeviceId),
cancellationToken);
}
public async Task ResetPasswordAsync(
PasswordResetRequest request,
CancellationToken cancellationToken = default)
{
var phone = SmsCodeHashing.NormalizePhone(request.Phone);
var user = await dbContext.Users.SingleOrDefaultAsync(
item => item.Phone == phone && item.Status == UserStatus.Active,
cancellationToken);
if (user is null || !await dbContext.TenantMemberships.AnyAsync(
membership => membership.TenantId == request.TenantId && membership.UserId == user.Id &&
membership.Status == MembershipStatus.Active,
cancellationToken))
{
throw new InvalidCredentialsException();
}
await smsVerificationService.VerifyCodeAsync(
request.TenantId,
phone,
SmsPurpose.ResetPassword,
request.Code,
cancellationToken);
var token = await userManager.GeneratePasswordResetTokenAsync(user);
var reset = await userManager.ResetPasswordAsync(user, token, request.NewPassword);
if (!reset.Succeeded)
{
throw new InvalidCredentialsException("invalid_new_password");
}
user.ForcePasswordChange = false;
var updated = await userManager.UpdateAsync(user);
if (!updated.Succeeded)
{
throw new InvalidOperationException("Unable to finalize the password reset.");
}
await sessionStore.RevokeAllAsync(user.Id, "password_reset", cancellationToken);
await AddSecurityAuditAsync(
user.Id,
request.TenantId,
"auth.password.reset",
null,
request.IpAddress,
request.UserAgent,
cancellationToken);
}
public async Task<AuthenticationResult> ChangePasswordAsync(
AuthenticatedPasswordChangeRequest request,
CancellationToken cancellationToken = default)
{
var session = await sessionStore.ResolveActiveSessionAsync(
request.SessionId,
request.UserId,
cancellationToken) ?? throw new SessionRevokedException();
var user = await userManager.FindByIdAsync(request.UserId.ToString())
?? throw new InvalidCredentialsException();
var changed = await userManager.ChangePasswordAsync(user, request.CurrentPassword, request.NewPassword);
if (!changed.Succeeded)
{
var currentPasswordInvalid = changed.Errors.Any(error =>
string.Equals(error.Code, "PasswordMismatch", StringComparison.OrdinalIgnoreCase));
throw new InvalidCredentialsException(currentPasswordInvalid ? "invalid_credentials" : "invalid_new_password");
}
user.ForcePasswordChange = false;
var updated = await userManager.UpdateAsync(user);
if (!updated.Succeeded)
{
throw new InvalidOperationException("Unable to finalize the password change.");
}
await sessionStore.RevokeAllAsync(user.Id, "password_changed", cancellationToken);
await AddSecurityAuditAsync(
user.Id,
session.TenantId,
"auth.password.changed_authenticated",
null,
request.IpAddress,
request.UserAgent,
cancellationToken);
return await CompleteSuccessfulLoginAsync(
session.Realm,
session.TenantId,
user,
PasswordProvider,
user.Email ?? user.Phone ?? user.Id.ToString(),
request.IpAddress,
request.UserAgent,
cancellationToken);
}
private async Task<AuthChallenge> FindChallengeAsync(
string token,
AuthChallengePurpose purpose,