feat(auth): implement login security features including account locking and login attempt tracking
This commit is contained in:
@@ -25,6 +25,11 @@ GOOGLE_REDIRECT_URI=http://localhost/auth/google/callback
|
|||||||
|
|
||||||
|
|
||||||
BCRYPT_ROUNDS=12
|
BCRYPT_ROUNDS=12
|
||||||
|
AUTH_MAX_LOGIN_ATTEMPTS=5
|
||||||
|
AUTH_LOGIN_ATTEMPT_WINDOW_MINUTES=30
|
||||||
|
AUTH_LOGIN_LOCK_MINUTES=15
|
||||||
|
AUTH_LOGIN_RATE_LIMIT_PER_MINUTE=10
|
||||||
|
AUTH_LOGIN_IP_RATE_LIMIT_PER_MINUTE=30
|
||||||
|
|
||||||
LOG_CHANNEL=stack
|
LOG_CHANNEL=stack
|
||||||
LOG_STACK=single
|
LOG_STACK=single
|
||||||
|
|||||||
@@ -2,35 +2,31 @@
|
|||||||
|
|
||||||
namespace App\Domains\Auth\Controllers;
|
namespace App\Domains\Auth\Controllers;
|
||||||
|
|
||||||
use App\Domains\Auth\Models\User;
|
|
||||||
use App\Domains\Auth\Requests\LoginUserRequest;
|
use App\Domains\Auth\Requests\LoginUserRequest;
|
||||||
use App\Domains\Auth\Resources\UserResource;
|
use App\Domains\Auth\Resources\UserResource;
|
||||||
|
use App\Domains\Auth\Services\PasswordLoginService;
|
||||||
use App\Domains\Cart\Services\GuestCartMergeService;
|
use App\Domains\Cart\Services\GuestCartMergeService;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Support\Facades\Cookie;
|
use Illuminate\Support\Facades\Cookie;
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Illuminate\Validation\ValidationException;
|
|
||||||
|
|
||||||
class LoginController extends Controller
|
class LoginController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly GuestCartMergeService $guestCartMergeService,
|
private readonly GuestCartMergeService $guestCartMergeService,
|
||||||
|
private readonly PasswordLoginService $passwordLoginService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws ValidationException
|
|
||||||
*/
|
|
||||||
public function __invoke(LoginUserRequest $request): JsonResponse
|
public function __invoke(LoginUserRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
$credentials = $request->validated();
|
$credentials = $request->validated();
|
||||||
$user = User::query()->where('email', $credentials['email'])->first();
|
$user = $this->passwordLoginService->authenticate(
|
||||||
|
$credentials['email'],
|
||||||
if (! $user || ! Hash::check($credentials['password'], $user->password)) {
|
$credentials['password'],
|
||||||
throw ValidationException::withMessages([
|
$credentials['tenant_codigo'],
|
||||||
'email' => __('api.auth.invalid_credentials'),
|
$request->ip(),
|
||||||
]);
|
$request->userAgent(),
|
||||||
}
|
);
|
||||||
|
|
||||||
$expirationMinutes = (int) config('sanctum.expiration');
|
$expirationMinutes = (int) config('sanctum.expiration');
|
||||||
$token = $user->createToken(
|
$token = $user->createToken(
|
||||||
|
|||||||
20
app/Domains/Auth/Exceptions/AccountLockedException.php
Normal file
20
app/Domains/Auth/Exceptions/AccountLockedException.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/Domains/Auth/Models/LoginAttempt.php
Normal file
40
app/Domains/Auth/Models/LoginAttempt.php
Normal 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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,12 @@ class User extends Authenticatable
|
|||||||
return $this->hasMany(ResetPasswordAttempt::class);
|
return $this->hasMany(ResetPasswordAttempt::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<LoginAttempt, $this> */
|
||||||
|
public function loginAttempts(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(LoginAttempt::class);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
@@ -37,6 +43,9 @@ class User extends Authenticatable
|
|||||||
return [
|
return [
|
||||||
'email_verified_at' => 'datetime',
|
'email_verified_at' => 'datetime',
|
||||||
'password' => 'hashed',
|
'password' => 'hashed',
|
||||||
|
'failed_login_attempts' => 'integer',
|
||||||
|
'last_failed_login_at' => 'datetime',
|
||||||
|
'locked_until' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Domains\Auth\Requests;
|
namespace App\Domains\Auth\Requests;
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class LoginUserRequest extends FormRequest
|
class LoginUserRequest extends FormRequest
|
||||||
{
|
{
|
||||||
@@ -11,6 +12,17 @@ class LoginUserRequest extends FormRequest
|
|||||||
return true;
|
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>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
|
|||||||
173
app/Domains/Auth/Services/PasswordLoginService.php
Normal file
173
app/Domains/Auth/Services/PasswordLoginService.php
Normal 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),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -138,6 +138,9 @@ class ResetPasswordAttemptService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$user->password = $password;
|
$user->password = $password;
|
||||||
|
$user->failed_login_attempts = 0;
|
||||||
|
$user->last_failed_login_at = null;
|
||||||
|
$user->locked_until = null;
|
||||||
$user->save();
|
$user->save();
|
||||||
$user->tokens()->delete();
|
$user->tokens()->delete();
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use App\Domains\Auth\Controllers\ValidateResetPasswordAttemptController;
|
|||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::post('/register', RegisterController::class);
|
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)
|
Route::post('/password/reset-attempts', CreateResetPasswordAttemptController::class)
|
||||||
->middleware('throttle:5,1');
|
->middleware('throttle:5,1');
|
||||||
Route::post('/password/reset-attempts/validate', ValidateResetPasswordAttemptController::class)
|
Route::post('/password/reset-attempts/validate', ValidateResetPasswordAttemptController::class)
|
||||||
|
|||||||
@@ -11,8 +11,11 @@ use App\Domains\Notification\Listeners\SendTicketsAvailableEmail;
|
|||||||
use App\Domains\Notification\Listeners\SendWelcomeEmail;
|
use App\Domains\Notification\Listeners\SendWelcomeEmail;
|
||||||
use App\Domains\Purchase\Events\PurchasePaid;
|
use App\Domains\Purchase\Events\PurchasePaid;
|
||||||
use App\Domains\Ticket\Listeners\GenerateTicketsForPaidPurchase;
|
use App\Domains\Ticket\Listeners\GenerateTicketsForPaidPurchase;
|
||||||
|
use Illuminate\Cache\RateLimiting\Limit;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Event;
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
@@ -36,6 +39,25 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
Event::listen(UserRegistered::class, SendWelcomeEmail::class);
|
Event::listen(UserRegistered::class, SendWelcomeEmail::class);
|
||||||
Event::listen(PasswordResetRequested::class, SendPasswordResetEmail::class);
|
Event::listen(PasswordResetRequested::class, SendPasswordResetEmail::class);
|
||||||
|
|
||||||
|
RateLimiter::for('login', function (Request $request): array {
|
||||||
|
$normalizedEmail = mb_strtolower(trim((string) $request->input('email')));
|
||||||
|
$emailFingerprint = hash_hmac(
|
||||||
|
'sha256',
|
||||||
|
$normalizedEmail,
|
||||||
|
(string) config('app.key'),
|
||||||
|
);
|
||||||
|
$ipAddress = $request->ip() ?? 'unknown';
|
||||||
|
|
||||||
|
return [
|
||||||
|
Limit::perMinute(
|
||||||
|
max(1, (int) config('login-security.rate_limit_per_minute'))
|
||||||
|
)->by("login:identity:{$emailFingerprint}:{$ipAddress}"),
|
||||||
|
Limit::perMinute(
|
||||||
|
max(1, (int) config('login-security.ip_rate_limit_per_minute'))
|
||||||
|
)->by("login:ip:{$ipAddress}"),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
Builder::macro('paginateFromRequest', function (int $defaultPerPage = 15, int $maxPerPage = 100, ?int $page = null) {
|
Builder::macro('paginateFromRequest', function (int $defaultPerPage = 15, int $maxPerPage = 100, ?int $page = null) {
|
||||||
/** @var Builder $this */
|
/** @var Builder $this */
|
||||||
$perPage = (int) request()->query('per_page', $defaultPerPage);
|
$perPage = (int) request()->query('per_page', $defaultPerPage);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Auth\Exceptions\AccountLockedException;
|
||||||
use App\Domains\Ticket\Exceptions\TicketNotAvailableException;
|
use App\Domains\Ticket\Exceptions\TicketNotAvailableException;
|
||||||
use App\Http\Middleware\SetApiLocale;
|
use App\Http\Middleware\SetApiLocale;
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
@@ -39,6 +40,22 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
'message' => __('api.auth.unauthenticated'),
|
'message' => __('api.auth.unauthenticated'),
|
||||||
], 401);
|
], 401);
|
||||||
});
|
});
|
||||||
|
$exceptions->render(function (AccountLockedException $exception, Request $request) {
|
||||||
|
if (! $request->is('api/*')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$retryAfter = $exception->retryAfterSeconds();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => 'auth.account_locked',
|
||||||
|
'message' => __('api.auth.account_locked'),
|
||||||
|
'retry_after' => $retryAfter,
|
||||||
|
'locked_until' => $exception->lockedUntil->toIso8601String(),
|
||||||
|
], 429, [
|
||||||
|
'Retry-After' => (string) $retryAfter,
|
||||||
|
]);
|
||||||
|
});
|
||||||
$exceptions->render(function (AuthorizationException $exception, Request $request) {
|
$exceptions->render(function (AuthorizationException $exception, Request $request) {
|
||||||
if (! $request->is('api/*')) {
|
if (! $request->is('api/*')) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
9
config/login-security.php
Normal file
9
config/login-security.php
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'max_attempts' => (int) env('AUTH_MAX_LOGIN_ATTEMPTS', 3),
|
||||||
|
'attempt_window_minutes' => (int) env('AUTH_LOGIN_ATTEMPT_WINDOW_MINUTES', 30),
|
||||||
|
'lock_minutes' => (int) env('AUTH_LOGIN_LOCK_MINUTES', 15),
|
||||||
|
'rate_limit_per_minute' => (int) env('AUTH_LOGIN_RATE_LIMIT_PER_MINUTE', 10),
|
||||||
|
'ip_rate_limit_per_minute' => (int) env('AUTH_LOGIN_IP_RATE_LIMIT_PER_MINUTE', 30),
|
||||||
|
];
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table): void {
|
||||||
|
$table->unsignedSmallInteger('failed_login_attempts')->default(0);
|
||||||
|
$table->timestamp('last_failed_login_at')->nullable();
|
||||||
|
$table->timestamp('locked_until')->nullable()->index();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table): void {
|
||||||
|
$table->dropIndex(['locked_until']);
|
||||||
|
$table->dropColumn([
|
||||||
|
'failed_login_attempts',
|
||||||
|
'last_failed_login_at',
|
||||||
|
'locked_until',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('login_attempts', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')
|
||||||
|
->nullable()
|
||||||
|
->constrained()
|
||||||
|
->cascadeOnUpdate()
|
||||||
|
->nullOnDelete();
|
||||||
|
$table->string('email_fingerprint', 64);
|
||||||
|
$table->string('tenant_codigo')->nullable();
|
||||||
|
$table->string('outcome', 32);
|
||||||
|
$table->string('ip_address', 45)->nullable();
|
||||||
|
$table->string('user_agent', 1024)->nullable();
|
||||||
|
$table->timestamp('created_at')->useCurrent();
|
||||||
|
|
||||||
|
$table->index(['email_fingerprint', 'created_at']);
|
||||||
|
$table->index(['user_id', 'created_at']);
|
||||||
|
$table->index(['ip_address', 'created_at']);
|
||||||
|
$table->index(['outcome', 'created_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('login_attempts');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -4,6 +4,7 @@ return [
|
|||||||
'auth' => [
|
'auth' => [
|
||||||
'unauthenticated' => 'Unauthenticated.',
|
'unauthenticated' => 'Unauthenticated.',
|
||||||
'invalid_credentials' => 'Email or password is incorrect.',
|
'invalid_credentials' => 'Email or password is incorrect.',
|
||||||
|
'account_locked' => 'The account is temporarily locked. Please try again later.',
|
||||||
'login_success' => 'Signed in successfully.',
|
'login_success' => 'Signed in successfully.',
|
||||||
'logout_success' => 'Signed out successfully.',
|
'logout_success' => 'Signed out successfully.',
|
||||||
'register_success' => 'User registered successfully.',
|
'register_success' => 'User registered successfully.',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ return [
|
|||||||
'auth' => [
|
'auth' => [
|
||||||
'unauthenticated' => 'No autenticado.',
|
'unauthenticated' => 'No autenticado.',
|
||||||
'invalid_credentials' => 'Email o contraseña incorrectos.',
|
'invalid_credentials' => 'Email o contraseña incorrectos.',
|
||||||
|
'account_locked' => 'La cuenta está bloqueada temporalmente. Intenta nuevamente más tarde.',
|
||||||
'login_success' => 'Sesión iniciada correctamente.',
|
'login_success' => 'Sesión iniciada correctamente.',
|
||||||
'logout_success' => 'Sesión cerrada correctamente.',
|
'logout_success' => 'Sesión cerrada correctamente.',
|
||||||
'register_success' => 'Usuario registrado correctamente.',
|
'register_success' => 'Usuario registrado correctamente.',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace Tests\Feature\Auth;
|
|||||||
|
|
||||||
use App\Domains\Attachable\Enums\AttachmentType;
|
use App\Domains\Attachable\Enums\AttachmentType;
|
||||||
use App\Domains\Attachable\Models\Attachment;
|
use App\Domains\Attachable\Models\Attachment;
|
||||||
|
use App\Domains\Auth\Models\LoginAttempt;
|
||||||
use App\Domains\Auth\Models\User;
|
use App\Domains\Auth\Models\User;
|
||||||
use App\Domains\Cart\Models\Cart;
|
use App\Domains\Cart\Models\Cart;
|
||||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||||
@@ -102,11 +103,190 @@ class LoginControllerTest extends TestCase
|
|||||||
'password' => Hash::make('secret123'),
|
'password' => Hash::make('secret123'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->postJson('/api/login', [
|
$this->withHeader('User-Agent', 'Shopit login test')
|
||||||
'email' => 'grace@example.com',
|
->postJson('/api/login', [
|
||||||
|
'email' => 'grace@example.com',
|
||||||
|
'password' => 'wrong-password',
|
||||||
|
'tenant_codigo' => $tenant->codigo,
|
||||||
|
])->assertUnprocessable()->assertJsonValidationErrors(['email']);
|
||||||
|
|
||||||
|
$user = User::query()->where('email', 'grace@example.com')->sole();
|
||||||
|
|
||||||
|
$this->assertSame(1, $user->failed_login_attempts);
|
||||||
|
$this->assertNotNull($user->last_failed_login_at);
|
||||||
|
$this->assertDatabaseHas('login_attempts', [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'tenant_codigo' => $tenant->codigo,
|
||||||
|
'outcome' => LoginAttempt::OUTCOME_INVALID_CREDENTIALS,
|
||||||
|
'ip_address' => '127.0.0.1',
|
||||||
|
'user_agent' => 'Shopit login test',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_locks_an_account_after_the_maximum_failed_attempts(): void
|
||||||
|
{
|
||||||
|
config([
|
||||||
|
'login-security.max_attempts' => 3,
|
||||||
|
'login-security.lock_minutes' => 15,
|
||||||
|
'login-security.rate_limit_per_minute' => 100,
|
||||||
|
'login-security.ip_rate_limit_per_minute' => 100,
|
||||||
|
]);
|
||||||
|
$this->travelTo(now()->startOfSecond());
|
||||||
|
|
||||||
|
$tenant = $this->createTenant('locked');
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'email' => 'locked@example.com',
|
||||||
|
'password' => Hash::make('secret123'),
|
||||||
|
]);
|
||||||
|
$payload = [
|
||||||
|
'email' => $user->email,
|
||||||
'password' => 'wrong-password',
|
'password' => 'wrong-password',
|
||||||
'tenant_codigo' => $tenant->codigo,
|
'tenant_codigo' => $tenant->codigo,
|
||||||
])->assertUnprocessable()->assertJsonValidationErrors(['email']);
|
];
|
||||||
|
|
||||||
|
for ($attempt = 0; $attempt < 2; $attempt++) {
|
||||||
|
$this->postJson('/api/login', $payload)->assertUnprocessable();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->postJson('/api/login', $payload)
|
||||||
|
->assertTooManyRequests()
|
||||||
|
->assertJsonPath('code', 'auth.account_locked');
|
||||||
|
|
||||||
|
$user->refresh();
|
||||||
|
$this->assertSame(3, $user->failed_login_attempts);
|
||||||
|
$this->assertTrue($user->locked_until->equalTo(now()->addMinutes(15)));
|
||||||
|
|
||||||
|
$this->postJson('/api/login', [
|
||||||
|
...$payload,
|
||||||
|
'password' => 'secret123',
|
||||||
|
])
|
||||||
|
->assertTooManyRequests()
|
||||||
|
->assertHeader('Retry-After', '900')
|
||||||
|
->assertJsonPath('code', 'auth.account_locked')
|
||||||
|
->assertJsonPath('retry_after', 900);
|
||||||
|
|
||||||
|
$this->assertDatabaseCount('login_attempts', 4);
|
||||||
|
$this->assertDatabaseHas('login_attempts', [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'outcome' => LoginAttempt::OUTCOME_ACCOUNT_LOCKED,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_a_successful_login_resets_failures_and_is_audited(): void
|
||||||
|
{
|
||||||
|
$tenant = $this->createTenant('successful');
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'email' => 'successful@example.com',
|
||||||
|
'password' => Hash::make('secret123'),
|
||||||
|
]);
|
||||||
|
$user->forceFill([
|
||||||
|
'failed_login_attempts' => 2,
|
||||||
|
'last_failed_login_at' => now()->subMinute(),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$this->postJson('/api/login', [
|
||||||
|
'email' => $user->email,
|
||||||
|
'password' => 'secret123',
|
||||||
|
'tenant_codigo' => $tenant->codigo,
|
||||||
|
])->assertOk();
|
||||||
|
|
||||||
|
$user->refresh();
|
||||||
|
$this->assertSame(0, $user->failed_login_attempts);
|
||||||
|
$this->assertNull($user->last_failed_login_at);
|
||||||
|
$this->assertNull($user->locked_until);
|
||||||
|
$this->assertDatabaseHas('login_attempts', [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'outcome' => LoginAttempt::OUTCOME_SUCCESS,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_an_expired_lock_allows_login_again(): void
|
||||||
|
{
|
||||||
|
$tenant = $this->createTenant('expired-lock');
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'email' => 'expired@example.com',
|
||||||
|
'password' => Hash::make('secret123'),
|
||||||
|
]);
|
||||||
|
$user->forceFill([
|
||||||
|
'failed_login_attempts' => 5,
|
||||||
|
'last_failed_login_at' => now()->subMinutes(20),
|
||||||
|
'locked_until' => now()->subMinute(),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$this->postJson('/api/login', [
|
||||||
|
'email' => $user->email,
|
||||||
|
'password' => 'secret123',
|
||||||
|
'tenant_codigo' => $tenant->codigo,
|
||||||
|
])->assertOk();
|
||||||
|
|
||||||
|
$user->refresh();
|
||||||
|
$this->assertSame(0, $user->failed_login_attempts);
|
||||||
|
$this->assertNull($user->locked_until);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_failures_outside_the_attempt_window_start_a_new_count(): void
|
||||||
|
{
|
||||||
|
config(['login-security.attempt_window_minutes' => 30]);
|
||||||
|
|
||||||
|
$tenant = $this->createTenant('attempt-window');
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'email' => 'window@example.com',
|
||||||
|
'password' => Hash::make('secret123'),
|
||||||
|
]);
|
||||||
|
$user->forceFill([
|
||||||
|
'failed_login_attempts' => 4,
|
||||||
|
'last_failed_login_at' => now()->subMinutes(31),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$this->postJson('/api/login', [
|
||||||
|
'email' => $user->email,
|
||||||
|
'password' => 'wrong-password',
|
||||||
|
'tenant_codigo' => $tenant->codigo,
|
||||||
|
])->assertUnprocessable();
|
||||||
|
|
||||||
|
$this->assertSame(1, $user->refresh()->failed_login_attempts);
|
||||||
|
$this->assertNull($user->locked_until);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_unknown_emails_are_audited_without_storing_the_email(): void
|
||||||
|
{
|
||||||
|
$tenant = $this->createTenant('unknown');
|
||||||
|
|
||||||
|
$this->postJson('/api/login', [
|
||||||
|
'email' => 'missing@example.com',
|
||||||
|
'password' => 'wrong-password',
|
||||||
|
'tenant_codigo' => $tenant->codigo,
|
||||||
|
])->assertUnprocessable();
|
||||||
|
|
||||||
|
$attempt = LoginAttempt::query()->sole();
|
||||||
|
$this->assertNull($attempt->user_id);
|
||||||
|
$this->assertSame(LoginAttempt::OUTCOME_INVALID_CREDENTIALS, $attempt->outcome);
|
||||||
|
$this->assertSame(64, strlen($attempt->email_fingerprint));
|
||||||
|
$this->assertStringNotContainsString('missing@example.com', $attempt->email_fingerprint);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_login_is_rate_limited_by_email_and_ip(): void
|
||||||
|
{
|
||||||
|
config([
|
||||||
|
'login-security.max_attempts' => 100,
|
||||||
|
'login-security.rate_limit_per_minute' => 2,
|
||||||
|
'login-security.ip_rate_limit_per_minute' => 100,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant = $this->createTenant('rate-limit');
|
||||||
|
$payload = [
|
||||||
|
'email' => 'rate-limited@example.com',
|
||||||
|
'password' => 'wrong-password',
|
||||||
|
'tenant_codigo' => $tenant->codigo,
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->postJson('/api/login', $payload)->assertUnprocessable();
|
||||||
|
$this->postJson('/api/login', $payload)->assertUnprocessable();
|
||||||
|
$this->postJson('/api/login', $payload)
|
||||||
|
->assertTooManyRequests()
|
||||||
|
->assertHeader('Retry-After');
|
||||||
|
|
||||||
|
$this->assertDatabaseCount('login_attempts', 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_validates_required_login_fields(): void
|
public function test_it_validates_required_login_fields(): void
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ class ResetPasswordControllerTest extends TestCase
|
|||||||
'email' => 'ada@example.com',
|
'email' => 'ada@example.com',
|
||||||
'password' => 'OldSecret!123',
|
'password' => 'OldSecret!123',
|
||||||
]);
|
]);
|
||||||
|
$user->forceFill([
|
||||||
|
'failed_login_attempts' => 5,
|
||||||
|
'last_failed_login_at' => now(),
|
||||||
|
'locked_until' => now()->addMinutes(15),
|
||||||
|
])->save();
|
||||||
$user->createToken('existing-session');
|
$user->createToken('existing-session');
|
||||||
$attempt = $user->resetPasswordAttempts()->create([
|
$attempt = $user->resetPasswordAttempts()->create([
|
||||||
'codigo' => '0123',
|
'codigo' => '0123',
|
||||||
@@ -38,6 +43,9 @@ class ResetPasswordControllerTest extends TestCase
|
|||||||
$this->assertFalse(Hash::check('OldSecret!123', $user->password));
|
$this->assertFalse(Hash::check('OldSecret!123', $user->password));
|
||||||
$this->assertSame(ResetPasswordAttempt::STATUS_USED, $attempt->fresh()->status);
|
$this->assertSame(ResetPasswordAttempt::STATUS_USED, $attempt->fresh()->status);
|
||||||
$this->assertDatabaseCount('personal_access_tokens', 0);
|
$this->assertDatabaseCount('personal_access_tokens', 0);
|
||||||
|
$this->assertSame(0, $user->failed_login_attempts);
|
||||||
|
$this->assertNull($user->last_failed_login_at);
|
||||||
|
$this->assertNull($user->locked_until);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_rejects_a_pending_expired_or_used_attempt(): void
|
public function test_it_rejects_a_pending_expired_or_used_attempt(): void
|
||||||
|
|||||||
Reference in New Issue
Block a user