feat(auth): implement AdminApp login functionality with controller, request, routes, and tests
This commit is contained in:
42
app/Domains/Auth/Controllers/AdminAppLoginController.php
Normal file
42
app/Domains/Auth/Controllers/AdminAppLoginController.php
Normal 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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
36
app/Domains/Auth/Requests/AdminAppLoginRequest.php
Normal file
36
app/Domains/Auth/Requests/AdminAppLoginRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
8
app/Domains/Auth/routes/adminapp.php
Normal file
8
app/Domains/Auth/routes/adminapp.php
Normal 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');
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
130
tests/Feature/Auth/AdminAppLoginControllerTest.php
Normal file
130
tests/Feature/Auth/AdminAppLoginControllerTest.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user