From cf2beb2ff5528d687f4f4c426c5c7e7e622aa466 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 31 Jul 2026 10:47:55 -0300 Subject: [PATCH] feat(auth): implement AdminApp login functionality with controller, request, routes, and tests --- .../Controllers/AdminAppLoginController.php | 42 ++++++ .../Auth/Requests/AdminAppLoginRequest.php | 36 +++++ .../Auth/Services/PasswordLoginService.php | 59 +++++++- app/Domains/Auth/routes/adminapp.php | 8 ++ app/Domains/Auth/routes/api.php | 2 + .../Auth/AdminAppLoginControllerTest.php | 130 ++++++++++++++++++ 6 files changed, 273 insertions(+), 4 deletions(-) create mode 100644 app/Domains/Auth/Controllers/AdminAppLoginController.php create mode 100644 app/Domains/Auth/Requests/AdminAppLoginRequest.php create mode 100644 app/Domains/Auth/routes/adminapp.php create mode 100644 tests/Feature/Auth/AdminAppLoginControllerTest.php diff --git a/app/Domains/Auth/Controllers/AdminAppLoginController.php b/app/Domains/Auth/Controllers/AdminAppLoginController.php new file mode 100644 index 0000000..8962325 --- /dev/null +++ b/app/Domains/Auth/Controllers/AdminAppLoginController.php @@ -0,0 +1,42 @@ +validated(); + $user = $this->passwordLoginService->authenticateAdminApp( + $credentials['email'], + $credentials['password'], + $request->ip(), + $request->userAgent(), + ); + + $expirationMinutes = (int) config('sanctum.expiration'); + $token = $user->createToken( + 'adminapp-token', + ['adminapp'], + now()->addMinutes($expirationMinutes), + )->plainTextToken; + + return response()->json([ + 'code' => 'auth.login_success', + 'message' => __('api.auth.login_success'), + 'token' => $token, + 'token_type' => 'Bearer', + 'user' => UserResource::make($user), + ]); + } +} diff --git a/app/Domains/Auth/Requests/AdminAppLoginRequest.php b/app/Domains/Auth/Requests/AdminAppLoginRequest.php new file mode 100644 index 0000000..9c89380 --- /dev/null +++ b/app/Domains/Auth/Requests/AdminAppLoginRequest.php @@ -0,0 +1,36 @@ +input('email'); + + if (is_string($email)) { + $this->merge([ + 'email' => Str::lower(trim($email)), + ]); + } + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'email' => ['required', 'string', 'email', 'max:255'], + 'password' => ['required', 'string'], + ]; + } +} diff --git a/app/Domains/Auth/Services/PasswordLoginService.php b/app/Domains/Auth/Services/PasswordLoginService.php index 3cc42a1..46dac23 100644 --- a/app/Domains/Auth/Services/PasswordLoginService.php +++ b/app/Domains/Auth/Services/PasswordLoginService.php @@ -5,6 +5,7 @@ namespace App\Domains\Auth\Services; use App\Domains\Auth\Exceptions\AccountLockedException; use App\Domains\Auth\Models\LoginAttempt; use App\Domains\Auth\Models\User; +use App\Domains\Authorization\Enums\RoleCode; use Carbon\CarbonImmutable; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; @@ -22,6 +23,48 @@ class PasswordLoginService string $tenantCode, ?string $ipAddress, ?string $userAgent, + ): User { + return $this->authenticateUser( + $email, + $password, + $tenantCode, + $ipAddress, + $userAgent, + ); + } + + /** + * Authenticate a tenant-bound AdminApp user without requiring the caller + * to know their tenant code beforehand. + * + * @throws AccountLockedException + * @throws ValidationException + */ + public function authenticateAdminApp( + string $email, + string $password, + ?string $ipAddress, + ?string $userAgent, + ): User { + return $this->authenticateUser( + $email, + $password, + null, + $ipAddress, + $userAgent, + RoleCode::AdminApp, + true, + ); + } + + private function authenticateUser( + string $email, + string $password, + ?string $tenantCode, + ?string $ipAddress, + ?string $userAgent, + RoleCode $requiredRole = RoleCode::User, + bool $requiresTenant = false, ): User { $normalizedEmail = mb_strtolower(trim($email)); $now = CarbonImmutable::now(); @@ -34,17 +77,25 @@ class PasswordLoginService $ipAddress, $userAgent, $now, + $requiredRole, + $requiresTenant, ): array { $user = User::query() ->where('email', $normalizedEmail) + ->where('rol_codigo', $requiredRole->value) + ->when( + $requiresTenant, + fn ($query) => $query->whereNotNull('tenant_codigo'), + ) ->lockForUpdate() ->first(); + $attemptTenantCode = $tenantCode ?? $user?->tenant_codigo; if ($user?->locked_until?->isFuture()) { $this->recordAttempt( $user, $normalizedEmail, - $tenantCode, + $attemptTenantCode, LoginAttempt::OUTCOME_ACCOUNT_LOCKED, $ipAddress, $userAgent, @@ -76,7 +127,7 @@ class PasswordLoginService $this->recordAttempt( $user, $normalizedEmail, - $tenantCode, + $attemptTenantCode, $outcome, $ipAddress, $userAgent, @@ -100,7 +151,7 @@ class PasswordLoginService $this->recordAttempt( $user, $normalizedEmail, - $tenantCode, + $attemptTenantCode, LoginAttempt::OUTCOME_SUCCESS, $ipAddress, $userAgent, @@ -150,7 +201,7 @@ class PasswordLoginService private function recordAttempt( ?User $user, string $normalizedEmail, - string $tenantCode, + ?string $tenantCode, string $outcome, ?string $ipAddress, ?string $userAgent, diff --git a/app/Domains/Auth/routes/adminapp.php b/app/Domains/Auth/routes/adminapp.php new file mode 100644 index 0000000..9b33dbb --- /dev/null +++ b/app/Domains/Auth/routes/adminapp.php @@ -0,0 +1,8 @@ +group(function (): void { + Route::post('login', AdminAppLoginController::class)->middleware('throttle:login'); +}); diff --git a/app/Domains/Auth/routes/api.php b/app/Domains/Auth/routes/api.php index d3236a7..43add70 100644 --- a/app/Domains/Auth/routes/api.php +++ b/app/Domains/Auth/routes/api.php @@ -23,3 +23,5 @@ Route::post('/auth/google/exchange', GoogleTokenExchangeController::class); Route::middleware('auth:sanctum')->post('/logout', LogoutController::class); Route::middleware('auth:sanctum')->get('/me', MeController::class); Route::middleware('auth:sanctum')->put('/me', UpdateProfileController::class); + +require __DIR__.'/adminapp.php'; diff --git a/tests/Feature/Auth/AdminAppLoginControllerTest.php b/tests/Feature/Auth/AdminAppLoginControllerTest.php new file mode 100644 index 0000000..a5d6070 --- /dev/null +++ b/tests/Feature/Auth/AdminAppLoginControllerTest.php @@ -0,0 +1,130 @@ +createTenant(); + $this->createRole(RoleCode::AdminApp); + $user = User::factory()->create([ + 'email' => 'admin@example.com', + 'password' => Hash::make('secret123'), + 'rol_codigo' => RoleCode::AdminApp->value, + 'tenant_codigo' => $tenant->codigo, + ]); + + $response = $this->postJson('/api/v1/adminapp/login', [ + 'email' => ' ADMIN@EXAMPLE.COM ', + 'password' => 'secret123', + ]); + + $response + ->assertOk() + ->assertJsonPath('code', 'auth.login_success') + ->assertJsonPath('token_type', 'Bearer') + ->assertJsonPath('user.id', $user->id) + ->assertJsonPath('user.rol_codigo', RoleCode::AdminApp->value) + ->assertJsonPath('user.tenant_codigo', $tenant->codigo); + + $this->assertNotEmpty($response->json('token')); + $this->assertSame(['adminapp'], $user->tokens()->sole()->abilities); + $this->assertDatabaseHas('login_attempts', [ + 'user_id' => $user->id, + 'tenant_codigo' => $tenant->codigo, + 'outcome' => LoginAttempt::OUTCOME_SUCCESS, + ]); + } + + public function test_it_rejects_non_adminapp_users_as_invalid_credentials(): void + { + $this->createRole(RoleCode::User); + $user = User::factory()->create([ + 'email' => 'customer@example.com', + 'password' => Hash::make('secret123'), + 'rol_codigo' => RoleCode::User->value, + ]); + + $this->postJson('/api/v1/adminapp/login', [ + 'email' => $user->email, + 'password' => 'secret123', + ])->assertUnprocessable()->assertJsonValidationErrors(['email']); + + $this->assertDatabaseCount('personal_access_tokens', 0); + $this->assertSame(0, $user->refresh()->failed_login_attempts); + } + + public function test_the_storefront_login_rejects_adminapp_users(): void + { + $tenant = $this->createTenant(); + $this->createRole(RoleCode::AdminApp); + $user = User::factory()->create([ + 'email' => 'admin@example.com', + 'password' => Hash::make('secret123'), + 'rol_codigo' => RoleCode::AdminApp->value, + 'tenant_codigo' => $tenant->codigo, + ]); + + $this->postJson('/api/login', [ + 'email' => $user->email, + 'password' => 'secret123', + 'tenant_codigo' => $tenant->codigo, + ])->assertUnprocessable()->assertJsonValidationErrors(['email']); + + $this->assertDatabaseCount('personal_access_tokens', 0); + } + + public function test_it_rejects_an_adminapp_user_without_a_tenant(): void + { + $this->createRole(RoleCode::AdminApp); + $user = User::factory()->create([ + 'email' => 'unbound@example.com', + 'password' => Hash::make('secret123'), + 'rol_codigo' => RoleCode::AdminApp->value, + 'tenant_codigo' => null, + ]); + + $this->postJson('/api/v1/adminapp/login', [ + 'email' => $user->email, + 'password' => 'secret123', + ])->assertUnprocessable()->assertJsonValidationErrors(['email']); + + $this->assertDatabaseCount('personal_access_tokens', 0); + } + + public function test_it_validates_required_fields(): void + { + $this->postJson('/api/v1/adminapp/login', []) + ->assertUnprocessable() + ->assertJsonValidationErrors(['email', 'password']); + } + + private function createRole(RoleCode $role): Role + { + return Role::query()->create([ + 'codigo' => $role->value, + 'nombre' => $role->value, + ]); + } + + private function createTenant(): Tenant + { + return Tenant::query()->create([ + 'codigo' => 'acme', + 'nombre' => 'Acme', + 'dominio' => 'acme.test', + ]); + } +}