Code Coverage
 
Classes and Traits
Functions and Methods
Lines
Total
0.00% covered (danger)
0.00%
0 / 1
53.85% covered (warning)
53.85%
7 / 13
CRAP
92.31% covered (success)
92.31%
96 / 104
FilesystemJsonStorage
0.00% covered (danger)
0.00%
0 / 1
53.85% covered (warning)
53.85%
7 / 13
48.01
92.31% covered (success)
92.31%
96 / 104
 __construct
0.00% covered (danger)
0.00%
0 / 1
4.10
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.03
85.71% covered (warning)
85.71%
6 / 7
 exchangeAuthCodeForAccessToken
0.00% covered (danger)
0.00%
0 / 1
11.04
92.86% covered (success)
92.86%
26 / 28
 getAccessToken
100.00% covered (success)
100.00%
1 / 1
5
100.00% covered (success)
100.00%
5 / 5
 revokeAccessToken
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
1 / 1
 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 (!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}