310 lines
9.7 KiB
PHP
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);
|
|
}
|
|
}
|