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