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    /** @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}