Files
server/core/lib/Service/TokenService.php
2026-02-10 18:46:11 -05:00

310 lines
9.7 KiB
PHP

<?php
namespace KTXC\Service;
use KTXC\SessionTenant;
use KTXF\Cache\CacheScope;
use KTXF\Cache\EphemeralCacheInterface;
/**
* Token Service
*
* Unified service for JWT token operations including:
* - Token creation with configurable expiry and claims
* - Token validation with algorithm verification
* - Token blacklisting for revocation before natural expiry
* - User-wide token invalidation (logout all devices)
*
* Uses EphemeralCache for blacklist storage.
*/
class TokenService
{
private const ALLOWED_ALGORITHMS = ['HS256'];
private const CACHE_USAGE_BLACKLIST = 'token_blacklist';
private const CACHE_USAGE_USER_BLACKLIST = 'token_user_blacklist';
private string $algorithm = 'HS256';
public function __construct(
private readonly SessionTenant $sessionTenant,
private readonly EphemeralCacheInterface $cache,
) {
}
// =========================================================================
// Token Creation
// =========================================================================
/**
* Generate a unique JWT ID (jti) for token identification
*/
public function generateJti(): string
{
return bin2hex(random_bytes(16));
}
/**
* Create a JWT token with the given payload
*
* @param array $payload The token payload (claims)
* @param string $secretKey The secret key for signing
* @param int $expirationTime Token lifetime in seconds (default: 1 hour)
* @param string|null $jti Optional JWT ID (auto-generated if not provided)
* @return string The encoded JWT token
*/
public function createToken(array $payload, string $secretKey, int $expirationTime = 3600, ?string $jti = null): string
{
$header = [
'typ' => 'JWT',
'alg' => $this->algorithm
];
$payload['iat'] = time(); // Issued at
$payload['exp'] = time() + $expirationTime; // Expiration
// Add JWT ID for token identification and revocation support
$payload['jti'] = $jti ?? $this->generateJti();
$headerEncoded = $this->base64UrlEncode(json_encode($header));
$payloadEncoded = $this->base64UrlEncode(json_encode($payload));
$signature = $this->createSignature($headerEncoded . '.' . $payloadEncoded, $secretKey);
return $headerEncoded . '.' . $payloadEncoded . '.' . $signature;
}
// =========================================================================
// Token Validation
// =========================================================================
/**
* Validate a JWT token and return its payload
*
* @param string $token The JWT token to validate
* @param string $secretKey The secret key for verification
* @param bool $checkBlacklist Whether to check the blacklist (default: true)
* @return array|null The token payload if valid, null otherwise
*/
public function validateToken(string $token, string $secretKey, bool $checkBlacklist = true): ?array
{
$parts = explode('.', $token);
if (count($parts) !== 3) {
return null;
}
[$headerEncoded, $payloadEncoded, $signature] = $parts;
// Decode and validate header first
$header = json_decode($this->base64UrlDecode($headerEncoded), true);
if (!$header) {
return null;
}
// SECURITY: Validate algorithm to prevent "none" algorithm and algorithm switching attacks
if (!isset($header['alg']) || !in_array($header['alg'], self::ALLOWED_ALGORITHMS, true)) {
return null; // Reject tokens with unexpected algorithms
}
// Verify signature using our expected algorithm (not the one in the header)
$expectedSignature = $this->createSignature($headerEncoded . '.' . $payloadEncoded, $secretKey);
if (!hash_equals($signature, $expectedSignature)) {
return null;
}
// Decode payload
$payload = json_decode($this->base64UrlDecode($payloadEncoded), true);
if (!$payload) {
return null;
}
// Check expiration
if (isset($payload['exp']) && $payload['exp'] < time()) {
return null; // Token expired
}
// Check blacklist if enabled
if ($checkBlacklist) {
// Check if this specific token has been blacklisted (by jti)
if (isset($payload['jti']) && $this->isBlacklisted($payload['jti'])) {
return null;
}
// Check if user's tokens have been globally invalidated
if (isset($payload['identity'], $payload['iat'])) {
if ($this->isUserTokenBlacklisted($payload['identity'], $payload['iat'])) {
return null;
}
}
}
return $payload;
}
/**
* Refresh a token by creating a new one with fresh timestamps
*
* @param string $token The token to refresh
* @param string $secretKey The secret key
* @return string|null The new token, or null if original was invalid
*/
public function refreshToken(string $token, string $secretKey): ?string
{
$payload = $this->validateToken($token, $secretKey);
if (!$payload) {
return null;
}
// Remove old timestamps and jti (new token gets new jti)
unset($payload['iat'], $payload['exp'], $payload['jti']);
// Create new token with fresh timestamps and new jti
return $this->createToken($payload, $secretKey);
}
// =========================================================================
// Token Blacklisting
// =========================================================================
/**
* Add a token to the blacklist (revoke it)
*
* @param string $jti The JWT ID to blacklist
* @param int $expiresAt Unix timestamp when the token expires (for cleanup)
*/
public function blacklist(string $jti, int $expiresAt): void
{
$ttl = max($expiresAt - time(), 60); // Minimum 60 seconds
$this->cache->set(
$this->getTokenCacheKey($jti),
$expiresAt,
CacheScope::Tenant,
self::CACHE_USAGE_BLACKLIST,
$ttl
);
}
/**
* Check if a token is blacklisted
*
* @param string $jti The JWT ID to check
* @return bool True if blacklisted, false otherwise
*/
public function isBlacklisted(string $jti): bool
{
return $this->cache->has(
$this->getTokenCacheKey($jti),
CacheScope::Tenant,
self::CACHE_USAGE_BLACKLIST
);
}
/**
* Remove a token from the blacklist
*
* @param string $jti The JWT ID to remove
*/
public function unblacklist(string $jti): void
{
$this->cache->delete(
$this->getTokenCacheKey($jti),
CacheScope::Tenant,
self::CACHE_USAGE_BLACKLIST
);
}
/**
* Blacklist all tokens for a user issued before a timestamp
* Used for "logout all devices" functionality
*
* @param string $identity User identity
* @param int $beforeTimestamp Tokens issued before this time are invalid
*/
public function blacklistUserTokensBefore(string $identity, int $beforeTimestamp): void
{
// Store for 30 days (longer than any token lifetime)
$this->cache->set(
$this->getUserCacheKey($identity),
$beforeTimestamp,
CacheScope::Tenant,
self::CACHE_USAGE_USER_BLACKLIST,
2592000 // 30 days
);
}
/**
* Check if a user's token was issued before the blacklist timestamp
*
* @param string $identity User identity
* @param int $issuedAt Token's iat claim
* @return bool True if token should be rejected
*/
public function isUserTokenBlacklisted(string $identity, int $issuedAt): bool
{
$blacklistBefore = $this->cache->get(
$this->getUserCacheKey($identity),
CacheScope::Tenant,
self::CACHE_USAGE_USER_BLACKLIST
);
if ($blacklistBefore === null) {
return false;
}
return $issuedAt < (int) $blacklistBefore;
}
/**
* Clear user's "logout all devices" blacklist
*
* @param string $identity User identity
*/
public function clearUserBlacklist(string $identity): void
{
$this->cache->delete(
$this->getUserCacheKey($identity),
CacheScope::Tenant,
self::CACHE_USAGE_USER_BLACKLIST
);
}
// =========================================================================
// Private Helpers
// =========================================================================
private function createSignature(string $data, string $secretKey): string
{
$signature = hash_hmac('sha256', $data, $secretKey, true);
return $this->base64UrlEncode($signature);
}
private function base64UrlEncode(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
private function base64UrlDecode(string $data): string
{
return base64_decode(str_pad(strtr($data, '-_', '+/'), strlen($data) % 4, '=', STR_PAD_RIGHT));
}
/**
* Generate cache key for token blacklist
*/
private function getTokenCacheKey(string $jti): string
{
return 'jti_' . hash('sha256', $jti);
}
/**
* Generate cache key for user blacklist
*/
private function getUserCacheKey(string $identity): string
{
return 'user_' . hash('sha256', $identity);
}
}