diff --git a/config/graphql.php b/config/graphql.php index e2b46d2b411..329c0d463f0 100644 --- a/config/graphql.php +++ b/config/graphql.php @@ -27,6 +27,18 @@ 'users' => false, ], + /* + |-------------------------------------------------------------------------- + | Authentication + |-------------------------------------------------------------------------- + | + | By default, the GraphQL API will be publicly accessible. However, you + | may define an API token here which will be used to authenticate requests. + | + */ + + 'auth_token' => env('STATAMIC_GRAPHQL_AUTH_TOKEN'), + /* |-------------------------------------------------------------------------- | Queries diff --git a/resources/views/graphql/graphiql.blade.php b/resources/views/graphql/graphiql.blade.php index cff8df91d92..3acaff8d21d 100644 --- a/resources/views/graphql/graphiql.blade.php +++ b/resources/views/graphql/graphiql.blade.php @@ -68,6 +68,11 @@ const fetcher = createGraphiQLFetcher({ url: '{{ $url }}', + @if ($authToken) + headers: { + 'Authorization': 'Bearer {{ $authToken }}', + }, + @endif }); let plugins = [HISTORY_PLUGIN]; diff --git a/src/GraphQL/DefaultSchema.php b/src/GraphQL/DefaultSchema.php index 1aaed9f1a17..ed2e70278a4 100644 --- a/src/GraphQL/DefaultSchema.php +++ b/src/GraphQL/DefaultSchema.php @@ -6,6 +6,7 @@ use Rebing\GraphQL\Support\Contracts\ConfigConvertible; use Statamic\Facades\GraphQL; use Statamic\GraphQL\Middleware\CacheResponse; +use Statamic\GraphQL\Middleware\HandleAuthentication; use Statamic\GraphQL\Queries\AssetContainerQuery; use Statamic\GraphQL\Queries\AssetContainersQuery; use Statamic\GraphQL\Queries\AssetQuery; @@ -77,7 +78,7 @@ private function getQueries() private function getMiddleware() { return array_merge( - [CacheResponse::class], + [HandleAuthentication::class, CacheResponse::class], config('statamic.graphql.middleware', []), GraphQL::getExtraMiddleware() ); diff --git a/src/GraphQL/Middleware/HandleAuthentication.php b/src/GraphQL/Middleware/HandleAuthentication.php new file mode 100644 index 00000000000..ca5ba4e5195 --- /dev/null +++ b/src/GraphQL/Middleware/HandleAuthentication.php @@ -0,0 +1,26 @@ +bearerToken() !== $token) + ) { + abort(401); + } + + return $next($request); + } +} diff --git a/src/Http/Controllers/CP/GraphQLController.php b/src/Http/Controllers/CP/GraphQLController.php index 327aaac2f74..25bc5217252 100644 --- a/src/Http/Controllers/CP/GraphQLController.php +++ b/src/Http/Controllers/CP/GraphQLController.php @@ -24,6 +24,7 @@ public function graphiql() return view('statamic::graphql.graphiql', [ 'url' => '/'.config('graphql.route.prefix'), 'introspection' => GraphQL::introspectionEnabled(), + 'authToken' => config('statamic.graphql.auth_token'), ]); } } diff --git a/tests/Feature/GraphQL/AuthenticationTest.php b/tests/Feature/GraphQL/AuthenticationTest.php new file mode 100644 index 00000000000..9dd5f5ca0ef --- /dev/null +++ b/tests/Feature/GraphQL/AuthenticationTest.php @@ -0,0 +1,91 @@ +withToken('foobar') + ->postJson('/graphql', ['query' => '{ping}']) + ->assertOk(); + } + + #[Test] + public function it_cant_authenticate_with_invalid_auth_token() + { + Config::set('statamic.graphql.auth_token', 'foobar'); + + $this + ->withToken($token = 'invalid') + ->postJson($url = '/graphql', ['query' => '{ping}']) + ->assertUnauthorized(); + + $this + ->withToken($token) + ->post($url, ['query' => '{ping}']) + ->assertUnauthorized(); + } + + #[Test] + public function it_cant_authenticate_without_auth_token() + { + Config::set('statamic.graphql.auth_token', 'foobar'); + + $this + ->postJson($url = '/graphql', ['query' => '{ping}']) + ->assertUnauthorized(); + + $this + ->post($url, ['query' => '{ping}']) + ->assertUnauthorized(); + } + + #[Test] + public function authentication_only_required_when_auth_token_is_set() + { + Config::set('statamic.graphql.auth_token', null); + + $this + ->postJson($url = '/graphql', ['query' => '{ping}']) + ->assertOk(); + + $this + ->post($url, ['query' => '{ping}']) + ->assertOk(); + } + + #[Test] + public function authenticated_responses_are_not_served_to_unauthenticated_requests() + { + Config::set('statamic.graphql.auth_token', 'foobar'); + Config::set('statamic.graphql.cache', ['expiry' => 60]); + + // First, make an authenticated request that gets cached + $this + ->withToken('foobar') + ->postJson('/graphql', ['query' => '{ping}']) + ->assertOk() + ->assertJsonPath('data.ping', 'pong'); + + // Now make an unauthenticated request - should get 401, not cached response + // This verifies auth happens before caching + $this + ->withoutToken() + ->postJson('/graphql', ['query' => '{ping}']) + ->assertUnauthorized(); + } +}