Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
83.48% |
96 / 115 |
|
46.15% |
6 / 13 |
CRAP | |
0.00% |
0 / 1 |
FilesystemJsonStorage | |
83.48% |
96 / 115 |
|
46.15% |
6 / 13 |
55.54 | |
0.00% |
0 / 1 |
__construct | |
80.00% |
8 / 10 |
|
0.00% |
0 / 1 |
3.07 | |||
setLogger | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
createAuthCode | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
3.01 | |||
exchangeAuthCodeForAccessToken | |
75.00% |
27 / 36 |
|
0.00% |
0 / 1 |
12.89 | |||
getAccessToken | |
63.64% |
7 / 11 |
|
0.00% |
0 / 1 |
6.20 | |||
revokeAccessToken | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
deleteExpiredTokens | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
9 | |||
get | |
90.91% |
10 / 11 |
|
0.00% |
0 / 1 |
4.01 | |||
put | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
delete | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
3.03 | |||
getPath | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
withLock | |
90.91% |
10 / 11 |
|
0.00% |
0 / 1 |
3.01 | |||
hash | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 |
1 | <?php declare(strict_types=1); |
2 | |
3 | namespace Taproot\IndieAuth\Storage; |
4 | |
5 | use DirectoryIterator; |
6 | use Exception; |
7 | use Psr\Log\LoggerAwareInterface; |
8 | use Psr\Log\LoggerInterface; |
9 | use Psr\Log\NullLogger; |
10 | use Taproot\IndieAuth\IndieAuthException; |
11 | |
12 | use function Taproot\IndieAuth\generateRandomString; |
13 | |
14 | /** |
15 | * Filesystem JSON Token Storage |
16 | * |
17 | * An implementation of `TokenStorageInterface` which stores authorization codes |
18 | * and access tokens in the filesystem as JSON files, and supports custom access |
19 | * token lifetimes. |
20 | * |
21 | * This is intended as a default, example implementation with minimal requirements. |
22 | * In practise, most people should probably be using an SQLite3 version of this |
23 | * which I haven’t written yet. I haven’t extensively documented this class, as it |
24 | * will likely be superceded by the SQLite version. |
25 | * |
26 | * Each auth code/access token pair is stored in one file. The file name is the |
27 | * access token, which is also the result of `$storage->hash($authCode)`. |
28 | * |
29 | * The file format is as follows: |
30 | * |
31 | * ```json |
32 | * { |
33 | * "code_exp": int (epoch seconds), expiry time of the auth code |
34 | * "_access_token_ttl": int, custom token-specific access token TTL |
35 | * "iat": int (epoch seconds), time of code->token exchange |
36 | * "exp": int (epoch seconds), access token expiry time, |
37 | * "scope": string, comma separated scope values |
38 | * "me": string, me URI |
39 | * "profile": { |
40 | * "name": string |
41 | * "url": string |
42 | * "photo": string |
43 | * } |
44 | * } |
45 | * ``` |
46 | */ |
47 | class FilesystemJsonStorage implements TokenStorageInterface, LoggerAwareInterface { |
48 | const DEFAULT_AUTH_CODE_TTL = 60 * 5; // Five minutes. |
49 | const DEFAULT_ACCESS_TOKEN_TTL = 60 * 60 * 24 * 7; // One week. |
50 | |
51 | const TOKEN_LENGTH = 64; |
52 | |
53 | /** @var string $path */ |
54 | protected $path; |
55 | |
56 | /** @var int $authCodeTtl */ |
57 | protected $authCodeTtl; |
58 | |
59 | /** @var int $accessTokenTtl */ |
60 | protected $accessTokenTtl; |
61 | |
62 | /** @var string $secret */ |
63 | protected $secret; |
64 | |
65 | /** @var LoggerInterface $logger */ |
66 | protected $logger; |
67 | |
68 | public function __construct(string $path, string $secret, ?int $authCodeTtl=null, ?int $accessTokenTtl=null, $cleanUpNow=false, ?LoggerInterface $logger=null) { |
69 | $this->logger = $logger ?? new NullLogger(); |
70 | |
71 | if (strlen($secret) < 64) { |
72 | throw new Exception("\$secret must be a string with a minimum length of 64 characters. Make one with Taproot\IndieAuth\generateRandomString(64)"); |
73 | } |
74 | $this->secret = $secret; |
75 | |
76 | $this->path = rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; |
77 | |
78 | $this->authCodeTtl = $authCodeTtl ?? self::DEFAULT_AUTH_CODE_TTL; |
79 | $this->accessTokenTtl = $accessTokenTtl ?? self::DEFAULT_ACCESS_TOKEN_TTL; |
80 | |
81 | @mkdir($this->path, 0777, true); |
82 | |
83 | if ($cleanUpNow) { |
84 | $this->deleteExpiredTokens(); |
85 | } |
86 | } |
87 | |
88 | // LoggerAwareInterface method. |
89 | |
90 | public function setLogger(LoggerInterface $logger): void { |
91 | $this->logger = $logger; |
92 | } |
93 | |
94 | // TokenStorageInterface Methods. |
95 | |
96 | public function createAuthCode(array $data): ?string { |
97 | $this->logger->info("Creating authorization code.", $data); |
98 | $authCode = generateRandomString(self::TOKEN_LENGTH); |
99 | $accessToken = $this->hash($authCode); |
100 | |
101 | // Store issued at and expiry times for the auth code. To keep a complete record of the history of the |
102 | // code/token, the auth code and access token have their own iat and exp fields. |
103 | $data['code_iat'] = time(); |
104 | if (!array_key_exists('code_exp', $data)) { |
105 | $data['code_exp'] = time() + $this->authCodeTtl; |
106 | } |
107 | |
108 | if (!$this->put($accessToken, $data)) { |
109 | return null; |
110 | } |
111 | return $authCode; |
112 | } |
113 | |
114 | public function exchangeAuthCodeForAccessToken(string $code, callable $validateAuthCode): ?array { |
115 | // Hash the auth code to get the theoretical matching access token filename. |
116 | $accessToken = $this->hash($code); |
117 | |
118 | // Prevent the token file from being read, modified or deleted while we’re working with it. |
119 | // r+ to allow reading and writing, but to make sure we don’t create the file if it doesn’t |
120 | // already exist. |
121 | return $this->withLock($this->getPath($accessToken), 'r+', function ($fp) use ($accessToken, $validateAuthCode) { |
122 | // Read the file contents. |
123 | $fileContents = ''; |
124 | while ($d = fread($fp, 1024)) { $fileContents .= $d; } |
125 | |
126 | $data = json_decode($fileContents, true); |
127 | |
128 | if (!is_array($data)) { |
129 | $this->logger->error('Authorization Code data could not be parsed as a JSON object.'); |
130 | return null; |
131 | } |
132 | |
133 | // Make sure the auth code hasn’t already been redeemed. |
134 | if ($data['iat'] ?? false) { |
135 | $this->logger->error("This authorization code has already been exchanged."); |
136 | return null; |
137 | } |
138 | |
139 | // Make sure the auth code isn’t expired. |
140 | if (($data['code_exp'] ?? 0) < time()) { |
141 | $this->logger->error("This authorization code has expired."); |
142 | return null; |
143 | } |
144 | |
145 | // The auth code is valid as far as we know, pass it to the validation callback passed from the |
146 | // Server. |
147 | try { |
148 | $validateAuthCode($data); |
149 | } catch (IndieAuthException $e) { |
150 | // If there was an issue with the auth code, delete it before bubbling the exception |
151 | // up to the Server for handling. We currently have a lock on the file path, so pass |
152 | // false to $observeLock to prevent a deadlock. |
153 | $this->logger->info("Deleting authorization code, as it failed the Server-level validation."); |
154 | $this->delete($accessToken, false); |
155 | throw $e; |
156 | } |
157 | |
158 | // If the access token is valid, mark it as redeemed and set a new expiry time. |
159 | $data['iat'] = time(); |
160 | |
161 | $expiresIn = null; |
162 | if (is_int($data['_access_token_ttl'] ?? null)) { |
163 | // This access token has a custom TTL, use that. |
164 | $data['exp'] = time() + $data['_access_token_ttl']; |
165 | $expiresIn = (int) $data['_access_token_ttl']; |
166 | } elseif ($this->accessTokenTtl == 0) { |
167 | // The token should be valid until explicitly revoked. |
168 | $data['exp'] = null; |
169 | } else { |
170 | // Use the default TTL. |
171 | $data['exp'] = time() + $this->accessTokenTtl; |
172 | $expiresIn = $this->accessTokenTtl; |
173 | } |
174 | |
175 | // Write the new file contents, truncating afterwards in case the new data is shorter than the old data. |
176 | $jsonData = json_encode($data); |
177 | if (rewind($fp) === false) { return null; } |
178 | if (fwrite($fp, $jsonData) === false) { return null; } |
179 | if (ftruncate($fp, strlen($jsonData)) === false) { return null; } |
180 | |
181 | // Return the OAuth2-compatible access token data to the Server for passing onto |
182 | // the client app. Passed via array_filter to remove keys with null values. |
183 | return array_filter([ |
184 | 'access_token' => $accessToken, |
185 | 'scope' => ($data['scope'] ?? null), |
186 | 'me' => $data['me'], |
187 | 'profile' => ($data['profile'] ?? null), |
188 | 'expires_in' => $expiresIn |
189 | ]); |
190 | }); |
191 | } |
192 | |
193 | public function getAccessToken(string $token): ?array { |
194 | $data = $this->get($token); |
195 | |
196 | if (!is_array($data)) { |
197 | $this->logger->error("The access token could not be parsed as a JSON object."); |
198 | return null; |
199 | } |
200 | |
201 | // Check that this is a redeemed access token. |
202 | if (($data['iat'] ?? false) === false) { |
203 | $this->logger->error("This authorization code has not yet been exchanged for an access token."); |
204 | return null; |
205 | } |
206 | |
207 | // Check that the access token is still valid. exp=null means it should live until |
208 | // explicitly revoked. |
209 | if (is_int($data['exp']) && $data['exp'] < time()) { |
210 | $this->logger->error("This access token has expired."); |
211 | return null; |
212 | } |
213 | |
214 | // The token is valid! |
215 | return $data; |
216 | } |
217 | |
218 | public function revokeAccessToken(string $token): bool { |
219 | $this->logger->info("Deleting access token {$token}"); |
220 | return $this->delete($token); |
221 | } |
222 | |
223 | // Implementation-Specifc Methods. |
224 | |
225 | public function deleteExpiredTokens(): int { |
226 | $deleted = 0; |
227 | |
228 | foreach (new DirectoryIterator($this->path) as $fileInfo) { |
229 | if ($fileInfo->isFile() && $fileInfo->getExtension() == 'json') { |
230 | // Only delete files which we can lock. |
231 | $successfullyDeleted = $this->withLock($fileInfo->getPathname(), 'r', function ($fp) use ($fileInfo) { |
232 | // Read the file, check expiry date! Only unlink if file is expired. |
233 | $fileContents = ''; |
234 | while ($d = fread($fp, 1024)) { $fileContents .= $d; } |
235 | |
236 | $data = json_decode($fileContents, true); |
237 | |
238 | if (!is_array($data)) { return; } |
239 | |
240 | // If valid_until is a valid time, and is in the past, delete the token. |
241 | if (is_int($data['exp'] ?? null) && $data['exp'] < time()) { |
242 | return unlink($fileInfo->getPathname()); |
243 | } |
244 | }); |
245 | |
246 | if ($successfullyDeleted) { $deleted++; } |
247 | } |
248 | } |
249 | |
250 | return $deleted; |
251 | } |
252 | |
253 | public function get(string $key): ?array { |
254 | $path = $this->getPath($key); |
255 | |
256 | if (!file_exists($path)) { |
257 | return null; |
258 | } |
259 | |
260 | return $this->withLock($path, 'r', function ($fp) { |
261 | $fileContents = ''; |
262 | while ($data = fread($fp, 1024)) { |
263 | $fileContents .= $data; |
264 | } |
265 | $result = json_decode($fileContents, true); |
266 | |
267 | if (is_array($result)) { |
268 | return $result; |
269 | } |
270 | |
271 | return null; |
272 | }); |
273 | } |
274 | |
275 | public function put(string $key, array $data): bool { |
276 | // Ensure that the containing folder exists. |
277 | @mkdir($this->path, 0777, true); |
278 | |
279 | return $this->withLock($this->getPath($key), 'w', function ($fp) use ($data) { |
280 | return fwrite($fp, json_encode($data)) !== false; |
281 | }); |
282 | } |
283 | |
284 | public function delete(string $key, $observeLock=true): bool { |
285 | $path = $this->getPath($key); |
286 | if (file_exists($path)) { |
287 | if ($observeLock) { |
288 | return $this->withLock($path, 'r', function ($fp) use ($path) { |
289 | return unlink($path); |
290 | }); |
291 | } else { |
292 | return unlink($path); |
293 | } |
294 | } |
295 | return false; |
296 | } |
297 | |
298 | public function getPath(string $key): string { |
299 | // TODO: ensure that the calculated path is a child of $this->path. |
300 | return $this->path . "$key.json"; |
301 | } |
302 | |
303 | protected function withLock(string $path, string $mode, callable $callback) { |
304 | $fp = @fopen($path, $mode); |
305 | |
306 | if ($fp === false) { |
307 | return null; |
308 | } |
309 | |
310 | // Wait for a lock. |
311 | if (flock($fp, LOCK_EX)) { |
312 | $return = null; |
313 | try { |
314 | // Perform whatever action on the file pointer. |
315 | $return = $callback($fp); |
316 | } finally { |
317 | // Regardless of what happens, release the lock. |
318 | flock($fp, LOCK_UN); |
319 | fclose($fp); |
320 | } |
321 | return $return; |
322 | } |
323 | // It wasn’t possible to get a lock. |
324 | return null; |
325 | } |
326 | |
327 | protected function hash(string $token): string { |
328 | return hash_hmac('sha256', $token, $this->secret); |
329 | } |
330 | } |