diff --git a/README.md b/README.md index c6cce3a..319f75d 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ ```shell composer install cp .env.example .env +php artisan key:generate touch database/database.sqlite php artisan migrate --seed ``` @@ -27,6 +28,40 @@ php artisan serve Access [the GraphiQL UI](https://github.com/graphql/graphiql/blob/main/packages/graphiql/README.md) at `/graphiql`. +Sanctum keeps sessions only for [stateful domains](https://laravel.com/docs/sanctum#configuring-your-first-party-domains), such as `127.0.0.1:8000`. + +In order to log in through Sanctum, find out the email of the seeded user: + +```graphql +{ + users { + data { + email + } + } +} +``` + +Then, log in with the following mutation: + +```graphql +mutation { + login(email: "", password: "password") { + id + } +} +``` + +To validate you are in fact logged in, run the following query: + +```graphql +{ + me { + id + } +} +``` + ## Minimalism In order to keep maintenance as simple as possible, diff --git a/app/GraphQL/Mutations/Login.php b/app/GraphQL/Mutations/Login.php new file mode 100644 index 0000000..62a542c --- /dev/null +++ b/app/GraphQL/Mutations/Login.php @@ -0,0 +1,35 @@ +attempt($args)) { + throw new Error('Invalid credentials.'); + } + + $user = $guard->user(); + assert($user instanceof User, 'must receive User after successful login'); + + return $user; + } +} diff --git a/app/GraphQL/Mutations/Logout.php b/app/GraphQL/Mutations/Logout.php new file mode 100644 index 0000000..4fe6097 --- /dev/null +++ b/app/GraphQL/Mutations/Logout.php @@ -0,0 +1,27 @@ +user(); + + $guard->logout(); + + return $user; + } +} diff --git a/composer.json b/composer.json index 92a5f1c..00b08b5 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,7 @@ "require": { "php": "^8.3", "laravel/framework": "^13", + "laravel/sanctum": "^4", "laravel/tinker": "^3.0", "mll-lab/laravel-graphiql": "^4", "nuwave/lighthouse": "^6" diff --git a/composer.lock b/composer.lock index c4d0a88..9806844 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": "bebb5a509aab0a2d03c9b567083ae332", + "content-hash": "56f77a687fe2609fe242cc1cf17648b0", "packages": [ { "name": "brick/math", @@ -1451,6 +1451,69 @@ }, "time": "2026-10-06T02:25:49+00:00" }, + { + "name": "laravel/sanctum", + "version": "v4.3.3", + "source": { + "type": "git", + "url": "https://github.com/laravel/sanctum.git", + "reference": "fee27a573d1a013af3721d86153a65e0b11927e6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/fee27a573d1a013af3721d86153a65e0b11927e6", + "reference": "fee27a573d1a013af3721d86153a65e0b11927e6", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/database": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/console": "^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sanctum\\SanctumServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sanctum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.", + "keywords": [ + "auth", + "laravel", + "sanctum" + ], + "support": { + "issues": "https://github.com/laravel/sanctum/issues", + "source": "https://github.com/laravel/sanctum" + }, + "time": "2026-06-23T18:26:55+00:00" + }, { "name": "laravel/serializable-closure", "version": "v2.1.0", diff --git a/config/graphiql.php b/config/graphiql.php new file mode 100644 index 0000000..ec586cf --- /dev/null +++ b/config/graphiql.php @@ -0,0 +1,64 @@ + [ + '/graphiql' => [ + 'name' => 'graphiql', + 'middleware' => ['web'], + // 'prefix' => '', + // 'domain' => 'graphql.' . env('APP_DOMAIN', 'localhost'), + + /* + |-------------------------------------------------------------------------- + | Default GraphQL endpoint + |-------------------------------------------------------------------------- + | + | The default endpoint that the GraphiQL UI is set to. + | It assumes you are running GraphQL on the same domain + | as GraphiQL, but can be set to any URL. + | + */ + + 'endpoint' => '/graphql', + + /* + |-------------------------------------------------------------------------- + | Subscription endpoint + |-------------------------------------------------------------------------- + | + | The default subscription endpoint the GraphiQL UI uses to connect to. + | Tries to connect to the `endpoint` value if `null` as ws://{{endpoint}} + | + | Example: `ws://your-endpoint` or `wss://your-endpoint` + | + */ + + 'subscription-endpoint' => env('GRAPHIQL_SUBSCRIPTION_ENDPOINT', null), + ], + ], + + /* + |-------------------------------------------------------------------------- + | Control GraphiQL availability + |-------------------------------------------------------------------------- + | + | Control if the GraphiQL UI is accessible at all. + | This allows you to disable it in certain environments, + | for example you might not want it active in production. + | + */ + + 'enabled' => env('GRAPHIQL_ENABLED', true), +]; diff --git a/config/lighthouse.php b/config/lighthouse.php new file mode 100644 index 0000000..308ffa1 --- /dev/null +++ b/config/lighthouse.php @@ -0,0 +1,54 @@ + false, to disable the default route + | registration and take full control. + | + */ + + 'route' => [ + /* + * The URI the endpoint responds to, e.g. mydomain.com/graphql. + */ + 'uri' => '/graphql', + + /* + * Lighthouse creates a named route for convenient URL generation and redirects. + */ + 'name' => 'graphql', + + /* + * Beware that middleware defined here runs before the GraphQL execution phase. + * Make sure to return spec-compliant responses in case an error is thrown. + */ + 'middleware' => [ + Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class, + + // Ensures the request is not vulnerable to cross-site request forgery. + // Nuwave\Lighthouse\Http\Middleware\EnsureXHR::class, + + // Always set the `Accept: application/json` header. + Nuwave\Lighthouse\Http\Middleware\AcceptJson::class, + + // Logs in a user if they are authenticated. In contrast to Laravel's 'auth' + // middleware, this delegates auth and permission checks to the field level. + Nuwave\Lighthouse\Http\Middleware\AttemptAuthentication::class, + + // Logs every incoming GraphQL query. + // Nuwave\Lighthouse\Http\Middleware\LogGraphQLQueries::class, + ], + + /* + * The `prefix`, `domain` and `where` configuration options are optional. + */ + // 'prefix' => '', + // 'domain' => '', + // 'where' => [], + ], +]; diff --git a/database/migrations/2024_11_13_083152_create_sessions_table.php b/database/migrations/2024_11_13_083152_create_sessions_table.php new file mode 100644 index 0000000..5fce475 --- /dev/null +++ b/database/migrations/2024_11_13_083152_create_sessions_table.php @@ -0,0 +1,25 @@ +string('id')->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->longText('payload'); + $table->integer('last_activity')->index(); + }); + } + + public function down(): void + { + Schema::dropIfExists('sessions'); + } +}; diff --git a/graphql/schema.graphql b/graphql/schema.graphql index 6772060..c10de1f 100644 --- a/graphql/schema.graphql +++ b/graphql/schema.graphql @@ -4,4 +4,12 @@ scalar Date @scalar(class: "Nuwave\\Lighthouse\\Schema\\Types\\Scalars\\Date") "A datetime string with format `Y-m-d H:i:s`, e.g. `2018-05-23 13:43:32`." scalar DateTime @scalar(class: "Nuwave\\Lighthouse\\Schema\\Types\\Scalars\\DateTime") +type Mutation { + "Log in to a new session and get the user." + login(email: String!, password: String!): User! + + "Log out from the current session, showing the user one last time." + logout: User @guard +} + #import models/*.graphql diff --git a/resources/views/vendor/graphiql/index.blade.php b/resources/views/vendor/graphiql/index.blade.php new file mode 100644 index 0000000..96846a6 --- /dev/null +++ b/resources/views/vendor/graphiql/index.blade.php @@ -0,0 +1,81 @@ +{{-- See https://github.com/graphql/graphiql/blob/main/examples/graphiql-cdn/index.html. --}} +@php +use MLL\GraphiQL\GraphiQLAsset; +@endphp + + + + + + GraphiQL + + + + + + + + + + +
+
Loading…
+
+ + + + + + + diff --git a/tests/Feature/AuthenticationTest.php b/tests/Feature/AuthenticationTest.php new file mode 100644 index 0000000..a87ebb4 --- /dev/null +++ b/tests/Feature/AuthenticationTest.php @@ -0,0 +1,59 @@ +createOne(); + $id = (string) $user->id; + + $this + ->graphQL(/** @lang GraphQL */ ' + mutation ($email: String!) { + login(email: $email, password: "password") { + id + } + } + ', [ + 'email' => $user->email, + ]) + ->assertExactJson(['data' => ['login' => ['id' => $id]]]); + + $this + ->graphQL(/** @lang GraphQL */ '{ me { id } }') + ->assertExactJson(['data' => ['me' => ['id' => $id]]]); + + $this + ->graphQL(/** @lang GraphQL */ 'mutation { logout { id } }') + ->assertExactJson(['data' => ['logout' => ['id' => $id]]]); + + $this + ->graphQL(/** @lang GraphQL */ '{ me { id } }') + ->assertExactJson(['data' => ['me' => null]]); + } + + public function test_login_with_wrong_password(): void + { + $user = User::factory()->createOne(); + + $this + ->graphQL(/** @lang GraphQL */ ' + mutation ($email: String!) { + login(email: $email, password: "wrong") { + id + } + } + ', [ + 'email' => $user->email, + ]) + ->assertGraphQLErrorMessage('Invalid credentials.'); + } +}