Code Coverage |
||||||||||
Classes and Traits |
Functions and Methods |
Lines |
||||||||
Total | |
0.00% |
0 / 1 |
|
46.15% |
6 / 13 |
CRAP | |
84.87% |
101 / 119 |
FilesystemJsonStorage | |
0.00% |
0 / 1 |
|
46.15% |
6 / 13 |
53.32 | |
84.87% |
101 / 119 |
__construct | |
0.00% |
0 / 1 |
3.05 | |
81.82% |
9 / 11 |
|||
setLogger | |
100.00% |
1 / 1 |
1 | |
100.00% |
2 / 2 |
|||
createAuthCode | |
0.00% |
0 / 1 |
3.02 | |
87.50% |
7 / 8 |
|||
exchangeAuthCodeForAccessToken | |
0.00% |
0 / 1 |
12.44 | |
77.14% |
27 / 35 |
|||
getAccessToken | |
0.00% |
0 / 1 |
6.20 | |
63.64% |
7 / 11 |
|||
revokeAccessToken | |
100.00% |
1 / 1 |
1 | |
100.00% |
2 / 2 |
|||
deleteExpiredTokens | |
100.00% |
1 / 1 |
9 | |
100.00% |
13 / 13 |
|||
get | |
0.00% |
0 / 1 |
4.01 | |
91.67% |
11 / 12 |
|||
put | |
100.00% |
1 / 1 |
1 | |
100.00% |
4 / 4 |
|||
delete | |
0.00% |
0 / 1 |
3.02 | |
87.50% |
7 / 8 |
|||
getPath | |
100.00% |
1 / 1 |
1 | |
100.00% |
1 / 1 |
|||
withLock | |
0.00% |
0 / 1 |
3.01 | |
90.91% |
10 / 11 |
|||
hash | |
100.00% |
1 / 1 |
1 | |
100.00% |
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 | class FilesystemJsonStorage implements TokenStorageInterface, LoggerAwareInterface { |
27 | const DEFAULT_AUTH_CODE_TTL = 60 * 5; // Five minutes. |
28 | const DEFAULT_ACCESS_TOKEN_TTL = 60 * 60 * 24 * 7; // One week. |
29 | |
30 | const TOKEN_LENGTH = 64; |
31 | |
32 | protected string $path; |
33 | protected int $authCodeTtl; |
34 | protected int $accessTokenTtl; |
35 | protected string $secret; |
36 | |
37 | protected LoggerInterface $logger; |
38 | |
39 | |
40 | public function __construct(string $path, string $secret, ?int $authCodeTtl=null, ?int $accessTokenTtl=null, $cleanUpNow=false, ?LoggerInterface $logger=null) { |
41 | $this->logger = $logger ?? new NullLogger(); |
42 | |
43 | if (strlen($secret) < 64) { |
44 | throw new Exception("\$secret must be a string with a minimum length of 64 characters. Make one with Taproot\IndieAuth\generateRandomString(64)"); |
45 | } |
46 | $this->secret = $secret; |
47 | |
48 | $this->path = rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; |
49 | |
50 | $this->authCodeTtl = $authCodeTtl ?? self::DEFAULT_AUTH_CODE_TTL; |
51 | $this->accessTokenTtl = $accessTokenTtl ?? self::DEFAULT_ACCESS_TOKEN_TTL; |
52 | |
53 | @mkdir($this->path, 0777, true); |
54 | |
55 | if ($cleanUpNow) { |
56 | $this->deleteExpiredTokens(); |
57 | } |
58 | } |
59 | |
60 | // LoggerAwareInterface method. |
61 | |
62 | public function setLogger(LoggerInterface $logger) { |
63 | $this->logger = $logger; |
64 | } |
65 | |
66 | // TokenStorageInterface Methods. |
67 | |
68 | public function createAuthCode(array $data): ?string { |
69 | $this->logger->info("Creating authorization code.", $data); |
70 | $authCode = generateRandomString(self::TOKEN_LENGTH); |
71 | $accessToken = $this->hash($authCode); |
72 | |
73 | // TODO: valid_until should be expire_after(? — look up), and should not be set here, |
74 | // as it applies only to access tokens, not auth codes! Auth code TTL should always be |
75 | // the default. |
76 | if (!array_key_exists('valid_until', $data)) { |
77 | $data['valid_until'] = time() + $this->authCodeTtl; |
78 | } |
79 | |
80 | if (!$this->put($accessToken, $data)) { |
81 | return null; |
82 | } |
83 | return $authCode; |
84 | } |
85 | |
86 | public function exchangeAuthCodeForAccessToken(string $code, callable $validateAuthCode): ?array { |
87 | // Hash the auth code to get the theoretical matching access token filename. |
88 | $accessToken = $this->hash($code); |
89 | |
90 | // Prevent the token file from being read, modified or deleted while we’re working with it. |
91 | // r+ to allow reading and writing, but to make sure we don’t create the file if it doesn’t |
92 | // already exist. |
93 | return $this->withLock($this->getPath($accessToken), 'r+', function ($fp) use ($accessToken, $validateAuthCode) { |
94 | // Read the file contents. |
95 | $fileContents = ''; |
96 | while ($d = fread($fp, 1024)) { $fileContents .= $d; } |
97 | |
98 | $data = json_decode($fileContents, true); |
99 | |
100 | if (!is_array($data)) { |
101 | $this->logger->error('Authoriazation Code data could not be parsed as a JSON object.'); |
102 | return null; |
103 | } |
104 | |
105 | // Make sure the auth code hasn’t already been redeemed. |
106 | if ($data['exchanged_at'] ?? false) { |
107 | $this->logger->error("This authorization code has already been exchanged."); |
108 | return null; |
109 | } |
110 | |
111 | // Make sure the auth code isn’t expired. |
112 | if (($data['valid_until'] ?? 0) < time()) { |
113 | $this->logger->error("This authorization code has expired."); |
114 | return null; |
115 | } |
116 | |
117 | // The auth code is valid as far as we know, pass it to the validation callback passed from the |
118 | // Server. |
119 | try { |
120 | $validateAuthCode($data); |
121 | } catch (IndieAuthException $e) { |
122 | // If there was an issue with the auth code, delete it before bubbling the exception |
123 | // up to the Server for handling. We currently have a lock on the file path, so pass |
124 | // false to $observeLock to prevent a deadlock. |
125 | $this->logger->info("Deleting authorization code, as it failed the Server-level validation."); |
126 | $this->delete($accessToken, false); |
127 | throw $e; |
128 | } |
129 | |
130 | // If the access token is valid, mark it as redeemed and set a new expiry time. |
131 | $data['exchanged_at'] = time(); |
132 | |
133 | if (is_int($data['_access_token_ttl'] ?? null)) { |
134 | // This access token has a custom TTL, use that. |
135 | $data['valid_until'] = time() + $data['_access_code_ttl']; |
136 | } elseif ($this->accessTokenTtl == 0) { |
137 | // The token should be valid until explicitly revoked. |
138 | $data['valid_until'] = null; |
139 | } else { |
140 | // Use the default TTL. |
141 | $data['valid_until'] = time() + $this->accessTokenTtl; |
142 | } |
143 | |
144 | // Write the new file contents, truncating afterwards in case the new data is shorter than the old data. |
145 | $jsonData = json_encode($data); |
146 | if (rewind($fp) === false) { return null; } |
147 | if (fwrite($fp, $jsonData) === false) { return null; } |
148 | if (ftruncate($fp, strlen($jsonData)) === false) { return null; } |
149 | |
150 | // Return the OAuth2-compatible access token data to the Server for passing onto |
151 | // the client app. Passed via array_filter to remove the scope key if scope is null. |
152 | return array_filter([ |
153 | 'access_token' => $accessToken, |
154 | 'scope' => $data['scope'] ?? null, |
155 | 'me' => $data['me'], |
156 | 'profile' => $data['profile'] ?? null |
157 | ]); |
158 | }); |
159 | } |
160 | |
161 | public function getAccessToken(string $token): ?array { |
162 | $data = $this->get($token); |
163 | |
164 | if (!is_array($data)) { |
165 | $this->logger->error("The access token could not be parsed as a JSON object."); |
166 | return null; |
167 | } |
168 | |
169 | // Check that this is a redeemed access token. |
170 | if (($data['exchanged_at'] ?? false) === false) { |
171 | $this->logger->error("This authorization code has not yet been exchanged for an access token."); |
172 | return null; |
173 | } |
174 | |
175 | // Check that the access token is still valid. valid_until=null means it should live until |
176 | // explicitly revoked. |
177 | if (is_int($data['valid_until']) && $data['valid_until'] < time()) { |
178 | $this->logger->error("This access token has expired."); |
179 | return null; |
180 | } |
181 | |
182 | // The token is valid! |
183 | return $data; |
184 | } |
185 | |
186 | public function revokeAccessToken(string $token): bool { |
187 | $this->logger->info("Deleting access token {$token}"); |
188 | return $this->delete($token); |
189 | } |
190 | |
191 | // Implementation-Specifc Methods. |
192 | |
193 | public function deleteExpiredTokens(): int { |
194 | $deleted = 0; |
195 | |
196 | foreach (new DirectoryIterator($this->path) as $fileInfo) { |
197 | if ($fileInfo->isFile() && $fileInfo->getExtension() == 'json') { |
198 | // Only delete files which we can lock. |
199 | $successfullyDeleted = $this->withLock($fileInfo->getPathname(), 'r', function ($fp) use ($fileInfo) { |
200 | // Read the file, check expiry date! Only unlink if file is expired. |
201 | $fileContents = ''; |
202 | while ($d = fread($fp, 1024)) { $fileContents .= $d; } |
203 | |
204 | $data = json_decode($fileContents, true); |
205 | |
206 | if (!is_array($data)) { return; } |
207 | |
208 | // If valid_until is a valid time, and is in the past, delete the token. |
209 | if (is_int($data['valid_until'] ?? null) && $data['valid_until'] < time()) { |
210 | return unlink($fileInfo->getPathname()); |
211 | } |
212 | }); |
213 | |
214 | if ($successfullyDeleted) { $deleted++; } |
215 | } |
216 | } |
217 | |
218 | return $deleted; |
219 | } |
220 | |
221 | public function get(string $key): ?array { |
222 | $path = $this->getPath($key); |
223 | |
224 | if (!file_exists($path)) { |
225 | return null; |
226 | } |
227 | |
228 | return $this->withLock($path, 'r', function ($fp) { |
229 | $fileContents = ''; |
230 | while ($data = fread($fp, 1024)) { |
231 | $fileContents .= $data; |
232 | } |
233 | $result = json_decode($fileContents, true); |
234 | |
235 | if (is_array($result)) { |
236 | return $result; |
237 | } |
238 | |
239 | return null; |
240 | }); |
241 | } |
242 | |
243 | public function put(string $key, array $data): bool { |
244 | // Ensure that the containing folder exists. |
245 | @mkdir($this->path, 0777, true); |
246 | |
247 | return $this->withLock($this->getPath($key), 'w', function ($fp) use ($data) { |
248 | return fwrite($fp, json_encode($data)) !== false; |
249 | }); |
250 | } |
251 | |
252 | public function delete(string $key, $observeLock=true): bool { |
253 | $path = $this->getPath($key); |
254 | if (file_exists($path)) { |
255 | if ($observeLock) { |
256 | return $this->withLock($path, 'r', function ($fp) use ($path) { |
257 | return unlink($path); |
258 | }); |
259 | } else { |
260 | return unlink($path); |
261 | } |
262 | } |
263 | return false; |
264 | } |
265 | |
266 | public function getPath(string $key): string { |
267 | // TODO: ensure that the calculated path is a child of $this->path. |
268 | return $this->path . "$key.json"; |
269 | } |
270 | |
271 | protected function withLock(string $path, string $mode, callable $callback) { |
272 | $fp = @fopen($path, $mode); |
273 | |
274 | if ($fp === false) { |
275 | return null; |
276 | } |
277 | |
278 | // Wait for a lock. |
279 | if (flock($fp, LOCK_EX)) { |
280 | $return = null; |
281 | try { |
282 | // Perform whatever action on the file pointer. |
283 | $return = $callback($fp); |
284 | } finally { |
285 | // Regardless of what happens, release the lock. |
286 | flock($fp, LOCK_UN); |
287 | fclose($fp); |
288 | } |
289 | return $return; |
290 | } |
291 | // It wasn’t possible to get a lock. |
292 | return null; |
293 | } |
294 | |
295 | protected function hash(string $token): string { |
296 | return hash_hmac('sha256', $token, $this->secret); |
297 | } |
298 | } |