feat(auth): implement AdminApp login functionality with controller, request, routes, and tests

This commit is contained in:
2026-07-31 10:47:55 -03:00
parent 484fa204b3
commit cf2beb2ff5
6 changed files with 273 additions and 4 deletions

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Domains\Auth\Controllers;
use App\Domains\Auth\Requests\AdminAppLoginRequest;
use App\Domains\Auth\Resources\UserResource;
use App\Domains\Auth\Services\PasswordLoginService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
class AdminAppLoginController extends Controller
{
public function __construct(
private readonly PasswordLoginService $passwordLoginService,
) {}
public function __invoke(AdminAppLoginRequest $request): JsonResponse
{
$credentials = $request->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),
]);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Domains\Auth\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
class AdminAppLoginRequest extends FormRequest
{
public function authorize(): bool
{
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>
*/
public function rules(): array
{
return [
'email' => ['required', 'string', 'email', 'max:255'],
'password' => ['required', 'string'],
];
}
}

View File

@@ -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,

View File

@@ -0,0 +1,8 @@
<?php
use App\Domains\Auth\Controllers\AdminAppLoginController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp')->group(function (): void {
Route::post('login', AdminAppLoginController::class)->middleware('throttle:login');
});

View File

@@ -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';

View File

@@ -0,0 +1,130 @@
<?php
namespace Tests\Feature\Auth;
use App\Domains\Auth\Models\LoginAttempt;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Authorization\Models\Role;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
class AdminAppLoginControllerTest extends TestCase
{
use RefreshDatabase;
public function test_it_logs_in_a_tenant_bound_adminapp_user(): 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,
]);
$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',
]);
}
}