feat(auth): expire stale password reset codes

This commit is contained in:
2026-08-25 14:10:33 -03:00
parent 99808c1053
commit 733064c0dc
4 changed files with 49 additions and 6 deletions

View File

@@ -22,10 +22,18 @@ class ValidateResetPasswordAttemptController extends Controller
{ {
$data = $request->validated(); $data = $request->validated();
if (! $this->resetPasswordAttemptService->validateCode( $result = $this->resetPasswordAttemptService->validateCode(
$data['email'], $data['email'],
$data['codigo'], $data['codigo'],
)) { );
if ($result === ResetPasswordAttemptService::CODE_EXPIRED) {
throw ValidationException::withMessages([
'codigo' => __('api.auth.reset_code_expired'),
]);
}
if ($result !== ResetPasswordAttemptService::CODE_VALID) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'codigo' => __('api.auth.reset_code_invalid'), 'codigo' => __('api.auth.reset_code_invalid'),
]); ]);

View File

@@ -12,6 +12,12 @@ use Throwable;
class ResetPasswordAttemptService class ResetPasswordAttemptService
{ {
public const CODE_VALID = 'valid';
public const CODE_INVALID = 'invalid';
public const CODE_EXPIRED = 'expired';
public function createForEmail( public function createForEmail(
string $email, string $email,
string $tenantCode, string $tenantCode,
@@ -146,12 +152,12 @@ class ResetPasswordAttemptService
); );
} }
public function validateCode(string $email, string $code): bool public function validateCode(string $email, string $code): string
{ {
$emailFingerprint = $this->emailFingerprint($email); $emailFingerprint = $this->emailFingerprint($email);
try { try {
return DB::transaction(function () use ($email, $code, $emailFingerprint): bool { return DB::transaction(function () use ($email, $code, $emailFingerprint): string {
$user = User::query() $user = User::query()
->where('email', $email) ->where('email', $email)
->lockForUpdate() ->lockForUpdate()
@@ -169,14 +175,27 @@ class ResetPasswordAttemptService
'email_fingerprint' => $emailFingerprint, 'email_fingerprint' => $emailFingerprint,
]); ]);
return false; return self::CODE_INVALID;
}
if ($attempt->expires_at?->isPast()) {
$attempt->update([
'status' => ResetPasswordAttempt::STATUS_EXPIRED,
]);
Log::info('Password reset code validation failed: attempt expired.', [
'email_fingerprint' => $emailFingerprint,
'attempt_id' => $attempt->getKey(),
]);
return self::CODE_EXPIRED;
} }
$attempt->update([ $attempt->update([
'status' => ResetPasswordAttempt::STATUS_VALIDATED, 'status' => ResetPasswordAttempt::STATUS_VALIDATED,
]); ]);
return true; return self::CODE_VALID;
}); });
} catch (Throwable $exception) { } catch (Throwable $exception) {
Log::error('Failed to validate password reset code.', [ Log::error('Failed to validate password reset code.', [
@@ -214,6 +233,19 @@ class ResetPasswordAttemptService
return false; return false;
} }
if ($attempt->expires_at?->isPast()) {
$attempt->update([
'status' => ResetPasswordAttempt::STATUS_EXPIRED,
]);
Log::info('Password reset failed: attempt expired.', [
'email_fingerprint' => $emailFingerprint,
'attempt_id' => $attempt->getKey(),
]);
return false;
}
$user->password = $password; $user->password = $password;
$user->failed_login_attempts = 0; $user->failed_login_attempts = 0;
$user->last_failed_login_at = null; $user->last_failed_login_at = null;
@@ -270,6 +302,7 @@ class ResetPasswordAttemptService
'codigo' => $this->generateCode(), 'codigo' => $this->generateCode(),
'reason' => $reason, 'reason' => $reason,
'status' => ResetPasswordAttempt::STATUS_PENDING, 'status' => ResetPasswordAttempt::STATUS_PENDING,
'expires_at' => now()->addMinutes((int) config('auth.passwords.users.expire')),
]); ]);
return $attempt->getKey(); return $attempt->getKey();

View File

@@ -12,6 +12,7 @@ return [
'password_reset_invalid' => 'The password recovery request is invalid or has already been used.', 'password_reset_invalid' => 'The password recovery request is invalid or has already been used.',
'password_updated' => 'Password updated successfully.', 'password_updated' => 'Password updated successfully.',
'reset_code_invalid' => 'The code you entered is invalid.', 'reset_code_invalid' => 'The code you entered is invalid.',
'reset_code_expired' => 'The password recovery code expired. Request a new one.',
'reset_code_valid' => 'Code validated successfully.', 'reset_code_valid' => 'Code validated successfully.',
'invalid_tenant_or_return_url' => 'The tenant or return URL is invalid.', 'invalid_tenant_or_return_url' => 'The tenant or return URL is invalid.',
'request_expired' => 'The authentication request expired. Please try again.', 'request_expired' => 'The authentication request expired. Please try again.',

View File

@@ -12,6 +12,7 @@ return [
'password_reset_invalid' => 'La solicitud de recuperación es inválida o ya fue utilizada.', 'password_reset_invalid' => 'La solicitud de recuperación es inválida o ya fue utilizada.',
'password_updated' => 'Contraseña modificada correctamente.', 'password_updated' => 'Contraseña modificada correctamente.',
'reset_code_invalid' => 'El código ingresado es inválido.', 'reset_code_invalid' => 'El código ingresado es inválido.',
'reset_code_expired' => 'El código de recuperación expiró. Solicitá uno nuevo.',
'reset_code_valid' => 'Código validado correctamente.', 'reset_code_valid' => 'Código validado correctamente.',
'invalid_tenant_or_return_url' => 'El tenant o la URL de retorno no son válidos.', 'invalid_tenant_or_return_url' => 'El tenant o la URL de retorno no son válidos.',
'request_expired' => 'La solicitud de autenticación expiró. Intenta nuevamente.', 'request_expired' => 'La solicitud de autenticación expiró. Intenta nuevamente.',