313 lines
10 KiB
PHP
313 lines
10 KiB
PHP
<?php
|
|
|
|
namespace App\Domains\Auth\Services;
|
|
|
|
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
|
use App\Domains\Auth\Models\User;
|
|
use App\Domains\Authorization\Enums\RoleCode;
|
|
use App\Domains\Notification\Events\PasswordResetRequested;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Throwable;
|
|
|
|
class ResetPasswordAttemptService
|
|
{
|
|
public function createForEmail(
|
|
string $email,
|
|
string $tenantCode,
|
|
string $reason = ResetPasswordAttempt::REASON_MANUAL,
|
|
): void {
|
|
$emailFingerprint = $this->emailFingerprint($email);
|
|
|
|
try {
|
|
$attemptId = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?int {
|
|
$user = User::query()
|
|
->where('email', $email)
|
|
->lockForUpdate()
|
|
->first();
|
|
|
|
return $this->createAttemptForUser(
|
|
$user,
|
|
$reason,
|
|
$emailFingerprint,
|
|
'Password reset attempt was not created because the user was not found.',
|
|
);
|
|
});
|
|
} catch (Throwable $exception) {
|
|
Log::error('Failed to create password reset attempt.', [
|
|
'email_fingerprint' => $emailFingerprint,
|
|
'exception' => $exception,
|
|
]);
|
|
|
|
throw $exception;
|
|
}
|
|
|
|
$this->dispatchPasswordResetRequested(
|
|
$attemptId,
|
|
$tenantCode,
|
|
PasswordResetRequested::CHANNEL_STOREFRONT,
|
|
$emailFingerprint,
|
|
);
|
|
}
|
|
|
|
public function createForAdminAppEmail(
|
|
string $email,
|
|
string $reason = ResetPasswordAttempt::REASON_MANUAL,
|
|
): void {
|
|
$emailFingerprint = $this->emailFingerprint($email);
|
|
|
|
try {
|
|
$result = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?array {
|
|
$user = User::query()
|
|
->where('email', $email)
|
|
->where('rol_codigo', RoleCode::AdminApp->value)
|
|
->whereNotNull('tenant_codigo')
|
|
->lockForUpdate()
|
|
->first();
|
|
|
|
$attemptId = $this->createAttemptForUser(
|
|
$user,
|
|
$reason,
|
|
$emailFingerprint,
|
|
'AdminApp password reset attempt was not created because the user was not found.',
|
|
);
|
|
|
|
if ($user === null || $attemptId === null) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'attempt_id' => $attemptId,
|
|
'tenant_code' => $user->tenant_codigo,
|
|
];
|
|
});
|
|
} catch (Throwable $exception) {
|
|
Log::error('Failed to create AdminApp password reset attempt.', [
|
|
'email_fingerprint' => $emailFingerprint,
|
|
'exception' => $exception,
|
|
]);
|
|
|
|
throw $exception;
|
|
}
|
|
|
|
$this->dispatchPasswordResetRequested(
|
|
$result['attempt_id'] ?? null,
|
|
$result['tenant_code'] ?? null,
|
|
PasswordResetRequested::CHANNEL_ADMINAPP,
|
|
$emailFingerprint,
|
|
);
|
|
}
|
|
|
|
public function createForScannerEmail(
|
|
string $email,
|
|
string $reason = ResetPasswordAttempt::REASON_MANUAL,
|
|
): void {
|
|
$emailFingerprint = $this->emailFingerprint($email);
|
|
|
|
try {
|
|
$result = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?array {
|
|
$user = User::query()
|
|
->where('email', $email)
|
|
->where('rol_codigo', RoleCode::Scanner->value)
|
|
->whereNotNull('tenant_codigo')
|
|
->lockForUpdate()
|
|
->first();
|
|
|
|
$attemptId = $this->createAttemptForUser(
|
|
$user,
|
|
$reason,
|
|
$emailFingerprint,
|
|
'Scanner password reset attempt was not created because the user was not found.',
|
|
);
|
|
|
|
if ($user === null || $attemptId === null) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'attempt_id' => $attemptId,
|
|
'tenant_code' => $user->tenant_codigo,
|
|
];
|
|
});
|
|
} catch (Throwable $exception) {
|
|
Log::error('Failed to create Scanner password reset attempt.', [
|
|
'email_fingerprint' => $emailFingerprint,
|
|
'exception' => $exception,
|
|
]);
|
|
|
|
throw $exception;
|
|
}
|
|
|
|
$this->dispatchPasswordResetRequested(
|
|
$result['attempt_id'] ?? null,
|
|
$result['tenant_code'] ?? null,
|
|
PasswordResetRequested::CHANNEL_SCANNER,
|
|
$emailFingerprint,
|
|
);
|
|
}
|
|
|
|
public function validateCode(string $email, string $code): bool
|
|
{
|
|
$emailFingerprint = $this->emailFingerprint($email);
|
|
|
|
try {
|
|
return DB::transaction(function () use ($email, $code, $emailFingerprint): bool {
|
|
$user = User::query()
|
|
->where('email', $email)
|
|
->lockForUpdate()
|
|
->first();
|
|
|
|
$attempt = $user?->resetPasswordAttempts()
|
|
->where('codigo', $code)
|
|
->where('status', ResetPasswordAttempt::STATUS_PENDING)
|
|
->latest('id')
|
|
->lockForUpdate()
|
|
->first();
|
|
|
|
if ($attempt === null) {
|
|
Log::warning('Password reset code validation failed: no matching pending attempt.', [
|
|
'email_fingerprint' => $emailFingerprint,
|
|
]);
|
|
|
|
return false;
|
|
}
|
|
|
|
$attempt->update([
|
|
'status' => ResetPasswordAttempt::STATUS_VALIDATED,
|
|
]);
|
|
|
|
return true;
|
|
});
|
|
} catch (Throwable $exception) {
|
|
Log::error('Failed to validate password reset code.', [
|
|
'email_fingerprint' => $emailFingerprint,
|
|
'exception' => $exception,
|
|
]);
|
|
|
|
throw $exception;
|
|
}
|
|
}
|
|
|
|
public function resetPassword(string $email, string $code, string $password): bool
|
|
{
|
|
$emailFingerprint = $this->emailFingerprint($email);
|
|
|
|
try {
|
|
return DB::transaction(function () use ($email, $code, $password, $emailFingerprint): bool {
|
|
$user = User::query()
|
|
->where('email', $email)
|
|
->lockForUpdate()
|
|
->first();
|
|
|
|
$attempt = $user?->resetPasswordAttempts()
|
|
->where('codigo', $code)
|
|
->where('status', ResetPasswordAttempt::STATUS_VALIDATED)
|
|
->latest('id')
|
|
->lockForUpdate()
|
|
->first();
|
|
|
|
if ($user === null || $attempt === null) {
|
|
Log::warning('Password reset failed: no matching validated attempt.', [
|
|
'email_fingerprint' => $emailFingerprint,
|
|
]);
|
|
|
|
return false;
|
|
}
|
|
|
|
$user->password = $password;
|
|
$user->failed_login_attempts = 0;
|
|
$user->last_failed_login_at = null;
|
|
$user->locked_until = null;
|
|
$user->save();
|
|
$user->tokens()->delete();
|
|
|
|
$user->resetPasswordAttempts()
|
|
->whereKeyNot($attempt->getKey())
|
|
->whereIn('status', [
|
|
ResetPasswordAttempt::STATUS_PENDING,
|
|
ResetPasswordAttempt::STATUS_VALIDATED,
|
|
])
|
|
->update(['status' => ResetPasswordAttempt::STATUS_EXPIRED]);
|
|
|
|
$attempt->update([
|
|
'status' => ResetPasswordAttempt::STATUS_USED,
|
|
]);
|
|
|
|
return true;
|
|
});
|
|
} catch (Throwable $exception) {
|
|
Log::error('Failed to reset user password.', [
|
|
'email_fingerprint' => $emailFingerprint,
|
|
'exception' => $exception,
|
|
]);
|
|
|
|
throw $exception;
|
|
}
|
|
}
|
|
|
|
private function createAttemptForUser(
|
|
?User $user,
|
|
string $reason,
|
|
string $emailFingerprint,
|
|
string $userNotFoundMessage,
|
|
): ?int {
|
|
if ($user === null) {
|
|
Log::warning($userNotFoundMessage, [
|
|
'email_fingerprint' => $emailFingerprint,
|
|
]);
|
|
|
|
return null;
|
|
}
|
|
|
|
$user->resetPasswordAttempts()
|
|
->whereIn('status', [
|
|
ResetPasswordAttempt::STATUS_PENDING,
|
|
ResetPasswordAttempt::STATUS_VALIDATED,
|
|
])
|
|
->update(['status' => ResetPasswordAttempt::STATUS_EXPIRED]);
|
|
|
|
$attempt = $user->resetPasswordAttempts()->create([
|
|
'codigo' => $this->generateCode(),
|
|
'reason' => $reason,
|
|
'status' => ResetPasswordAttempt::STATUS_PENDING,
|
|
]);
|
|
|
|
return $attempt->getKey();
|
|
}
|
|
|
|
private function dispatchPasswordResetRequested(
|
|
?int $attemptId,
|
|
?string $tenantCode,
|
|
string $channel,
|
|
string $emailFingerprint,
|
|
): void {
|
|
if ($attemptId === null || $tenantCode === null) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
PasswordResetRequested::dispatch($attemptId, $tenantCode, $channel);
|
|
} catch (Throwable $exception) {
|
|
Log::error('Failed to dispatch password reset email.', [
|
|
'attempt_id' => $attemptId,
|
|
'tenant_code' => $tenantCode,
|
|
'channel' => $channel,
|
|
'email_fingerprint' => $emailFingerprint,
|
|
'exception' => $exception,
|
|
]);
|
|
|
|
throw $exception;
|
|
}
|
|
}
|
|
|
|
private function generateCode(): string
|
|
{
|
|
return str_pad((string) random_int(0, 9999), 4, '0', STR_PAD_LEFT);
|
|
}
|
|
|
|
private function emailFingerprint(string $email): string
|
|
{
|
|
return substr(hash('sha256', strtolower(trim($email))), 0, 12);
|
|
}
|
|
}
|