Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.16% covered (success)
90.16%
55 / 61
42.86% covered (danger)
42.86%
3 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiKeyAuthenticator
90.16% covered (success)
90.16%
55 / 61
42.86% covered (danger)
42.86%
3 / 7
20.38
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 authenticate
94.74% covered (success)
94.74%
36 / 38
0.00% covered (danger)
0.00%
0 / 1
6.01
 getAuthenticatedUserId
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getAuthenticatedApiKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isNotExpired
83.33% covered (success)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 parseScopes
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
4.59
 hasRequiredScopes
85.71% covered (success)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
1<?php
2
3/**
4 * API key authenticator.
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2026-02-09
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Auth;
21
22use phpMyFAQ\Configuration;
23use phpMyFAQ\Database;
24use Symfony\Component\HttpFoundation\Request;
25
26final class ApiKeyAuthenticator
27{
28    /** @var array<string, mixed>|null */
29    private ?array $authenticatedApiKeyStorage = null;
30
31    /**
32     * Authenticated API key context of the current request.
33     *
34     * @var array<string, mixed>|null
35     */
36    public ?array $authenticatedApiKey {
37        get => $this->authenticatedApiKeyStorage;
38    }
39
40    public function __construct(
41        private readonly Configuration $configuration,
42    ) {
43    }
44
45    /**
46     * @param string[] $requiredScopes
47     */
48    public function authenticate(Request $request, array $requiredScopes = []): bool
49    {
50        $this->authenticatedApiKeyStorage = null;
51
52        $header = trim((string) $request->headers->get('Authorization', ''));
53        $matches = [];
54        if (!preg_match('/^Bearer\s+(pmf_[A-Za-z0-9]+)$/', $header, $matches)) {
55            return false;
56        }
57
58        $apiKey = $matches[1];
59        $apiKeyHash = hash('sha256', $apiKey);
60        $db = $this->configuration->getDb();
61        $sql = sprintf("SELECT id, user_id, api_key, name, scopes, last_used_at, expires_at, created
62             FROM %sfaqapi_keys
63             WHERE api_key = '%s'", Database::getTablePrefix(), $db->escape($apiKeyHash));
64
65        $result = $db->query($sql);
66        if ($result === false) {
67            return false;
68        }
69
70        $row = $db->fetchObject($result);
71        if (!is_object($row)) {
72            return false;
73        }
74
75        if (!$this->isNotExpired($row->expires_at ?? null)) {
76            return false;
77        }
78
79        $grantedScopes = $this->parseScopes($row->scopes ?? '[]');
80        if (!$this->hasRequiredScopes($grantedScopes, $requiredScopes)) {
81            return false;
82        }
83
84        $db->query(sprintf(
85            'UPDATE %sfaqapi_keys SET last_used_at = %s WHERE id = %d',
86            Database::getTablePrefix(),
87            $db->now(),
88            (int) $row->id,
89        ));
90
91        $this->authenticatedApiKeyStorage = [
92            'id' => (int) $row->id,
93            'user_id' => (int) $row->user_id,
94            'api_key' => (string) $row->api_key,
95            'name' => (string) ($row->name ?? ''),
96            'scopes' => $grantedScopes,
97            'last_used_at' => $row->last_used_at ?? null,
98            'expires_at' => $row->expires_at ?? null,
99            'created' => $row->created ?? null,
100        ];
101
102        return true;
103    }
104
105    public function getAuthenticatedUserId(): ?int
106    {
107        $userId = $this->authenticatedApiKeyStorage['user_id'] ?? null;
108
109        return $userId === null ? null : (int) $userId;
110    }
111
112    /**
113     * @return array<string, mixed>|null
114     */
115    public function getAuthenticatedApiKey(): ?array
116    {
117        return $this->authenticatedApiKey;
118    }
119
120    private function isNotExpired(mixed $expiresAt): bool
121    {
122        if ($expiresAt === null || $expiresAt === '') {
123            return true;
124        }
125
126        $timestamp = strtotime((string) $expiresAt);
127        if ($timestamp === false) {
128            return false;
129        }
130
131        return $timestamp >= time();
132    }
133
134    /**
135     * @return string[]
136     */
137    private function parseScopes(mixed $scopes): array
138    {
139        if (!is_string($scopes) || trim($scopes) === '') {
140            return [];
141        }
142
143        $decoded = json_decode(json: $scopes, associative: true);
144        if (!is_array($decoded)) {
145            return [];
146        }
147
148        return array_values(array_filter($decoded, is_string(...)));
149    }
150
151    /**
152     * @param string[] $grantedScopes
153     * @param string[] $requiredScopes
154     */
155    private function hasRequiredScopes(array $grantedScopes, array $requiredScopes): bool
156    {
157        if ($requiredScopes === []) {
158            return true;
159        }
160
161        return array_all($requiredScopes, static fn($requiredScope) => in_array(
162            $requiredScope,
163            $grantedScopes,
164            strict: true,
165        ));
166    }
167}