Reworked TokenStorageInterface, changed JsonStorage

* TokenStorageInterface is now responsible for accessing both auth codes
  and access tokens
* TokenStorageInterface now only defines the methods strictly required
  for IndieAuth to work
* TokenStorageInterface now responsible for generating the auth code,
  making self-encoded codes possible.
* TokenStorageInterface now responsible for token lifetimes, expiry,
  and exchange
* JsonStorage implements new methods, all disk access is wrapped with
  lock acquisition

Tests not yet updated!
This commit is contained in:
Barnaby Walters 2021-06-09 23:46:33 +02:00
parent ed5f43a5e4
commit ec27c48946
2 changed files with 187 additions and 23 deletions

View File

@ -3,14 +3,39 @@
namespace Taproot\IndieAuth\Storage;
use DirectoryIterator;
use Exception;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
class FilesystemJsonStorage implements TokenStorageInterface {
protected $path;
protected $ttl;
use function Taproot\IndieAuth\generateRandomString;
use function Taproot\IndieAuth\trySetLogger;
class FilesystemJsonStorage implements TokenStorageInterface, LoggerAwareInterface {
const DEFAULT_AUTH_CODE_TTL = 60 * 5; // Five minutes.
const DEFAULT_ACCESS_TOKEN_TTL = 60 * 60 * 24 * 7; // One week.
const TOKEN_LENGTH = 64;
protected string $path;
protected int $authCodeTtl;
protected int $accessTokenTtl;
protected string $secret;
protected LoggerInterface $logger;
public function __construct(string $path, string $secret, ?int $authCodeTtl=null, ?int $accessTokenTtl=null, $cleanUpNow=false, ?LoggerInterface $logger=null) {
$this->logger = $logger ?? new NullLogger();
if (!is_string($secret) || strlen($secret) < 64) {
throw new Exception("\$secret must be a string with a minimum length of 64 characters.");
}
$this->secret = $secret;
public function __construct(string $path, $ttl=0, $cleanUpNow=false) {
$this->path = rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
$this->ttl = $ttl;
$this->authCodeTtl == $authCodeTtl ?? self::DEFAULT_AUTH_CODE_TTL;
$this->accessTokenTtl = $accessTokenTtl ?? self::DEFAULT_ACCESS_TOKEN_TTL;
@mkdir($this->path, 0777, true);
@ -19,18 +44,114 @@ class FilesystemJsonStorage implements TokenStorageInterface {
}
}
public function cleanUp($ttl=null): int {
$ttl = $ttl ?? $this->ttl;
// LoggerAwareInterface method.
public function setLogger(LoggerInterface $logger) {
$this->logger = $logger;
}
// TokenStorageInterface Methods.
public function createAuthCode(array $data): ?Token {
$authCode = generateRandomString(self::TOKEN_LENGTH);
$accessToken = $this->hash($authCode);
if (!$this->put($accessToken, $data)) {
return null;
}
return new Token($authCode, $data);
}
public function exchangeAuthCodeForAccessToken(string $code): ?Token {
// Hash the auth code to get the theoretical matching access token filename.
$accessToken = $this->hash($code);
// Prevent the token file from being read, modified or deleted while were working with it.
// r+ to allow reading and writing, but to make sure we dont create the file if it doesnt
// already exist.
return $this->withLock($this->getPath($accessToken), 'r+', function ($fp) use ($accessToken) {
// Read the file contents.
$fileContents = '';
while ($d = fread($fp, 1024)) { $fileContents .= $d; }
$data = json_decode($fileContents, true);
if (!is_array($data)) { return null; }
// Make sure the auth code hasnt already been redeemed.
if ($data['exchanged_at'] ?? false) { return null; }
// Make sure the auth code isnt expired.
if ($data['valid_until'] ?? 0 < time()) { return null; }
// If the access token is valid, mark it as redeemed and set a new expiry time.
$data['exchanged_at'] = time();
if (is_int($data['_access_token_ttl'] ?? null)) {
// This access token has a custom TTL, use that.
$data['valid_until'] = time() + $data['_access_code_ttl'];
} elseif ($this->accessTokenTtl == 0) {
// The token should be valid until explicitly revoked.
$data['valid_until'] = null;
} else {
// Use the default TTL.
$data['valid_until'] = time() + $this->accessTokenTtl;
}
// Write the new file contents, truncating afterwards in case the new data is shorter than the old data.
$jsonData = json_encode($data);
if (rewind($fp) === false) { return null; }
if (fwrite($fp, $jsonData) === false) { return null; }
if (ftruncate($fp, strlen($jsonData)) === false) { return null; }
return new Token($accessToken, $data);
});
}
public function getAccessToken(string $token): ?Token {
$data = $this->get($token);
if (!is_array($data)) { return null; }
// Check that this is a redeemed access token.
if ($data['_redeemed'] ?? false === false) { return null; }
// Check that the access token is still valid. valid_until=null means it should live until
// explicitly revoked.
if (is_int($data['valid_until']) && $data['valid_until'] < time()) { return null; }
// The token is valid!
return new Token($token, $data);
}
public function revokeAccessToken(string $token): bool {
return $this->delete($token);
}
// Implementation-Specifc Methods.
public function deleteExpiredTokens(): int {
$deleted = 0;
// A TTL of 0 means the token should live until deleted. A negative TTLs means “delete everything”.
if ($ttl !== 0) {
foreach (new DirectoryIterator($this->path) as $fileInfo) {
if ($fileInfo->isFile() && $fileInfo->getExtension() == 'json' && time() - max($fileInfo->getMTime(), $fileInfo->getCTime()) > $ttl) {
unlink($fileInfo->getPathname());
$deleted++;
}
foreach (new DirectoryIterator($this->path) as $fileInfo) {
if ($fileInfo->isFile() && $fileInfo->getExtension() == 'json') {
// Only delete files which we can lock.
$successfullyDeleted = $this->withLock($fileInfo->getPathname(), 'r', function ($fp) use ($fileInfo) {
// Read the file, check expiry date! Only unlink if file is expired.
$fileContents = '';
while ($d = fread($fp, 1024)) { $fileContents .= $d; }
$data = json_decode($fileContents, true);
if (!is_array($data)) { return; }
// If valid_until is a valid time, and is in the past, delete the token.
if (is_int($data['valid_until'] ?? null) && $data['valid_until'] < time()) {
return unlink($fileInfo->getPathname());
}
});
if ($successfullyDeleted) { $deleted++; }
}
}
@ -39,32 +160,75 @@ class FilesystemJsonStorage implements TokenStorageInterface {
public function get(string $key): ?array {
$path = $this->getPath($key);
if (file_exists($path)) {
$result = json_decode(file_get_contents($path), true);
if (!file_exists($path)) {
return null;
}
return $this->withLock($path, 'r', function ($fp) {
$fileContents = '';
while ($data = fread($fp, 1024)) {
$fileContents .= $data;
}
$result = json_decode($fileContents, true);
if (is_array($result)) {
return $result;
}
}
return null;
return null;
});
}
public function put(string $key, array $data): bool {
// Ensure that the containing folder exists.
@mkdir($this->path, 0777, true);
return file_put_contents($this->getPath($key), json_encode($data)) !== false;
return $this->withLock($this->getPath($key), 'w', function ($fp) use ($data) {
return fwrite($fp, json_encode($data)) !== false;
});
}
public function delete(string $key): bool {
if (file_exists($this->getPath($key))) {
return unlink($this->getPath($key));
$path = $this->getPath($key);
if (file_exists($path)) {
return $this->withLock($path, 'r', function ($fp) use ($path) {
return unlink($path);
});
}
return false;
}
public function getPath(string $key): string {
// TODO: ensure that the calculated path is a child of $this->path.
return $this->path . "$key.json";
}
protected function withLock(string $path, string $mode, callable $callback): mixed {
$fp = fopen($path, $mode);
if ($fp === false) {
return false;
}
// Wait for a lock.
if (flock($fp, LOCK_EX)) {
$return = null;
try {
// Perform whatever action on the file pointer.
$return = $callback($fp);
} finally {
// Regardless of what happens, release the lock.
flock($fp, LOCK_UN);
fclose($fp);
}
return $return;
}
// It wasnt possible to get a lock.
return null;
}
protected function hash(string $token): string {
return hash_hmac('sha256', $token, $this->secret);
}
}

View File

@ -9,7 +9,7 @@ interface TokenStorageInterface {
public function exchangeAuthCodeForAccessToken(string $code): ?Token;
public function revokeAccessToken(string $token);
public function getAccessToken(string $token): ?Token;
public function revokeAccessToken(string $token): bool;
}