174 lines
5.5 KiB
PHP
174 lines
5.5 KiB
PHP
<?php
|
|
|
|
namespace App\Domains\Auth\Services;
|
|
|
|
use App\Domains\Auth\Exceptions\AccountLockedException;
|
|
use App\Domains\Auth\Models\LoginAttempt;
|
|
use App\Domains\Auth\Models\User;
|
|
use Carbon\CarbonImmutable;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Hash;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
class PasswordLoginService
|
|
{
|
|
/**
|
|
* @throws AccountLockedException
|
|
* @throws ValidationException
|
|
*/
|
|
public function authenticate(
|
|
string $email,
|
|
string $password,
|
|
string $tenantCode,
|
|
?string $ipAddress,
|
|
?string $userAgent,
|
|
): User {
|
|
$normalizedEmail = mb_strtolower(trim($email));
|
|
$now = CarbonImmutable::now();
|
|
|
|
/** @var array{outcome: string, user: User|null, locked_until: CarbonImmutable|null} $result */
|
|
$result = DB::transaction(function () use (
|
|
$normalizedEmail,
|
|
$password,
|
|
$tenantCode,
|
|
$ipAddress,
|
|
$userAgent,
|
|
$now,
|
|
): array {
|
|
$user = User::query()
|
|
->where('email', $normalizedEmail)
|
|
->lockForUpdate()
|
|
->first();
|
|
|
|
if ($user?->locked_until?->isFuture()) {
|
|
$this->recordAttempt(
|
|
$user,
|
|
$normalizedEmail,
|
|
$tenantCode,
|
|
LoginAttempt::OUTCOME_ACCOUNT_LOCKED,
|
|
$ipAddress,
|
|
$userAgent,
|
|
);
|
|
|
|
return [
|
|
'outcome' => LoginAttempt::OUTCOME_ACCOUNT_LOCKED,
|
|
'user' => $user,
|
|
'locked_until' => CarbonImmutable::instance($user->locked_until),
|
|
];
|
|
}
|
|
|
|
if ($user !== null && $user->locked_until !== null) {
|
|
$user->forceFill([
|
|
'failed_login_attempts' => 0,
|
|
'last_failed_login_at' => null,
|
|
'locked_until' => null,
|
|
])->save();
|
|
}
|
|
|
|
if ($user === null || ! Hash::check($password, $user->password)) {
|
|
if ($user !== null) {
|
|
$this->registerFailure($user, $now);
|
|
}
|
|
|
|
$outcome = $user?->locked_until?->isFuture()
|
|
? LoginAttempt::OUTCOME_ACCOUNT_LOCKED
|
|
: LoginAttempt::OUTCOME_INVALID_CREDENTIALS;
|
|
$this->recordAttempt(
|
|
$user,
|
|
$normalizedEmail,
|
|
$tenantCode,
|
|
$outcome,
|
|
$ipAddress,
|
|
$userAgent,
|
|
);
|
|
|
|
return [
|
|
'outcome' => $outcome,
|
|
'user' => $user,
|
|
'locked_until' => $user?->locked_until === null
|
|
? null
|
|
: CarbonImmutable::instance($user->locked_until),
|
|
];
|
|
}
|
|
|
|
$user->forceFill([
|
|
'failed_login_attempts' => 0,
|
|
'last_failed_login_at' => null,
|
|
'locked_until' => null,
|
|
])->save();
|
|
|
|
$this->recordAttempt(
|
|
$user,
|
|
$normalizedEmail,
|
|
$tenantCode,
|
|
LoginAttempt::OUTCOME_SUCCESS,
|
|
$ipAddress,
|
|
$userAgent,
|
|
);
|
|
|
|
return [
|
|
'outcome' => LoginAttempt::OUTCOME_SUCCESS,
|
|
'user' => $user,
|
|
'locked_until' => null,
|
|
];
|
|
});
|
|
|
|
if ($result['outcome'] === LoginAttempt::OUTCOME_ACCOUNT_LOCKED) {
|
|
throw new AccountLockedException($result['locked_until']);
|
|
}
|
|
|
|
if ($result['outcome'] === LoginAttempt::OUTCOME_INVALID_CREDENTIALS) {
|
|
throw ValidationException::withMessages([
|
|
'email' => __('api.auth.invalid_credentials'),
|
|
]);
|
|
}
|
|
|
|
return $result['user'];
|
|
}
|
|
|
|
private function registerFailure(User $user, CarbonImmutable $now): void
|
|
{
|
|
$windowMinutes = max(1, (int) config('login-security.attempt_window_minutes'));
|
|
$maxAttempts = max(1, (int) config('login-security.max_attempts'));
|
|
$lockMinutes = max(1, (int) config('login-security.lock_minutes'));
|
|
|
|
$withinAttemptWindow = $user->last_failed_login_at !== null
|
|
&& $user->last_failed_login_at->gte($now->subMinutes($windowMinutes));
|
|
$attempts = $withinAttemptWindow
|
|
? $user->failed_login_attempts + 1
|
|
: 1;
|
|
|
|
$user->forceFill([
|
|
'failed_login_attempts' => $attempts,
|
|
'last_failed_login_at' => $now,
|
|
'locked_until' => $attempts >= $maxAttempts
|
|
? $now->addMinutes($lockMinutes)
|
|
: null,
|
|
])->save();
|
|
}
|
|
|
|
private function recordAttempt(
|
|
?User $user,
|
|
string $normalizedEmail,
|
|
string $tenantCode,
|
|
string $outcome,
|
|
?string $ipAddress,
|
|
?string $userAgent,
|
|
): void {
|
|
LoginAttempt::query()->create([
|
|
'user_id' => $user?->getKey(),
|
|
'email_fingerprint' => hash_hmac(
|
|
'sha256',
|
|
$normalizedEmail,
|
|
(string) config('app.key'),
|
|
),
|
|
'tenant_codigo' => $tenantCode,
|
|
'outcome' => $outcome,
|
|
'ip_address' => $ipAddress,
|
|
'user_agent' => $userAgent === null
|
|
? null
|
|
: mb_substr($userAgent, 0, 1024),
|
|
]);
|
|
}
|
|
}
|