Code Coverage
 
Classes and Traits
Functions and Methods
Lines
Total
0.00% covered (danger)
0.00%
0 / 1
46.15% covered (danger)
46.15%
6 / 13
CRAP
84.87% covered (warning)
84.87%
101 / 119
FilesystemJsonStorage
0.00% covered (danger)
0.00%
0 / 1
46.15% covered (danger)
46.15%
6 / 13
53.32
84.87% covered (warning)
84.87%
101 / 119
 __construct
0.00% covered (danger)
0.00%
0 / 1
3.05
81.82% covered (warning)
81.82%
9 / 11
 setLogger
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 createAuthCode
0.00% covered (danger)
0.00%
0 / 1
3.02
87.50% covered (warning)
87.50%
7 / 8
 exchangeAuthCodeForAccessToken
0.00% covered (danger)
0.00%
0 / 1
12.44
77.14% covered (warning)
77.14%
27 / 35
 getAccessToken
0.00% covered (danger)
0.00%
0 / 1
6.20
63.64% covered (warning)
63.64%
7 / 11
 revokeAccessToken
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 deleteExpiredTokens
100.00% covered (success)
100.00%
1 / 1
9
100.00% covered (success)
100.00%
13 / 13
 get
0.00% covered (danger)
0.00%
0 / 1
4.01
91.67% covered (success)
91.67%
11 / 12
 put
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
4 / 4
 delete
0.00% covered (danger)
0.00%
0 / 1
3.02
87.50% covered (warning)
87.50%
7 / 8
 getPath
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
1 / 1
 withLock
0.00% covered (danger)
0.00%
0 / 1
3.01
90.91% covered (success)
90.91%
10 / 11
 hash
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
1 / 1
1<?php declare(strict_types=1);
2
3namespace Taproot\IndieAuth\Storage;
4
5use DirectoryIterator;
6use Exception;
7use Psr\Log\LoggerAwareInterface;
8use Psr\Log\LoggerInterface;
9use Psr\Log\NullLogger;
10use Taproot\IndieAuth\IndieAuthException;
11
12use 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 */
26class 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}