diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php new file mode 100644 index 0000000..592a4e5 --- /dev/null +++ b/app/Http/Controllers/AuthController.php @@ -0,0 +1,91 @@ +validated(); + + $user = User::where('email', $validated['email'])->first(); + + if (! $user || ! Hash::check($validated['password'], $user->password)) { + throw ValidationException::withMessages([ + 'email' => ['The provided credentials are incorrect.'], + ]); + } + + $deviceName = $validated['device_name'] ?? 'api-client'; + $token = $user->createToken($deviceName); + + return response()->json([ + 'token' => $token->plainTextToken, + 'user' => [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + ], + ], 201); + } + + /** + * Revoke the current user's access token. + */ + public function logout(Request $request): JsonResponse + { + /** @var User $user */ + $user = $request->user(); + $user->currentAccessToken()->delete(); + + return response()->json([ + 'message' => 'Token revoked successfully.', + ]); + } + + /** + * Revoke all tokens for the authenticated user. + */ + public function logoutAll(Request $request): JsonResponse + { + /** @var User $user */ + $user = $request->user(); + + $user->tokens()->delete(); + + return response()->json([ + 'message' => 'All tokens revoked successfully.', + ]); + } + + /** + * Get the authenticated user's information. + */ + public function me(Request $request): JsonResponse + { + /** @var User $user */ + $user = $request->user(); + + return response()->json([ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + ]); + } +} diff --git a/app/Http/Requests/TokenRequest.php b/app/Http/Requests/TokenRequest.php new file mode 100644 index 0000000..35383fc --- /dev/null +++ b/app/Http/Requests/TokenRequest.php @@ -0,0 +1,48 @@ +|string> + */ + public function rules(): array + { + return [ + 'email' => 'required|email', + 'password' => 'required|string', + 'device_name' => 'nullable|string|max:255', + ]; + } + + /** + * Get custom validation error messages. + * + * @return array + */ + public function messages(): array + { + return [ + 'email.required' => 'Email address is required.', + 'email.email' => 'Please provide a valid email address.', + 'password.required' => 'Password is required.', + 'device_name.max' => 'Device name must not exceed 255 characters.', + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 654190c..c974a18 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -9,11 +9,12 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable { /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasFactory, Notifiable; + use HasApiTokens, HasFactory, Notifiable; /** * The attributes that are mass assignable. diff --git a/database/migrations/2025_11_01_195727_create_personal_access_tokens_table.php b/database/migrations/2025_11_01_195727_create_personal_access_tokens_table.php new file mode 100644 index 0000000..d221721 --- /dev/null +++ b/database/migrations/2025_11_01_195727_create_personal_access_tokens_table.php @@ -0,0 +1,36 @@ +id(); + $table->morphs('tokenable'); + $table->string('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/phpstan.neon b/phpstan.neon index 1b31a36..dc6bbb8 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -42,8 +42,11 @@ parameters: # PEST testing framework limitations: PHPStan cannot infer Laravel TestCase methods # in PEST's closure-based tests. These are valid Laravel testing methods. - - message: '#Call to an undefined method PHPUnit\\Framework\\TestCase::(get|post|put|patch|delete|getJson|postJson|putJson|patchJson|deleteJson)\(\)#' + message: '#Call to an undefined method PHPUnit\\Framework\\TestCase::(get|post|put|patch|delete|getJson|postJson|putJson|patchJson|deleteJson|withHeader|actingAs)\(\)#' path: tests/Feature/* - - message: '#Cannot call method (assertStatus|assertJson|assertJsonStructure|assertJsonFragment|assertJsonPath|assertExactJson|assertSee|assertDontSee)\(\) on mixed#' + message: '#Cannot call method (assertStatus|assertJson|assertJsonStructure|assertJsonFragment|assertJsonPath|assertExactJson|assertJsonValidationErrors|assertJsonMissing|assertSee|assertDontSee|assertSuccessful|assertForbidden|assertNotFound|assertRedirect|assertSessionHas|assertSessionMissing|assertViewIs|assertViewHas|assertCreated|assertOk|assertUnprocessable|assertUnauthorized|json|getJson|postJson|putJson|patchJson|deleteJson)\(\) on mixed#' + path: tests/Feature/* + - + message: '#Cannot (call method|access property) .* on (App\\Models\\User|Laravel\\Sanctum\\PersonalAccessToken)\|null#' path: tests/Feature/* diff --git a/routes/api.php b/routes/api.php index 2e2e068..6c2b51c 100644 --- a/routes/api.php +++ b/routes/api.php @@ -3,6 +3,7 @@ // SPDX-FileCopyrightText: 2025 SecPal Contributors // SPDX-License-Identifier: AGPL-3.0-or-later +use App\Http\Controllers\AuthController; use Illuminate\Support\Facades\Route; /* @@ -27,12 +28,13 @@ // API v1 routes Route::prefix('v1')->group(function () { - // Authentication routes - // Route::post('/login', [AuthController::class, 'login']); - // Route::post('/register', [AuthController::class, 'register']); + // Authentication routes (public) + Route::post('/auth/token', [AuthController::class, 'token']); - // Protected routes - // Route::middleware('auth:sanctum')->group(function () { - // Route::apiResource('users', UserController::class); - // }); + // Protected routes (require auth:sanctum) + Route::middleware('auth:sanctum')->group(function () { + Route::post('/auth/logout', [AuthController::class, 'logout']); + Route::post('/auth/logout-all', [AuthController::class, 'logoutAll']); + Route::get('/me', [AuthController::class, 'me']); + }); }); diff --git a/tests/Feature/AuthTest.php b/tests/Feature/AuthTest.php new file mode 100644 index 0000000..d06885f --- /dev/null +++ b/tests/Feature/AuthTest.php @@ -0,0 +1,264 @@ +create([ + 'email' => 'test@example.com', + 'password' => bcrypt('password123'), + ]); + + $response = $this->postJson('/api/v1/auth/token', [ + 'email' => 'test@example.com', + 'password' => 'password123', + 'device_name' => 'test-device', + ]); + + $response->assertCreated() + ->assertJsonStructure([ + 'token', + 'user' => ['id', 'name', 'email'], + ]); + + expect($response->json('user.email'))->toBe('test@example.com'); + expect($user->tokens()->count())->toBe(1); + }); + + test('token generation fails with invalid email', function () { + $response = $this->postJson('/api/v1/auth/token', [ + 'email' => 'nonexistent@example.com', + 'password' => 'password123', + ]); + + $response->assertUnprocessable() + ->assertJsonValidationErrors(['email']); + }); + + test('token generation fails with invalid password', function () { + User::factory()->create([ + 'email' => 'test@example.com', + 'password' => bcrypt('correct-password'), + ]); + + $response = $this->postJson('/api/v1/auth/token', [ + 'email' => 'test@example.com', + 'password' => 'wrong-password', + ]); + + $response->assertUnprocessable() + ->assertJsonValidationErrors(['email']); + }); + + test('token generation requires email', function () { + $response = $this->postJson('/api/v1/auth/token', [ + 'password' => 'password123', + ]); + + $response->assertUnprocessable() + ->assertJsonValidationErrors(['email']); + }); + + test('token generation requires password', function () { + $response = $this->postJson('/api/v1/auth/token', [ + 'email' => 'test@example.com', + ]); + + $response->assertUnprocessable() + ->assertJsonValidationErrors(['password']); + }); + + test('token generation uses default device name when not provided', function () { + $user = User::factory()->create([ + 'email' => 'test@example.com', + 'password' => bcrypt('password123'), + ]); + + $response = $this->postJson('/api/v1/auth/token', [ + 'email' => 'test@example.com', + 'password' => 'password123', + ]); + + $response->assertCreated(); + expect($user->tokens()->first()?->name)->toBe('api-client'); + }); + + test('user can generate multiple tokens for different devices', function () { + $user = User::factory()->create([ + 'email' => 'test@example.com', + 'password' => bcrypt('password123'), + ]); + + $this->postJson('/api/v1/auth/token', [ + 'email' => 'test@example.com', + 'password' => 'password123', + 'device_name' => 'mobile', + ])->assertCreated(); + + $this->postJson('/api/v1/auth/token', [ + 'email' => 'test@example.com', + 'password' => 'password123', + 'device_name' => 'desktop', + ])->assertCreated(); + + expect($user->tokens()->count())->toBe(2); + expect($user->tokens()->pluck('name')->toArray())->toContain('mobile', 'desktop'); + }); +}); + +describe('Protected Endpoints', function () { + test('protected endpoint requires authentication', function () { + $response = $this->getJson('/api/v1/me'); + + $response->assertUnauthorized(); + }); + + test('protected endpoint works with valid token', function () { + $user = User::factory()->create([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]); + + $token = $user->createToken('test-device')->plainTextToken; + + $response = $this->withHeader('Authorization', "Bearer {$token}") + ->getJson('/api/v1/me'); + + $response->assertOk() + ->assertJson([ + 'id' => $user->id, + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]); + }); + + test('protected endpoint rejects invalid token', function () { + $response = $this->withHeader('Authorization', 'Bearer invalid-token-here') + ->getJson('/api/v1/me'); + + $response->assertUnauthorized(); + }); +}); + +describe('Token Revocation', function () { + test('user can logout and revoke current token', function () { + $user = User::factory()->create([ + 'email' => 'test@example.com', + 'password' => bcrypt('password123'), + ]); + + $token = $user->createToken('device-1')->plainTextToken; + + $response = $this->withHeader('Authorization', "Bearer {$token}") + ->postJson('/api/v1/auth/logout'); + + $response->assertOk() + ->assertJson(['message' => 'Token revoked successfully.']); + + expect($user->tokens()->count())->toBe(0); + }); + + test('user can logout from all devices', function () { + $user = User::factory()->create([ + 'email' => 'test@example.com', + 'password' => bcrypt('password123'), + ]); + + $token1 = $user->createToken('device-1')->plainTextToken; + $user->createToken('device-2'); + $user->createToken('device-3'); + + expect($user->tokens()->count())->toBe(3); + + $response = $this->withHeader('Authorization', "Bearer {$token1}") + ->postJson('/api/v1/auth/logout-all'); + + $response->assertOk() + ->assertJson(['message' => 'All tokens revoked successfully.']); + + expect($user->fresh()->tokens()->count())->toBe(0); + }); + + test('logout successfully deletes token from database', function () { + $user = User::factory()->create(); + $token = $user->createToken('test-device')->plainTextToken; + + // Token exists before logout + expect($user->tokens()->count())->toBe(1); + + // Logout (revoke token) + $this->withHeader('Authorization', "Bearer {$token}") + ->postJson('/api/v1/auth/logout') + ->assertOk(); + + // Token deleted after logout + expect($user->fresh()->tokens()->count())->toBe(0); + }); + + test('logout requires authentication', function () { + $response = $this->postJson('/api/v1/auth/logout'); + + $response->assertUnauthorized(); + }); + + test('logout-all requires authentication', function () { + $response = $this->postJson('/api/v1/auth/logout-all'); + + $response->assertUnauthorized(); + }); +}); + +describe('Token Security', function () { + test('token does not expose sensitive user data', function () { + $user = User::factory()->create([ + 'password' => bcrypt('secret-password'), + ]); + + $response = $this->postJson('/api/v1/auth/token', [ + 'email' => $user->email, + 'password' => 'secret-password', + ]); + + $response->assertCreated() + ->assertJsonMissing(['password']) + ->assertJsonMissing(['remember_token']); + }); + + test('protected endpoint does not expose sensitive user data', function () { + $user = User::factory()->create(); + $token = $user->createToken('test')->plainTextToken; + + $response = $this->withHeader('Authorization', "Bearer {$token}") + ->getJson('/api/v1/me'); + + $response->assertOk() + ->assertJsonMissing(['password']) + ->assertJsonMissing(['remember_token']); + }); + + test('token is stored hashed in database', function () { + $user = User::factory()->create([ + 'email' => 'test@example.com', + 'password' => bcrypt('password123'), + ]); + + $response = $this->postJson('/api/v1/auth/token', [ + 'email' => 'test@example.com', + 'password' => 'password123', + ]); + + $plainTextToken = $response->json('token'); + $tokenRecord = $user->tokens()->first(); + + // Plain text token should not match database token + expect($tokenRecord->token)->not->toBe($plainTextToken); + // Database token should be hashed (64 chars for SHA-256) + expect(strlen($tokenRecord->token))->toBe(64); + }); +});