Files
tiku-backend.net/Tiku.Infrastructure/Auth/PasswordLifecycle/AuthService.PasswordLifecycle.cs

173 lines
6.9 KiB
C#

using System.Text.Json;
using System.Security.Cryptography;
using System.Text;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Identity;
using Microsoft.IdentityModel.Tokens;
using Tiku.Application.Auth;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
using Tiku.Domain.Identity;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Auth;
public sealed partial class AuthService
{
public async Task<AuthenticationResult> ChangeRequiredPasswordAsync(
PasswordChangeChallengeRequest request,
CancellationToken cancellationToken = default)
{
var challenge = await FindChallengeAsync(
request.ChallengeToken, AuthChallengePurpose.PasswordChange, cancellationToken);
var user = await userManager.FindByIdAsync(challenge.UserId.ToString())
?? throw new InvalidAuthChallengeException();
var resetToken = await userManager.GeneratePasswordResetTokenAsync(user);
var reset = await userManager.ResetPasswordAsync(user, resetToken, 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 clear the password-change requirement.");
}
await sessionStore.RevokeAllAsync(user.Id, "password_changed", cancellationToken);
await ConsumeChallengeAsync(challenge, cancellationToken);
await AddSecurityAuditAsync(
user.Id, challenge.TenantId, "auth.password.changed", null,
request.IpAddress, request.UserAgent, cancellationToken);
return await CompleteSuccessfulLoginAsync(
challenge.Realm, challenge.TenantId, user, challenge.Provider, user.Email ?? user.Phone ?? user.Id.ToString(),
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);
}
}