Code Coverage |
||||||||||
Classes and Traits |
Functions and Methods |
Lines |
||||||||
Total | |
0.00% |
0 / 1 |
|
53.85% |
7 / 13 |
CRAP | |
92.31% |
96 / 104 |
FilesystemJsonStorage | |
0.00% |
0 / 1 |
|
53.85% |
7 / 13 |
48.01 | |
92.31% |
96 / 104 |
__construct | |
0.00% |
0 / 1 |
4.10 | |
81.82% |
9 / 11 |
|||
setLogger | |
100.00% |
1 / 1 |
1 | |
100.00% |
2 / 2 |
|||
createAuthCode | |
0.00% |
0 / 1 |
3.03 | |
85.71% |
6 / 7 |
|||
exchangeAuthCodeForAccessToken | |
0.00% |
0 / 1 |
11.04 | |
92.86% |
26 / 28 |
|||
getAccessToken | |
100.00% |
1 / 1 |
5 | |
100.00% |
5 / 5 |
|||
revokeAccessToken | |
100.00% |
1 / 1 |
1 | |
100.00% |
1 / 1 |
|||
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 (!is_string($secret) || 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 | $authCode = generateRandomString(self::TOKEN_LENGTH); |
70 | $accessToken = $this->hash($authCode); |
71 | |
72 | if (!array_key_exists('valid_until', $data)) { |
73 | $data['valid_until'] = time() + $this->authCodeTtl; |
74 | } |
75 | |
76 | if (!$this->put($accessToken, $data)) { |
77 | return null; |
78 | } |
79 | return $authCode; |
80 | } |
81 | |
82 | public function exchangeAuthCodeForAccessToken(string $code, callable $validateAuthCode): ?array { |
83 | // Hash the auth code to get the theoretical matching access token filename. |
84 | $accessToken = $this->hash($code); |
85 | |
86 | // Prevent the token file from being read, modified or deleted while we’re working with it. |
87 | // r+ to allow reading and writing, but to make sure we don’t create the file if it doesn’t |
88 | // already exist. |
89 | return $this->withLock($this->getPath($accessToken), 'r+', function ($fp) use ($accessToken, $validateAuthCode) { |
90 | // Read the file contents. |
91 | $fileContents = ''; |
92 | while ($d = fread($fp, 1024)) { $fileContents .= $d; } |
93 | |
94 | $data = json_decode($fileContents, true); |
95 | |
96 | if (!is_array($data)) { return null; } |
97 | |
98 | // Make sure the auth code hasn’t already been redeemed. |
99 | if ($data['exchanged_at'] ?? false) { return null; } |
100 | |
101 | // Make sure the auth code isn’t expired. |
102 | if (($data['valid_until'] ?? 0) < time()) { return null; } |
103 | |
104 | // The auth code is valid as far as we know, pass it to the validation callback passed from the |
105 | // Server. |
106 | try { |
107 | $validateAuthCode($data); |
108 | } catch (IndieAuthException $e) { |
109 | // If there was an issue with the auth code, delete it before bubbling the exception |
110 | // up to the Server for handling. We currently have a lock on the file path, so pass |
111 | // false to $observeLock to prevent a deadlock. |
112 | $this->delete($accessToken, false); |
113 | throw $e; |
114 | } |
115 | |
116 | // If the access token is valid, mark it as redeemed and set a new expiry time. |
117 | $data['exchanged_at'] = time(); |
118 | |
119 | if (is_int($data['_access_token_ttl'] ?? null)) { |
120 | // This access token has a custom TTL, use that. |
121 | $data['valid_until'] = time() + $data['_access_code_ttl']; |
122 | } elseif ($this->accessTokenTtl == 0) { |
123 | // The token should be valid until explicitly revoked. |
124 | $data['valid_until'] = null; |
125 | } else { |
126 | // Use the default TTL. |
127 | $data['valid_until'] = time() + $this->accessTokenTtl; |
128 | } |
129 | |
130 | // Write the new file contents, truncating afterwards in case the new data is shorter than the old data. |
131 | $jsonData = json_encode($data); |
132 | if (rewind($fp) === false) { return null; } |
133 | if (fwrite($fp, $jsonData) === false) { return null; } |
134 | if (ftruncate($fp, strlen($jsonData)) === false) { return null; } |
135 | |
136 | // Return the OAuth2-compatible access token data to the Server for passing onto |
137 | // the client app. Passed via array_filter to remove the scope key if scope is null. |
138 | return array_filter([ |
139 | 'access_token' => $accessToken, |
140 | 'scope' => $data['scope'] ?? null, |
141 | 'me' => $data['me'], |
142 | 'profile' => $data['profile'] ?? null |
143 | ]); |
144 | }); |
145 | } |
146 | |
147 | public function getAccessToken(string $token): ?array { |
148 | $data = $this->get($token); |
149 | |
150 | if (!is_array($data)) { return null; } |
151 | |
152 | // Check that this is a redeemed access token. |
153 | if (($data['exchanged_at'] ?? false) === false) { return null; } |
154 | |
155 | // Check that the access token is still valid. valid_until=null means it should live until |
156 | // explicitly revoked. |
157 | if (is_int($data['valid_until']) && $data['valid_until'] < time()) { return null; } |
158 | |
159 | // The token is valid! |
160 | return $data; |
161 | } |
162 | |
163 | public function revokeAccessToken(string $token): bool { |
164 | return $this->delete($token); |
165 | } |
166 | |
167 | // Implementation-Specifc Methods. |
168 | |
169 | public function deleteExpiredTokens(): int { |
170 | $deleted = 0; |
171 | |
172 | foreach (new DirectoryIterator($this->path) as $fileInfo) { |
173 | if ($fileInfo->isFile() && $fileInfo->getExtension() == 'json') { |
174 | // Only delete files which we can lock. |
175 | $successfullyDeleted = $this->withLock($fileInfo->getPathname(), 'r', function ($fp) use ($fileInfo) { |
176 | // Read the file, check expiry date! Only unlink if file is expired. |
177 | $fileContents = ''; |
178 | while ($d = fread($fp, 1024)) { $fileContents .= $d; } |
179 | |
180 | $data = json_decode($fileContents, true); |
181 | |
182 | if (!is_array($data)) { return; } |
183 | |
184 | // If valid_until is a valid time, and is in the past, delete the token. |
185 | if (is_int($data['valid_until'] ?? null) && $data['valid_until'] < time()) { |
186 | return unlink($fileInfo->getPathname()); |
187 | } |
188 | }); |
189 | |
190 | if ($successfullyDeleted) { $deleted++; } |
191 | } |
192 | } |
193 | |
194 | return $deleted; |
195 | } |
196 | |
197 | public function get(string $key): ?array { |
198 | $path = $this->getPath($key); |
199 | |
200 | if (!file_exists($path)) { |
201 | return null; |
202 | } |
203 | |
204 | return $this->withLock($path, 'r', function ($fp) { |
205 | $fileContents = ''; |
206 | while ($data = fread($fp, 1024)) { |
207 | $fileContents .= $data; |
208 | } |
209 | $result = json_decode($fileContents, true); |
210 | |
211 | if (is_array($result)) { |
212 | return $result; |
213 | } |
214 | |
215 | return null; |
216 | }); |
217 | } |
218 | |
219 | public function put(string $key, array $data): bool { |
220 | // Ensure that the containing folder exists. |
221 | @mkdir($this->path, 0777, true); |
222 | |
223 | return $this->withLock($this->getPath($key), 'w', function ($fp) use ($data) { |
224 | return fwrite($fp, json_encode($data)) !== false; |
225 | }); |
226 | } |
227 | |
228 | public function delete(string $key, $observeLock=true): bool { |
229 | $path = $this->getPath($key); |
230 | if (file_exists($path)) { |
231 | if ($observeLock) { |
232 | return $this->withLock($path, 'r', function ($fp) use ($path) { |
233 | return unlink($path); |
234 | }); |
235 | } else { |
236 | return unlink($path); |
237 | } |
238 | } |
239 | return false; |
240 | } |
241 | |
242 | public function getPath(string $key): string { |
243 | // TODO: ensure that the calculated path is a child of $this->path. |
244 | return $this->path . "$key.json"; |
245 | } |
246 | |
247 | protected function withLock(string $path, string $mode, callable $callback) { |
248 | $fp = @fopen($path, $mode); |
249 | |
250 | if ($fp === false) { |
251 | return null; |
252 | } |
253 | |
254 | // Wait for a lock. |
255 | if (flock($fp, LOCK_EX)) { |
256 | $return = null; |
257 | try { |
258 | // Perform whatever action on the file pointer. |
259 | $return = $callback($fp); |
260 | } finally { |
261 | // Regardless of what happens, release the lock. |
262 | flock($fp, LOCK_UN); |
263 | fclose($fp); |
264 | } |
265 | return $return; |
266 | } |
267 | // It wasn’t possible to get a lock. |
268 | return null; |
269 | } |
270 | |
271 | protected function hash(string $token): string { |
272 | return hash_hmac('sha256', $token, $this->secret); |
273 | } |
274 | } |