From 9a5a8e85b9bd1e9ad2c0ba366b32ac4faba09034 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 2 Jul 2026 14:55:47 -0300 Subject: [PATCH] feat: implement authentication functionality with login and logout endpoints --- ShopIt_API_Postman_Collection.json | 154 +++++++++++++++--- .../Auth/Controllers/LoginController.php | 38 +++++ .../Auth/Controllers/LogoutController.php | 29 ++++ app/Domains/Auth/Models/User.php | 3 +- .../Auth/Requests/LoginUserRequest.php | 24 +++ app/Domains/Auth/routes/api.php | 4 + composer.json | 2 +- composer.lock | 2 +- config/sanctum.php | 2 +- ...54_create_personal_access_tokens_table.php | 37 +++++ tests/Feature/Auth/LoginControllerTest.php | 87 ++++++++++ 11 files changed, 351 insertions(+), 31 deletions(-) create mode 100644 app/Domains/Auth/Controllers/LoginController.php create mode 100644 app/Domains/Auth/Controllers/LogoutController.php create mode 100644 app/Domains/Auth/Requests/LoginUserRequest.php create mode 100644 database/migrations/2026_07_02_170254_create_personal_access_tokens_table.php create mode 100644 tests/Feature/Auth/LoginControllerTest.php diff --git a/ShopIt_API_Postman_Collection.json b/ShopIt_API_Postman_Collection.json index 878cba7..dea5794 100644 --- a/ShopIt_API_Postman_Collection.json +++ b/ShopIt_API_Postman_Collection.json @@ -1173,6 +1173,119 @@ } ] }, + { + "name": "Auth", + "item": [ + { + "name": "Register User", + "request": { + "method": "POST", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"nombre_apellido\": \"Ada Lovelace\",\n \"email\": \"ada@example.com\",\n \"password\": \"secret123\",\n \"password_confirmation\": \"secret123\"\n}" + }, + "url": { + "raw": "{{base_url}}/api/register", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "register" + ] + } + }, + "response": [] + }, + { + "name": "Login User", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const response = pm.response.json();", + "", + "if (response.token) {", + " pm.collectionVariables.set('auth_token', response.token);", + " console.log('auth_token saved to collection variables');", + "} else {", + " console.warn('Login response does not contain token');", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"ada@example.com\",\n \"password\": \"secret123\"\n}" + }, + "url": { + "raw": "{{base_url}}/api/login", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "login" + ] + } + }, + "response": [] + }, + { + "name": "Logout User", + "request": { + "method": "POST", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{base_url}}/api/logout", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "logout" + ] + } + }, + "response": [] + } + ] + }, { "name": "Purchases", "item": [ @@ -1185,11 +1298,6 @@ "key": "Accept", "value": "application/json", "type": "text" - }, - { - "key": "Authorization", - "value": "Bearer {{sanctum_token}}", - "type": "text" } ], "url": { @@ -1217,11 +1325,6 @@ "value": "application/json", "type": "text" }, - { - "key": "Authorization", - "value": "Bearer {{sanctum_token}}", - "type": "text" - }, { "key": "Content-Type", "value": "application/json", @@ -1256,11 +1359,6 @@ "key": "Accept", "value": "application/json", "type": "text" - }, - { - "key": "Authorization", - "value": "Bearer {{sanctum_token}}", - "type": "text" } ], "url": { @@ -1415,11 +1513,6 @@ "key": "Accept", "value": "application/json", "type": "text" - }, - { - "key": "Authorization", - "value": "Bearer {{sanctum_token}}", - "type": "text" } ], "url": { @@ -1444,7 +1537,14 @@ "script": { "type": "text/javascript", "exec": [ - "" + "const authToken = pm.collectionVariables.get('auth_token');", + "", + "if (authToken) {", + " pm.request.headers.upsert({", + " key: 'Authorization',", + " value: `Bearer ${authToken}`,", + " });", + "}" ] } }, @@ -1461,7 +1561,12 @@ "variable": [ { "key": "base_url", - "value": "http://localhost:8000", + "value": "http://127.0.0.1:8000", + "type": "string" + }, + { + "key": "auth_token", + "value": "", "type": "string" }, { @@ -1528,11 +1633,6 @@ "key": "compra_id", "value": "1", "type": "string" - }, - { - "key": "sanctum_token", - "value": "", - "type": "string" } ] } diff --git a/app/Domains/Auth/Controllers/LoginController.php b/app/Domains/Auth/Controllers/LoginController.php new file mode 100644 index 0000000..90c790c --- /dev/null +++ b/app/Domains/Auth/Controllers/LoginController.php @@ -0,0 +1,38 @@ +validated(); + $user = User::query()->where('email', $credentials['email'])->first(); + + if (! $user || ! Hash::check($credentials['password'], $user->password)) { + throw ValidationException::withMessages([ + 'email' => __('auth.failed'), + ]); + } + + $token = $user->createToken('api-token')->plainTextToken; + + return response()->json([ + 'message' => 'Sesion iniciada correctamente.', + 'token' => $token, + 'token_type' => 'Bearer', + 'user' => UserResource::make($user), + ]); + } +} diff --git a/app/Domains/Auth/Controllers/LogoutController.php b/app/Domains/Auth/Controllers/LogoutController.php new file mode 100644 index 0000000..3b4499f --- /dev/null +++ b/app/Domains/Auth/Controllers/LogoutController.php @@ -0,0 +1,29 @@ +user(); + + $user->currentAccessToken()?->delete(); + $user->tokens()->delete(); + Auth::guard('web')->logout(); + + if ($request->hasSession()) { + $request->session()->invalidate(); + $request->session()->regenerateToken(); + } + + return response()->json([ + 'message' => 'Sesion cerrada correctamente.', + ]); + } +} diff --git a/app/Domains/Auth/Models/User.php b/app/Domains/Auth/Models/User.php index ff5db7a..719f5ed 100644 --- a/app/Domains/Auth/Models/User.php +++ b/app/Domains/Auth/Models/User.php @@ -8,13 +8,14 @@ use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Laravel\Sanctum\HasApiTokens; #[Fillable(['nombre_apellido', 'email', 'password'])] #[Hidden(['password', 'remember_token'])] class User extends Authenticatable { /** @use HasFactory */ - use HasFactory, Notifiable; + use HasApiTokens, HasFactory, Notifiable; protected static function newFactory(): UserFactory { diff --git a/app/Domains/Auth/Requests/LoginUserRequest.php b/app/Domains/Auth/Requests/LoginUserRequest.php new file mode 100644 index 0000000..7c7caff --- /dev/null +++ b/app/Domains/Auth/Requests/LoginUserRequest.php @@ -0,0 +1,24 @@ + + */ + public function rules(): array + { + return [ + 'email' => ['required', 'string', 'email', 'max:255'], + 'password' => ['required', 'string'], + ]; + } +} diff --git a/app/Domains/Auth/routes/api.php b/app/Domains/Auth/routes/api.php index 002c11f..7a3c8ce 100644 --- a/app/Domains/Auth/routes/api.php +++ b/app/Domains/Auth/routes/api.php @@ -1,6 +1,10 @@ post('logout', LogoutController::class); diff --git a/composer.json b/composer.json index a386c98..d78c9b0 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,7 @@ "require": { "php": "^8.3", "laravel/framework": "^13.8", - "laravel/sanctum": "^4.0", + "laravel/sanctum": "^4.3", "laravel/tinker": "^3.0", "league/flysystem-aws-s3-v3": "3.0" }, diff --git a/composer.lock b/composer.lock index 5c619c0..9e9a406 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c1933c09dc4d90a389d19d7cffff4e43", + "content-hash": "08dac456d84023cd816d5c96086a3b3c", "packages": [ { "name": "aws/aws-crt-php", diff --git a/config/sanctum.php b/config/sanctum.php index cde73cf..d315693 100644 --- a/config/sanctum.php +++ b/config/sanctum.php @@ -37,7 +37,7 @@ return [ | */ - 'guard' => ['web'], + 'guard' => [], /* |-------------------------------------------------------------------------- diff --git a/database/migrations/2026_07_02_170254_create_personal_access_tokens_table.php b/database/migrations/2026_07_02_170254_create_personal_access_tokens_table.php new file mode 100644 index 0000000..6ac41a5 --- /dev/null +++ b/database/migrations/2026_07_02_170254_create_personal_access_tokens_table.php @@ -0,0 +1,37 @@ +id(); + $table->morphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/tests/Feature/Auth/LoginControllerTest.php b/tests/Feature/Auth/LoginControllerTest.php new file mode 100644 index 0000000..b5198a5 --- /dev/null +++ b/tests/Feature/Auth/LoginControllerTest.php @@ -0,0 +1,87 @@ +create([ + 'nombre_apellido' => 'Grace Hopper', + 'email' => 'grace@example.com', + 'password' => Hash::make('secret123'), + ]); + + $response = $this->postJson('/api/login', [ + 'email' => 'grace@example.com', + 'password' => 'secret123', + ]); + + $response + ->assertOk() + ->assertJsonPath('message', 'Sesion iniciada correctamente.') + ->assertJsonPath('token_type', 'Bearer') + ->assertJsonPath('user.id', $user->id) + ->assertJsonPath('user.nombre_apellido', 'Grace Hopper') + ->assertJsonPath('user.email', 'grace@example.com'); + + $this->assertIsString($response->json('token')); + $this->assertNotEmpty($response->json('token')); + + $this->withHeader('Authorization', 'Bearer '.$response->json('token')) + ->getJson('/api/user') + ->assertOk() + ->assertJsonPath('id', $user->id) + ->assertJsonPath('email', 'grace@example.com'); + } + + public function test_it_rejects_access_to_the_current_user_endpoint_without_a_token(): void + { + $this->getJson('/api/user')->assertUnauthorized(); + } + + public function test_it_logs_out_the_current_token(): void + { + $user = User::factory()->create(); + $token = $user->createToken('api-token')->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/logout') + ->assertOk() + ->assertJsonPath('message', 'Sesion cerrada correctamente.'); + + $this->assertDatabaseCount('personal_access_tokens', 0); + } + + public function test_it_returns_a_validation_error_for_invalid_credentials(): void + { + User::query()->create([ + 'nombre_apellido' => 'Grace Hopper', + 'email' => 'grace@example.com', + 'password' => Hash::make('secret123'), + ]); + + $this->postJson('/api/login', [ + 'email' => 'grace@example.com', + 'password' => 'wrong-password', + ])->assertUnprocessable()->assertJsonValidationErrors(['email']); + } + + public function test_it_validates_required_login_fields(): void + { + $this->postJson('/api/login', [ + 'email' => 'invalid-email', + 'password' => '', + ])->assertUnprocessable()->assertJsonValidationErrors([ + 'email', + 'password', + ]); + } +}