feat(demo): add story 1 — Sorano: Rock and Time
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Grav\Plugin\Api\Auth;
|
||||
|
||||
use Grav\Common\Grav;
|
||||
use Grav\Common\User\Interfaces\UserCollectionInterface;
|
||||
use Grav\Common\User\Interfaces\UserInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
class ApiKeyAuthenticator implements AuthenticatorInterface
|
||||
{
|
||||
public function __construct(
|
||||
protected readonly Grav $grav,
|
||||
) {}
|
||||
|
||||
public function authenticate(ServerRequestInterface $request): ?UserInterface
|
||||
{
|
||||
$apiKey = $this->extractApiKey($request);
|
||||
if (!$apiKey || !str_starts_with($apiKey, 'grav_')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$manager = new ApiKeyManager();
|
||||
$match = $manager->findKey($apiKey);
|
||||
|
||||
if (!$match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$keyData = $match['data'];
|
||||
$keyId = $match['key_id'];
|
||||
$username = $match['username'];
|
||||
|
||||
// Check if key is active
|
||||
if (($keyData['active'] ?? true) === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check expiry
|
||||
if (isset($keyData['expires']) && $keyData['expires'] < time()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Load the associated user
|
||||
/** @var UserCollectionInterface $accounts */
|
||||
$accounts = $this->grav['accounts'];
|
||||
$user = $accounts->load($username);
|
||||
|
||||
if (!$user->exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Auto-rehash legacy SHA-256 keys to bcrypt
|
||||
if (!str_starts_with($keyData['hash'], '$2')) {
|
||||
$manager->rehashKey($keyId, $apiKey);
|
||||
}
|
||||
|
||||
// Update last_used timestamp
|
||||
$manager->touchKey($keyId);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
protected function extractApiKey(ServerRequestInterface $request): ?string
|
||||
{
|
||||
// Check X-API-Key header first
|
||||
$key = $request->getHeaderLine('X-API-Key');
|
||||
if ($key) {
|
||||
return $key;
|
||||
}
|
||||
|
||||
// Fall back to query parameter
|
||||
$query = $request->getQueryParams();
|
||||
return $query['api_key'] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Grav\Plugin\Api\Auth;
|
||||
|
||||
use Grav\Common\Grav;
|
||||
use Grav\Common\User\Authentication;
|
||||
use Grav\Common\User\Interfaces\UserInterface;
|
||||
use Grav\Common\Yaml;
|
||||
|
||||
/**
|
||||
* Manages API keys stored centrally in user/data/api-keys.yaml
|
||||
*/
|
||||
class ApiKeyManager
|
||||
{
|
||||
protected static ?array $keysCache = null;
|
||||
|
||||
/**
|
||||
* Generate a new API key for a user.
|
||||
*
|
||||
* @param int|null $expiryDays Number of days until the key expires, or null for no expiry
|
||||
* @return array{key: string, id: string} The raw key (shown once) and the key ID
|
||||
*/
|
||||
public function generateKey(UserInterface $user, string $name = '', array $scopes = [], ?int $expiryDays = null): array
|
||||
{
|
||||
$rawKey = 'grav_' . bin2hex(random_bytes(24));
|
||||
$keyId = bin2hex(random_bytes(8));
|
||||
$hash = Authentication::create($rawKey);
|
||||
$expires = $expiryDays !== null ? time() + ($expiryDays * 86400) : null;
|
||||
|
||||
$keys = $this->loadKeys();
|
||||
$keys[$keyId] = [
|
||||
'id' => $keyId,
|
||||
'username' => $user->username,
|
||||
'name' => $name ?: 'API Key',
|
||||
'hash' => $hash,
|
||||
'prefix' => substr($rawKey, 0, 12) . '...',
|
||||
'scopes' => $scopes,
|
||||
'active' => true,
|
||||
'created' => time(),
|
||||
'last_used' => null,
|
||||
'expires' => $expires,
|
||||
];
|
||||
|
||||
$this->saveKeys($keys);
|
||||
|
||||
return [
|
||||
'key' => $rawKey,
|
||||
'id' => $keyId,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* List all API keys for a user (without hashes).
|
||||
*/
|
||||
public function listKeys(UserInterface $user): array
|
||||
{
|
||||
$keys = $this->loadKeys();
|
||||
$result = [];
|
||||
|
||||
foreach ($keys as $keyData) {
|
||||
if (!is_array($keyData) || ($keyData['username'] ?? '') !== $user->username) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result[] = [
|
||||
'id' => $keyData['id'] ?? '',
|
||||
'name' => $keyData['name'] ?? 'API Key',
|
||||
'prefix' => $keyData['prefix'] ?? '',
|
||||
'scopes' => $keyData['scopes'] ?? [],
|
||||
'active' => $keyData['active'] ?? true,
|
||||
'created' => $keyData['created'] ?? null,
|
||||
'last_used' => $keyData['last_used'] ?? null,
|
||||
'expires' => $keyData['expires'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke (delete) an API key.
|
||||
*/
|
||||
public function revokeKey(UserInterface $user, string $keyId): bool
|
||||
{
|
||||
$keys = $this->loadKeys();
|
||||
|
||||
if (!isset($keys[$keyId]) || ($keys[$keyId]['username'] ?? '') !== $user->username) {
|
||||
return false;
|
||||
}
|
||||
|
||||
unset($keys[$keyId]);
|
||||
$this->saveKeys($keys);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a raw API key against a stored hash.
|
||||
*/
|
||||
public static function verifyKey(string $rawKey, string $hash): bool
|
||||
{
|
||||
// Bcrypt hashes start with $2y$ or $2b$
|
||||
if (str_starts_with($hash, '$2')) {
|
||||
return Authentication::verify($rawKey, $hash) > 0;
|
||||
}
|
||||
|
||||
// Legacy SHA-256 fallback
|
||||
return hash_equals($hash, hash('sha256', $rawKey));
|
||||
}
|
||||
|
||||
/**
|
||||
* Rehash a legacy SHA-256 key to bcrypt.
|
||||
*/
|
||||
public function rehashKey(string $keyId, string $rawKey): void
|
||||
{
|
||||
$keys = $this->loadKeys();
|
||||
|
||||
if (isset($keys[$keyId]) && is_array($keys[$keyId])) {
|
||||
$keys[$keyId]['hash'] = Authentication::create($rawKey);
|
||||
$this->saveKeys($keys);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last_used timestamp for a key.
|
||||
*/
|
||||
public function touchKey(string $keyId): void
|
||||
{
|
||||
$keys = $this->loadKeys();
|
||||
|
||||
if (isset($keys[$keyId]) && is_array($keys[$keyId])) {
|
||||
$keys[$keyId]['last_used'] = time();
|
||||
$this->saveKeys($keys);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a key entry by raw API key. Returns [keyId, keyData, username] or null.
|
||||
*/
|
||||
public function findKey(string $rawKey): ?array
|
||||
{
|
||||
$keys = $this->loadKeys();
|
||||
|
||||
foreach ($keys as $keyId => $keyData) {
|
||||
if (!is_array($keyData) || !isset($keyData['hash'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (self::verifyKey($rawKey, $keyData['hash'])) {
|
||||
return [
|
||||
'key_id' => $keyId,
|
||||
'data' => $keyData,
|
||||
'username' => $keyData['username'] ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all API keys from the data file.
|
||||
*/
|
||||
public function loadKeys(): array
|
||||
{
|
||||
if (static::$keysCache !== null) {
|
||||
return static::$keysCache;
|
||||
}
|
||||
|
||||
$file = $this->getKeysFile();
|
||||
if (!file_exists($file)) {
|
||||
static::$keysCache = [];
|
||||
return [];
|
||||
}
|
||||
|
||||
$data = Yaml::parse(file_get_contents($file)) ?? [];
|
||||
static::$keysCache = $data;
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save all API keys to the data file.
|
||||
*/
|
||||
protected function saveKeys(array $keys): void
|
||||
{
|
||||
$file = $this->getKeysFile();
|
||||
$dir = dirname($file);
|
||||
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0775, true);
|
||||
}
|
||||
|
||||
// Write atomically
|
||||
$tmp = $file . '.tmp';
|
||||
file_put_contents($tmp, Yaml::dump($keys));
|
||||
rename($tmp, $file);
|
||||
|
||||
static::$keysCache = $keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the API keys data file.
|
||||
*/
|
||||
protected function getKeysFile(): string
|
||||
{
|
||||
$locator = Grav::instance()['locator'];
|
||||
return $locator->findResource('user://data', true, true) . '/api-keys.yaml';
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate keys from user account files to centralized storage.
|
||||
*/
|
||||
public function migrateFromAccounts(): int
|
||||
{
|
||||
$grav = Grav::instance();
|
||||
$accounts = $grav['accounts'];
|
||||
$locator = $grav['locator'];
|
||||
$migrated = 0;
|
||||
|
||||
// Scan account files
|
||||
$accountDir = $locator->findResource('account://', true)
|
||||
?: $locator->findResource('user://accounts', true);
|
||||
|
||||
if (!$accountDir || !is_dir($accountDir)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
foreach (new \DirectoryIterator($accountDir) as $file) {
|
||||
if ($file->isDot() || !$file->isFile() || $file->getExtension() !== 'yaml') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$username = $file->getBasename('.yaml');
|
||||
$user = $accounts->load($username);
|
||||
|
||||
if (!$user->exists()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$userKeys = $user->get('api_keys', []);
|
||||
if (empty($userKeys)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$existingKeys = $this->loadKeys();
|
||||
|
||||
foreach ($userKeys as $keyId => $keyData) {
|
||||
if (!is_array($keyData) || isset($existingKeys[$keyId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$keyData['username'] = $username;
|
||||
$existingKeys[$keyId] = $keyData;
|
||||
$migrated++;
|
||||
}
|
||||
|
||||
$this->saveKeys($existingKeys);
|
||||
static::$keysCache = null; // Clear cache for next loadKeys()
|
||||
|
||||
// Remove api_keys from user account
|
||||
$user->undef('api_keys');
|
||||
$user->save();
|
||||
}
|
||||
|
||||
return $migrated;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Grav\Plugin\Api\Auth;
|
||||
|
||||
use Grav\Common\User\Interfaces\UserInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
interface AuthenticatorInterface
|
||||
{
|
||||
/**
|
||||
* Attempt to authenticate the request.
|
||||
* Returns the authenticated user, or null if this authenticator cannot handle the request.
|
||||
*/
|
||||
public function authenticate(ServerRequestInterface $request): ?UserInterface;
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Grav\Plugin\Api\Auth;
|
||||
|
||||
use Firebase\JWT\JWT;
|
||||
use Firebase\JWT\Key;
|
||||
use Grav\Common\Config\Config;
|
||||
use Grav\Common\Grav;
|
||||
use Grav\Common\User\Interfaces\UserCollectionInterface;
|
||||
use Grav\Common\User\Interfaces\UserInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Throwable;
|
||||
|
||||
class JwtAuthenticator implements AuthenticatorInterface
|
||||
{
|
||||
public function __construct(
|
||||
protected readonly Grav $grav,
|
||||
protected readonly Config $config,
|
||||
) {}
|
||||
|
||||
public function authenticate(ServerRequestInterface $request): ?UserInterface
|
||||
{
|
||||
$token = $this->extractBearerToken($request);
|
||||
if (!$token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->validateToken($token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an access token for a user.
|
||||
*/
|
||||
public function generateAccessToken(UserInterface $user): string
|
||||
{
|
||||
$secret = $this->getSecret();
|
||||
$algorithm = $this->config->get('plugins.api.auth.jwt_algorithm', 'HS256');
|
||||
$expiry = (int) $this->config->get('plugins.api.auth.jwt_expiry', 3600);
|
||||
|
||||
$payload = [
|
||||
'iss' => 'grav-api',
|
||||
'sub' => $user->username,
|
||||
'iat' => time(),
|
||||
'exp' => time() + $expiry,
|
||||
'type' => 'access',
|
||||
];
|
||||
|
||||
return JWT::encode($payload, $secret, $algorithm);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a refresh token for a user.
|
||||
*/
|
||||
public function generateRefreshToken(UserInterface $user): string
|
||||
{
|
||||
$secret = $this->getSecret();
|
||||
$algorithm = $this->config->get('plugins.api.auth.jwt_algorithm', 'HS256');
|
||||
$expiry = (int) $this->config->get('plugins.api.auth.jwt_refresh_expiry', 604800);
|
||||
|
||||
$payload = [
|
||||
'iss' => 'grav-api',
|
||||
'sub' => $user->username,
|
||||
'iat' => time(),
|
||||
'exp' => time() + $expiry,
|
||||
'type' => 'refresh',
|
||||
'jti' => bin2hex(random_bytes(16)),
|
||||
];
|
||||
|
||||
return JWT::encode($payload, $secret, $algorithm);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a short-lived, single-use challenge token for flows like 2FA
|
||||
* verification or password reset handoff. The $purpose field is stored in
|
||||
* the token's `type` claim and must match on validation.
|
||||
*/
|
||||
public function generateChallengeToken(UserInterface $user, string $purpose, int $ttl = 300): string
|
||||
{
|
||||
$secret = $this->getSecret();
|
||||
$algorithm = $this->config->get('plugins.api.auth.jwt_algorithm', 'HS256');
|
||||
|
||||
$payload = [
|
||||
'iss' => 'grav-api',
|
||||
'sub' => $user->username,
|
||||
'iat' => time(),
|
||||
'exp' => time() + $ttl,
|
||||
'type' => $purpose,
|
||||
'jti' => bin2hex(random_bytes(16)),
|
||||
];
|
||||
|
||||
return JWT::encode($payload, $secret, $algorithm);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a challenge token and return the associated user. The token must
|
||||
* carry the expected purpose in its `type` claim and must not have been
|
||||
* revoked. Returns null if invalid, expired, or revoked.
|
||||
*/
|
||||
public function validateChallengeToken(string $token, string $expectedPurpose): ?UserInterface
|
||||
{
|
||||
try {
|
||||
$secret = $this->getSecret();
|
||||
$algorithm = $this->config->get('plugins.api.auth.jwt_algorithm', 'HS256');
|
||||
|
||||
$decoded = JWT::decode($token, new Key($secret, $algorithm));
|
||||
|
||||
if (($decoded->type ?? null) !== $expectedPurpose) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->isTokenRevoked($decoded->jti ?? '')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var UserCollectionInterface $accounts */
|
||||
$accounts = $this->grav['accounts'];
|
||||
$user = $accounts->load($decoded->sub);
|
||||
|
||||
return $user->exists() ? $user : null;
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a refresh token and return the associated user.
|
||||
*/
|
||||
public function validateRefreshToken(string $token): ?UserInterface
|
||||
{
|
||||
try {
|
||||
$secret = $this->getSecret();
|
||||
$algorithm = $this->config->get('plugins.api.auth.jwt_algorithm', 'HS256');
|
||||
|
||||
$decoded = JWT::decode($token, new Key($secret, $algorithm));
|
||||
|
||||
if (($decoded->type ?? null) !== 'refresh') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if token has been revoked
|
||||
if ($this->isTokenRevoked($decoded->jti ?? '')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var UserCollectionInterface $accounts */
|
||||
$accounts = $this->grav['accounts'];
|
||||
$user = $accounts->load($decoded->sub);
|
||||
|
||||
return $user->exists() ? $user : null;
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a refresh token by its JTI.
|
||||
*/
|
||||
public function revokeToken(string $token): bool
|
||||
{
|
||||
try {
|
||||
$secret = $this->getSecret();
|
||||
$algorithm = $this->config->get('plugins.api.auth.jwt_algorithm', 'HS256');
|
||||
|
||||
$decoded = JWT::decode($token, new Key($secret, $algorithm));
|
||||
$jti = $decoded->jti ?? null;
|
||||
|
||||
if (!$jti) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->addRevokedToken($jti, $decoded->exp ?? time() + 604800);
|
||||
return true;
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected function extractBearerToken(ServerRequestInterface $request): ?string
|
||||
{
|
||||
// Primary: `X-API-Token` custom header. Preferred because it survives
|
||||
// FPM / FastCGI / CGI setups that silently strip the `Authorization`
|
||||
// header (MAMP's mod_fastcgi being the common trigger). Accepts either
|
||||
// a bare JWT or the traditional `Bearer <jwt>` form.
|
||||
$custom = trim($request->getHeaderLine('X-API-Token'));
|
||||
if ($custom !== '') {
|
||||
return str_starts_with($custom, 'Bearer ') ? substr($custom, 7) : $custom;
|
||||
}
|
||||
|
||||
// Legacy / standards-compliant: `Authorization: Bearer <jwt>`.
|
||||
// Kept for external clients (curl, Postman, CI) and backward compat.
|
||||
$header = $request->getHeaderLine('Authorization');
|
||||
if (str_starts_with($header, 'Bearer ')) {
|
||||
return substr($header, 7);
|
||||
}
|
||||
|
||||
// Fallback: query parameter for direct links (e.g. file downloads
|
||||
// where a browser <a download> tag can't attach a header).
|
||||
$params = $request->getQueryParams();
|
||||
if (!empty($params['token'])) {
|
||||
return $params['token'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function validateToken(string $token): ?UserInterface
|
||||
{
|
||||
try {
|
||||
$secret = $this->getSecret();
|
||||
$algorithm = $this->config->get('plugins.api.auth.jwt_algorithm', 'HS256');
|
||||
|
||||
$decoded = JWT::decode($token, new Key($secret, $algorithm));
|
||||
|
||||
// Only accept access tokens for API authentication
|
||||
if (($decoded->type ?? null) !== 'access') {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var UserCollectionInterface $accounts */
|
||||
$accounts = $this->grav['accounts'];
|
||||
$user = $accounts->load($decoded->sub);
|
||||
|
||||
return $user->exists() ? $user : null;
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
protected function getSecret(): string
|
||||
{
|
||||
$secret = $this->config->get('plugins.api.auth.jwt_secret', '');
|
||||
|
||||
// Auto-generate secret if not set
|
||||
if (!$secret) {
|
||||
$secret = bin2hex(random_bytes(32));
|
||||
$this->config->set('plugins.api.auth.jwt_secret', $secret);
|
||||
|
||||
// Persist the generated secret so subsequent requests can verify
|
||||
// tokens signed with it. Without persistence every request re-mints
|
||||
// a different secret, producing the classic "login succeeds, next
|
||||
// request 401" reauth loop on a fresh install.
|
||||
//
|
||||
// findResource() with defaults (absolute=true, all=false) returns
|
||||
// either the first existing path or false — the previous third
|
||||
// `true` flag returned an array and silently broke the fallback.
|
||||
$locator = $this->grav['locator'];
|
||||
$file = $locator->findResource('config://plugins/api.yaml');
|
||||
if (!$file) {
|
||||
$configDir = $locator->findResource('config://', true);
|
||||
if (!$configDir) {
|
||||
if (isset($this->grav['log'])) {
|
||||
$this->grav['log']->warning('api.auth: could not resolve config:// stream to persist JWT secret; tokens will be single-request only until jwt_secret is configured.');
|
||||
}
|
||||
return $secret;
|
||||
}
|
||||
$file = $configDir . '/plugins/api.yaml';
|
||||
}
|
||||
|
||||
$dir = dirname($file);
|
||||
if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
|
||||
if (isset($this->grav['log'])) {
|
||||
$this->grav['log']->warning(sprintf('api.auth: could not create %s to persist JWT secret.', $dir));
|
||||
}
|
||||
return $secret;
|
||||
}
|
||||
|
||||
$yaml = \Grav\Common\Yaml::parse(file_exists($file) ? file_get_contents($file) : '') ?? [];
|
||||
$yaml['auth']['jwt_secret'] = $secret;
|
||||
if (@file_put_contents($file, \Grav\Common\Yaml::dump($yaml)) === false) {
|
||||
if (isset($this->grav['log'])) {
|
||||
$this->grav['log']->warning(sprintf('api.auth: could not write JWT secret to %s — tokens will not survive past this request.', $file));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $secret;
|
||||
}
|
||||
|
||||
protected function isTokenRevoked(string $jti): bool
|
||||
{
|
||||
$file = $this->getRevokedTokensFile();
|
||||
if (!file_exists($file)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$revoked = json_decode(file_get_contents($file), true) ?: [];
|
||||
$this->cleanExpiredRevocations($revoked, $file);
|
||||
|
||||
return isset($revoked[$jti]);
|
||||
}
|
||||
|
||||
protected function addRevokedToken(string $jti, int $expiresAt): void
|
||||
{
|
||||
$file = $this->getRevokedTokensFile();
|
||||
$dir = dirname($file);
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0775, true);
|
||||
}
|
||||
|
||||
$revoked = [];
|
||||
if (file_exists($file)) {
|
||||
$revoked = json_decode(file_get_contents($file), true) ?: [];
|
||||
}
|
||||
|
||||
$revoked[$jti] = $expiresAt;
|
||||
$this->cleanExpiredRevocations($revoked, $file);
|
||||
}
|
||||
|
||||
protected function cleanExpiredRevocations(array &$revoked, string $file): void
|
||||
{
|
||||
$now = time();
|
||||
$revoked = array_filter($revoked, fn($exp) => $exp > $now);
|
||||
file_put_contents($file, json_encode($revoked));
|
||||
}
|
||||
|
||||
protected function getRevokedTokensFile(): string
|
||||
{
|
||||
$locator = $this->grav['locator'];
|
||||
return $locator->findResource('cache://api', true, true) . '/revoked_tokens.json';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Grav\Plugin\Api\Auth;
|
||||
|
||||
use Grav\Common\Grav;
|
||||
use Grav\Common\User\Interfaces\UserInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Throwable;
|
||||
|
||||
class SessionAuthenticator implements AuthenticatorInterface
|
||||
{
|
||||
public function __construct(
|
||||
protected readonly Grav $grav,
|
||||
) {}
|
||||
|
||||
public function authenticate(ServerRequestInterface $request): ?UserInterface
|
||||
{
|
||||
try {
|
||||
/** @var \Grav\Common\Session $session */
|
||||
$session = $this->grav['session'];
|
||||
|
||||
// Only if session is already started (e.g., from admin browsing)
|
||||
if (!$session->isStarted()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var UserInterface|null $user */
|
||||
$user = $session->user ?? null;
|
||||
|
||||
// Accept any authenticated session user, including one restored via the
|
||||
// login plugin's "remember me" cookie (which leaves the user
|
||||
// `authenticated` but not `authorized`). Without this, a remembered user
|
||||
// shows as signed in in the UI yet every write call is rejected until a
|
||||
// fresh login. Per-route permission checks (the user's `access` map,
|
||||
// refreshed below) still gate what they can actually do, and the
|
||||
// remember-me cookie is itself HttpOnly/Secure/SameSite.
|
||||
if ($user && $user->exists() && $user->authenticated) {
|
||||
// Session stores a serialized user snapshot whose `access` map
|
||||
// is frozen at the moment of login. Admin permission changes
|
||||
// wouldn't take effect until the session is destroyed. Refresh
|
||||
// `access` from disk so an operator's grant/revoke is honored
|
||||
// on the next API request without forcing a re-login.
|
||||
$username = (string) $user->get('username');
|
||||
if ($username !== '') {
|
||||
try {
|
||||
$fresh = $this->grav['accounts']->load($username);
|
||||
if ($fresh->exists()) {
|
||||
$user->set('access', $fresh->get('access'));
|
||||
$user->set('groups', $fresh->get('groups'));
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// Disk reload failed — fall through with stale access
|
||||
// rather than denying a legitimately authenticated user.
|
||||
}
|
||||
}
|
||||
return $user;
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// Session not available or errored
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user