Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
83.48% covered (warning)
83.48%
96 / 115
46.15% covered (danger)
46.15%
6 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
FilesystemJsonStorage
83.48% covered (warning)
83.48%
96 / 115
46.15% covered (danger)
46.15%
6 / 13
55.54
0.00% covered (danger)
0.00%
0 / 1
 __construct
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
3.07
 setLogger
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createAuthCode
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 exchangeAuthCodeForAccessToken
75.00% covered (warning)
75.00%
27 / 36
0.00% covered (danger)
0.00%
0 / 1
12.89
 getAccessToken
63.64% covered (warning)
63.64%
7 / 11
0.00% covered (danger)
0.00%
0 / 1
6.20
 revokeAccessToken
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 deleteExpiredTokens
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
9
 get
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
4.01
 put
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 delete
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 getPath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 withLock
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
3.01
 hash
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 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 * 
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 */
47class 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}