feat(auth): implement login security features including account locking and login attempt tracking

This commit is contained in:
2026-07-29 10:47:26 -03:00
parent 45553dc514
commit 7c9ebc6d4d
18 changed files with 578 additions and 17 deletions

View File

@@ -2,35 +2,31 @@
namespace App\Domains\Auth\Controllers;
use App\Domains\Auth\Models\User;
use App\Domains\Auth\Requests\LoginUserRequest;
use App\Domains\Auth\Resources\UserResource;
use App\Domains\Auth\Services\PasswordLoginService;
use App\Domains\Cart\Services\GuestCartMergeService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Cookie;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class LoginController extends Controller
{
public function __construct(
private readonly GuestCartMergeService $guestCartMergeService,
private readonly PasswordLoginService $passwordLoginService,
) {}
/**
* @throws ValidationException
*/
public function __invoke(LoginUserRequest $request): JsonResponse
{
$credentials = $request->validated();
$user = User::query()->where('email', $credentials['email'])->first();
if (! $user || ! Hash::check($credentials['password'], $user->password)) {
throw ValidationException::withMessages([
'email' => __('api.auth.invalid_credentials'),
]);
}
$user = $this->passwordLoginService->authenticate(
$credentials['email'],
$credentials['password'],
$credentials['tenant_codigo'],
$request->ip(),
$request->userAgent(),
);
$expirationMinutes = (int) config('sanctum.expiration');
$token = $user->createToken(

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Domains\Auth\Exceptions;
use Carbon\CarbonImmutable;
use RuntimeException;
class AccountLockedException extends RuntimeException
{
public function __construct(
public readonly CarbonImmutable $lockedUntil,
) {
parent::__construct('The account is temporarily locked.');
}
public function retryAfterSeconds(): int
{
return max(1, (int) now()->diffInSeconds($this->lockedUntil, false));
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Domains\Auth\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'user_id',
'email_fingerprint',
'tenant_codigo',
'outcome',
'ip_address',
'user_agent',
])]
class LoginAttempt extends Model
{
public const OUTCOME_SUCCESS = 'success';
public const OUTCOME_INVALID_CREDENTIALS = 'invalid_credentials';
public const OUTCOME_ACCOUNT_LOCKED = 'account_locked';
public const UPDATED_AT = null;
/** @return BelongsTo<User, $this> */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
protected function casts(): array
{
return [
'user_id' => 'integer',
'created_at' => 'datetime',
];
}
}

View File

@@ -29,6 +29,12 @@ class User extends Authenticatable
return $this->hasMany(ResetPasswordAttempt::class);
}
/** @return HasMany<LoginAttempt, $this> */
public function loginAttempts(): HasMany
{
return $this->hasMany(LoginAttempt::class);
}
/**
* @return array<string, string>
*/
@@ -37,6 +43,9 @@ class User extends Authenticatable
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'failed_login_attempts' => 'integer',
'last_failed_login_at' => 'datetime',
'locked_until' => 'datetime',
];
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Domains\Auth\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
class LoginUserRequest extends FormRequest
{
@@ -11,6 +12,17 @@ class LoginUserRequest extends FormRequest
return true;
}
protected function prepareForValidation(): void
{
$email = $this->input('email');
if (is_string($email)) {
$this->merge([
'email' => Str::lower(trim($email)),
]);
}
}
/**
* @return array<string, mixed>
*/

View File

@@ -0,0 +1,173 @@
<?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),
]);
}
}

View File

@@ -138,6 +138,9 @@ class ResetPasswordAttemptService
}
$user->password = $password;
$user->failed_login_attempts = 0;
$user->last_failed_login_at = null;
$user->locked_until = null;
$user->save();
$user->tokens()->delete();

View File

@@ -12,7 +12,7 @@ use App\Domains\Auth\Controllers\ValidateResetPasswordAttemptController;
use Illuminate\Support\Facades\Route;
Route::post('/register', RegisterController::class);
Route::post('/login', LoginController::class);
Route::post('/login', LoginController::class)->middleware('throttle:login');
Route::post('/password/reset-attempts', CreateResetPasswordAttemptController::class)
->middleware('throttle:5,1');
Route::post('/password/reset-attempts/validate', ValidateResetPasswordAttemptController::class)