Initial Version

This commit is contained in:
root
2025-12-21 10:09:54 -05:00
commit 2fbddd7dbc
366 changed files with 41999 additions and 0 deletions

View File

@@ -0,0 +1,228 @@
<?php
namespace KTXC\Service;
use KTXC\Db\DataStore;
use KTXC\Db\Collection;
use KTXC\Db\UTCDateTime;
use KTXC\SessionTenant;
class ConfigurationService
{
// Service constants
private const TABLE_NAME = 'system_configuration';
// Type constants for configuration values
public const TYPE_NULL = 0;
public const TYPE_STRING = 1;
public const TYPE_INTEGER = 2;
public const TYPE_FLOAT = 3;
public const TYPE_BOOLEAN = 4;
public const TYPE_ARRAY = 5;
public const TYPE_JSON = 6;
private Collection $collection;
public function __construct(
DataStore $store,
private readonly SessionTenant $tenant
) {
// DataStore provides selectCollection method
$this->collection = $store->selectCollection(self::TABLE_NAME);
$this->collection->createIndex(['did' => 1, 'path' => 1, 'key' => 1], ['unique' => true]);
}
/**
* Get a configuration value by path and key
*/
public function get(string $path, string $key, mixed $default = null, ?string $tenant = null): mixed
{
if ($tenant === null && !$this->tenant->isConfigured()) {
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
} elseif ($tenant === null) {
$tenant = $this->tenant->identifier();
}
$doc = $this->collection->findOne(['did' => $tenant, 'path' => $path, 'key' => $key]);
if (!$doc) { return $default; }
$value = $doc['value'] ?? ($doc['default'] ?? null);
if ($value === null) { return $default; }
return $this->convertFromDatabase((string)$value, (int)$doc['type']);
}
/**
* Set a configuration value
*/
public function set(string $path, string $key, mixed $value, mixed $default = null, ?string $tenant = null): bool
{
if ($tenant === null && !$this->tenant->isConfigured()) {
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
} elseif ($tenant === null) {
$tenant = $this->tenant->identifier();
}
$type = $this->determineType($value);
$serializedValue = $this->convertToDatabase($value, $type);
$serializedDefault = $default !== null ? $this->convertToDatabase($default, $type) : null;
$this->collection->updateOne(
['did' => $tenant, 'path' => $path, 'key' => $key],
['$set' => [
'did' => $tenant,
'path' => $path,
'key' => $key,
'value' => $serializedValue,
'type' => $type,
'default' => $serializedDefault,
'updated_at' => $this->bsonUtcDateTime()
], '$setOnInsert' => [ 'created_at' => $this->bsonUtcDateTime() ]],
['upsert' => true]
);
return true;
}
/**
* Get all configuration values for a specific path
*/
public function getByPath(?string $path = null, bool $subset = false, ?string $tenant = null): array
{
if ($tenant === null && !$this->tenant->isConfigured()) {
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
} elseif ($tenant === null) {
$tenant = $this->tenant->identifier();
}
$filter = ['did' => $tenant];
if ($path !== null) {
if ($subset) {
$filter['$or'] = [
['path' => $path],
['path' => ['$regex' => '^' . preg_quote($path, '/') . '/']]
];
} else {
$filter['path'] = $path;
}
}
$cursor = $this->collection->find($filter);
$configurations = [];
foreach ($cursor as $doc) {
$value = $doc['value'] ?? ($doc['default'] ?? null);
$convertedValue = $value !== null ? $this->convertFromDatabase((string)$value, (int)$doc['type']) : null;
$configurations[$doc['path']] = [$doc['key'] => $convertedValue];
}
return $configurations;
}
/**
* Delete a configuration value
*/
public function delete(string $path, string $key, ?string $tenant = null): bool
{
if ($tenant === null && !$this->tenant->isConfigured()) {
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
} elseif ($tenant === null) {
$tenant = $this->tenant->identifier();
}
$this->collection->deleteOne(['did' => $tenant, 'path' => $path, 'key' => $key]);
return true;
}
/**
* Delete all configuration values for a specific path
*/
public function deleteByPath(string $path, bool $includeSubPaths = false, ?string $tenant = null): bool
{
if ($tenant === null && !$this->tenant->isConfigured()) {
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
} elseif ($tenant === null) {
$tenant = $this->tenant->identifier();
}
$filter = ['did' => $tenant];
if ($includeSubPaths) {
$filter['$or'] = [
['path' => $path],
['path' => ['$regex' => '^' . preg_quote($path, '/') . '/']]
];
} else {
$filter['path'] = $path;
}
$this->collection->deleteMany($filter);
return true;
}
/**
* Check if a configuration exists
*/
public function exists(string $path, string $key, ?string $tenant = null): bool
{
if ($tenant === null && !$this->tenant->isConfigured()) {
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
} elseif ($tenant === null) {
$tenant = $this->tenant->identifier();
}
return $this->collection->countDocuments(['did' => $tenant, 'path' => $path, 'key' => $key]) > 0;
}
/**
* Determine the type of a PHP value
*/
private function determineType(mixed $value): int
{
return match (true) {
is_null($value) => self::TYPE_NULL,
is_bool($value) => self::TYPE_BOOLEAN,
is_int($value) => self::TYPE_INTEGER,
is_float($value) => self::TYPE_FLOAT,
is_array($value) => self::TYPE_ARRAY,
is_string($value) && $this->isJson($value) => self::TYPE_JSON,
default => self::TYPE_STRING
};
}
/**
* Convert a PHP value to database format
*/
private function convertToDatabase(mixed $value, int $type): string
{
return match ($type) {
self::TYPE_NULL => '',
self::TYPE_BOOLEAN => $value ? '1' : '0',
self::TYPE_INTEGER => (string)$value,
self::TYPE_FLOAT => (string)$value,
self::TYPE_ARRAY, self::TYPE_JSON => json_encode($value),
default => (string)$value
};
}
/**
* Convert a database value to PHP format
*/
private function convertFromDatabase(string $value, int $type): mixed
{
return match ($type) {
self::TYPE_NULL => null,
self::TYPE_BOOLEAN => $value === '1',
self::TYPE_INTEGER => (int)$value,
self::TYPE_FLOAT => (float)$value,
self::TYPE_ARRAY, self::TYPE_JSON => json_decode($value, true),
default => $value
};
}
/**
* Check if a string is valid JSON
*/
private function isJson(string $string): bool
{
json_decode($string);
return json_last_error() === JSON_ERROR_NONE;
}
/**
* Create a UTCDateTime for timestamp fields
*/
private function bsonUtcDateTime(): UTCDateTime
{
return UTCDateTime::now();
}
}

View File

@@ -0,0 +1,630 @@
<?php
declare(strict_types=1);
namespace KTXC\Service;
use KTXC\Http\Request\Request;
use KTXC\Models\Firewall\FirewallRuleObject;
use KTXC\Models\Firewall\FirewallLogObject;
use KTXC\Stores\FirewallStore;
use KTXC\SessionTenant;
use KTXF\Event\EventBus;
use KTXF\Event\SecurityEvent;
use KTXF\IpUtils;
/**
* Firewall service for IP/device-based access control
*
* Features:
* - IP allow/block lists per tenant
* - CIDR range support
* - Device fingerprint blocking
* - Automatic blocking on brute force detection
* - Event-driven integration
*/
class FirewallService
{
// Default thresholds for auto-blocking
private const DEFAULT_MAX_AUTH_FAILURES = 5;
private const DEFAULT_AUTH_FAILURE_WINDOW = 300; // 5 minutes
private const DEFAULT_AUTO_BLOCK_DURATION = 3600; // 1 hour
// Configuration keys
private const CONFIG_MAX_FAILURES = 'firewall.maxAuthFailures';
private const CONFIG_FAILURE_WINDOW = 'firewall.authFailureWindow';
private const CONFIG_AUTO_BLOCK_DURATION = 'firewall.autoBlockDuration';
private const CONFIG_ENABLED = 'firewall.enabled';
/** @var FirewallRuleObject[]|null */
private ?array $rulesCache = null;
public function __construct(
private readonly FirewallStore $store,
private readonly SessionTenant $tenant,
private readonly EventBus $eventBus
) {
// Listen for auth failures to detect brute force
$this->eventBus->subscribe(
SecurityEvent::AUTH_FAILURE,
[$this, 'handleAuthFailure'],
100 // High priority
);
// Log all security events asynchronously
$this->eventBus->subscribeAsync(
SecurityEvent::AUTH_FAILURE,
[$this, 'logSecurityEvent']
);
$this->eventBus->subscribeAsync(
SecurityEvent::AUTH_SUCCESS,
[$this, 'logSecurityEvent']
);
$this->eventBus->subscribeAsync(
SecurityEvent::ACCESS_DENIED,
[$this, 'logSecurityEvent']
);
$this->eventBus->subscribeAsync(
SecurityEvent::BRUTE_FORCE_DETECTED,
[$this, 'logSecurityEvent']
);
}
/**
* Check firewall rules for a request
* Returns a Response if blocked, null if allowed
*/
public function authorized(Request $request): bool
{
$ipAddress = $request->getClientIp() ?? '0.0.0.0';
$deviceFingerprint = $request->headers->get('X-Device-Fingerprint');
$result = $this->analyze($ipAddress, $deviceFingerprint);
if ($result->isBlocked()) {
return false;
}
return true;
}
/**
* Check if a request is allowed based on IP and device fingerprint
*/
public function analyze(
string $ipAddress,
?string $deviceFingerprint = null
): FirewallAnalyzeResult {
// Check if firewall is enabled for this tenant
if (!$this->isEnabled()) {
return new FirewallAnalyzeResult(true);
}
$tenantId = $this->tenant->identifier();
if (!$tenantId) {
return new FirewallAnalyzeResult(true);
}
$rules = $this->getActiveRules();
// First check for explicit allow rules (whitelist takes precedence)
foreach ($rules as $rule) {
if ($rule->getAction() !== FirewallRuleObject::ACTION_ALLOW) {
continue;
}
if ($this->ruleMatchesRequest($rule, $ipAddress, $deviceFingerprint)) {
return new FirewallAnalyzeResult(true, $rule->getId(), 'Explicitly allowed');
}
}
// Then check for block rules
foreach ($rules as $rule) {
if ($rule->getAction() !== FirewallRuleObject::ACTION_BLOCK) {
continue;
}
if ($this->ruleMatchesRequest($rule, $ipAddress, $deviceFingerprint)) {
$this->publishAccessDenied($ipAddress, $deviceFingerprint, $rule);
return new FirewallAnalyzeResult(false, $rule->getId(), $rule->getReason());
}
}
return new FirewallAnalyzeResult(true);
}
/**
* Check if a rule matches the request
*/
private function ruleMatchesRequest(
FirewallRuleObject $rule,
string $ipAddress,
?string $deviceFingerprint
): bool {
$type = $rule->getType();
$value = $rule->getValue();
return match ($type) {
FirewallRuleObject::TYPE_IP => $ipAddress === $value,
FirewallRuleObject::TYPE_IP_RANGE => IpUtils::checkIp($ipAddress, $value),
FirewallRuleObject::TYPE_DEVICE => $deviceFingerprint !== null && $deviceFingerprint === $value,
default => false,
};
}
/**
* Handle authentication failure event
*/
public function handleAuthFailure(SecurityEvent $event): void
{
$ipAddress = $event->getIpAddress();
$tenantId = $event->getTenantId() ?? $this->tenant->identifier();
if (!$ipAddress || !$tenantId) {
return;
}
// Check for brute force
$windowSeconds = $this->getConfig(
self::CONFIG_FAILURE_WINDOW,
self::DEFAULT_AUTH_FAILURE_WINDOW
);
$maxFailures = $this->getConfig(
self::CONFIG_MAX_FAILURES,
self::DEFAULT_MAX_AUTH_FAILURES
);
$failureCount = $this->store->countRecentFailures(
$tenantId,
$ipAddress,
$windowSeconds
);
// Include current failure in count
$failureCount++;
if ($failureCount >= $maxFailures) {
$this->handleBruteForce($ipAddress, $failureCount, $windowSeconds);
}
}
/**
* Handle detected brute force attack
*/
private function handleBruteForce(
string $ipAddress,
int $failureCount,
int $windowSeconds
): void {
// Publish brute force event
$event = SecurityEvent::bruteForceDetected($ipAddress, $failureCount, $windowSeconds);
$event->setTenantId($this->tenant->identifier());
$this->eventBus->publish($event);
// Auto-block the IP
$blockDuration = $this->getConfig(
self::CONFIG_AUTO_BLOCK_DURATION,
self::DEFAULT_AUTO_BLOCK_DURATION
);
$this->blockIp(
$ipAddress,
sprintf('Auto-blocked: %d failed auth attempts in %d seconds', $failureCount, $windowSeconds),
null, // System-created
$blockDuration
);
}
/**
* Log security event to firewall logs
*/
public function logSecurityEvent(SecurityEvent $event): void
{
$tenantId = $event->getTenantId() ?? $this->tenant->identifier();
if (!$tenantId) {
return;
}
$log = new FirewallLogObject();
$log->setTenantId($tenantId)
->setIpAddress($event->getIpAddress())
->setDeviceFingerprint($event->getDeviceFingerprint())
->setUserAgent($event->getUserAgent())
->setRequestPath($event->getRequestPath())
->setRequestMethod($event->getRequestMethod())
->setEventType($this->mapEventToLogType($event->getName()))
->setResult($this->mapEventToResult($event->getName()))
->setIdentityId($event->getUserId())
->setTimestamp(new \DateTimeImmutable())
->setMetadata($event->getData());
$this->store->createLog($log);
}
/**
* Map security event name to log event type
*/
private function mapEventToLogType(string $eventName): string
{
return match ($eventName) {
SecurityEvent::AUTH_FAILURE => FirewallLogObject::EVENT_AUTH_FAILURE,
SecurityEvent::AUTH_SUCCESS => FirewallLogObject::EVENT_ACCESS_CHECK,
SecurityEvent::BRUTE_FORCE_DETECTED => FirewallLogObject::EVENT_BRUTE_FORCE,
SecurityEvent::RATE_LIMIT_EXCEEDED => FirewallLogObject::EVENT_RATE_LIMIT,
SecurityEvent::ACCESS_DENIED => FirewallLogObject::EVENT_RULE_MATCH,
SecurityEvent::SUSPICIOUS_ACTIVITY => FirewallLogObject::EVENT_SUSPICIOUS,
default => FirewallLogObject::EVENT_ACCESS_CHECK,
};
}
/**
* Map security event to result
*/
private function mapEventToResult(string $eventName): string
{
return match ($eventName) {
SecurityEvent::AUTH_SUCCESS,
SecurityEvent::ACCESS_GRANTED => FirewallLogObject::RESULT_ALLOWED,
default => FirewallLogObject::RESULT_BLOCKED,
};
}
/**
* Publish access denied event
*/
private function publishAccessDenied(
string $ipAddress,
?string $deviceFingerprint,
FirewallRuleObject $rule
): void {
$event = SecurityEvent::accessDenied(
$ipAddress,
$deviceFingerprint,
$rule->getId(),
$rule->getReason()
);
$event->setTenantId($this->tenant->identifier());
$this->eventBus->publish($event);
}
// ========================================
// Rule Management
// ========================================
/**
* Block an IP address
*/
public function blockIp(
string $ipAddress,
?string $reason = null,
?string $createdBy = null,
?int $durationSeconds = null
): FirewallRuleObject {
$tenantId = $this->tenant->identifier();
if (!$tenantId) {
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
}
// Check if already blocked
$existing = $this->store->findExactIpRule(
$tenantId,
$ipAddress,
FirewallRuleObject::ACTION_BLOCK
);
if ($existing) {
return $existing;
}
$rule = new FirewallRuleObject();
$rule->setTenantId($tenantId)
->setType(FirewallRuleObject::TYPE_IP)
->setAction(FirewallRuleObject::ACTION_BLOCK)
->setValue($ipAddress)
->setReason($reason ?? 'Blocked by administrator')
->setCreatedBy($createdBy)
->setCreatedAt(new \DateTimeImmutable())
->setEnabled(true);
if ($durationSeconds !== null) {
$rule->setExpiresAt(
(new \DateTimeImmutable())->modify("+{$durationSeconds} seconds")
);
}
$this->store->depositRule($rule);
$this->clearRulesCache();
// Publish event
$event = new SecurityEvent(SecurityEvent::IP_BLOCKED, ['ip' => $ipAddress, 'reason' => $reason]);
$event->setIpAddress($ipAddress)
->setReason($reason)
->setTenantId($tenantId);
$this->eventBus->publish($event);
return $rule;
}
/**
* Allow an IP address (whitelist)
*/
public function allowIp(
string $ipAddress,
?string $reason = null,
?string $createdBy = null
): FirewallRuleObject {
$tenantId = $this->tenant->identifier();
if (!$tenantId) {
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
}
$rule = new FirewallRuleObject();
$rule->setTenantId($tenantId)
->setType(FirewallRuleObject::TYPE_IP)
->setAction(FirewallRuleObject::ACTION_ALLOW)
->setValue($ipAddress)
->setReason($reason ?? 'Allowed by administrator')
->setCreatedBy($createdBy)
->setCreatedAt(new \DateTimeImmutable())
->setEnabled(true);
$this->store->depositRule($rule);
$this->clearRulesCache();
// Publish event
$event = new SecurityEvent(SecurityEvent::IP_ALLOWED, ['ip' => $ipAddress, 'reason' => $reason]);
$event->setIpAddress($ipAddress)
->setReason($reason)
->setTenantId($tenantId);
$this->eventBus->publish($event);
return $rule;
}
/**
* Block an IP range (CIDR notation)
*/
public function blockIpRange(
string $cidr,
?string $reason = null,
?string $createdBy = null
): FirewallRuleObject {
$tenantId = $this->tenant->identifier();
if (!$tenantId) {
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
}
$rule = new FirewallRuleObject();
$rule->setTenantId($tenantId)
->setType(FirewallRuleObject::TYPE_IP_RANGE)
->setAction(FirewallRuleObject::ACTION_BLOCK)
->setValue($cidr)
->setReason($reason ?? 'Range blocked by administrator')
->setCreatedBy($createdBy)
->setCreatedAt(new \DateTimeImmutable())
->setEnabled(true);
$this->store->depositRule($rule);
$this->clearRulesCache();
return $rule;
}
/**
* Block a device fingerprint
*/
public function blockDevice(
string $fingerprint,
?string $reason = null,
?string $createdBy = null,
?int $durationSeconds = null
): FirewallRuleObject {
$tenantId = $this->tenant->identifier();
if (!$tenantId) {
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
}
$rule = new FirewallRuleObject();
$rule->setTenantId($tenantId)
->setType(FirewallRuleObject::TYPE_DEVICE)
->setAction(FirewallRuleObject::ACTION_BLOCK)
->setValue($fingerprint)
->setReason($reason ?? 'Device blocked by administrator')
->setCreatedBy($createdBy)
->setCreatedAt(new \DateTimeImmutable())
->setEnabled(true);
if ($durationSeconds !== null) {
$rule->setExpiresAt(
(new \DateTimeImmutable())->modify("+{$durationSeconds} seconds")
);
}
$this->store->depositRule($rule);
$this->clearRulesCache();
// Publish event
$event = new SecurityEvent(SecurityEvent::DEVICE_BLOCKED, ['device' => $fingerprint, 'reason' => $reason]);
$event->setDeviceFingerprint($fingerprint)
->setReason($reason)
->setTenantId($tenantId);
$this->eventBus->publish($event);
return $rule;
}
/**
* Remove a rule by ID
*/
public function removeRule(string $ruleId): bool
{
$rule = $this->store->fetchRule($ruleId);
if (!$rule) {
return false;
}
// Verify tenant ownership
if ($rule->getTenantId() !== $this->tenant->identifier()) {
return false;
}
$this->store->destroyRule($rule);
$this->clearRulesCache();
return true;
}
/**
* Disable a rule (soft delete)
*/
public function disableRule(string $ruleId): bool
{
$rule = $this->store->fetchRule($ruleId);
if (!$rule) {
return false;
}
// Verify tenant ownership
if ($rule->getTenantId() !== $this->tenant->identifier()) {
return false;
}
$rule->setEnabled(false);
$this->store->depositRule($rule);
$this->clearRulesCache();
return true;
}
/**
* Get all rules for current tenant
*/
public function listRules(bool $activeOnly = true): array
{
$tenantId = $this->tenant->identifier();
if (!$tenantId) {
return [];
}
return $this->store->listRules($tenantId, $activeOnly);
}
/**
* Get firewall logs for current tenant
*/
public function getLogs(
?string $ipAddress = null,
?string $eventType = null,
?string $result = null,
int $limit = 100
): array {
$tenantId = $this->tenant->identifier();
if (!$tenantId) {
return [];
}
return $this->store->listLogs($tenantId, $ipAddress, $eventType, $result, $limit);
}
/**
* Get blocked requests count
*/
public function getBlockedCount(?\DateTimeImmutable $since = null): int
{
$tenantId = $this->tenant->identifier();
if (!$tenantId) {
return 0;
}
return $this->store->countBlockedRequests($tenantId, $since);
}
// ========================================
// Helpers
// ========================================
/**
* Check if firewall is enabled for current tenant
*/
private function isEnabled(): bool
{
return (bool) $this->getConfig(self::CONFIG_ENABLED, true);
}
/**
* Get configuration value
*/
private function getConfig(string $key, mixed $default = null): mixed
{
$config = $this->tenant->configuration();
$parts = explode('.', $key);
foreach ($parts as $part) {
if (!is_array($config) || !array_key_exists($part, $config)) {
return $default;
}
$config = $config[$part];
}
return $config;
}
/**
* Get active rules (cached)
* @return FirewallRuleObject[]
*/
private function getActiveRules(): array
{
if ($this->rulesCache === null) {
$tenantId = $this->tenant->identifier();
$this->rulesCache = $tenantId
? $this->store->listRules($tenantId, true)
: [];
}
return $this->rulesCache;
}
/**
* Clear rules cache
*/
private function clearRulesCache(): void
{
$this->rulesCache = null;
}
/**
* Cleanup maintenance tasks
*/
public function cleanup(): array
{
$expiredRules = $this->store->cleanupExpiredRules();
$oldLogs = $this->store->cleanupOldLogs(30);
return [
'expiredRules' => $expiredRules,
'oldLogs' => $oldLogs,
];
}
}
/**
* Result of a firewall check
*/
class FirewallAnalyzeResult
{
public function __construct(
public readonly bool $allowed,
public readonly ?string $ruleId = null,
public readonly ?string $reason = null
) {}
public function isAllowed(): bool
{
return $this->allowed;
}
public function isBlocked(): bool
{
return !$this->allowed;
}
}

View File

@@ -0,0 +1,202 @@
<?php
declare(strict_types=1);
namespace KTXC\Service;
use KTXC\Http\Request\Request;
use KTXC\Models\Identity\User;
use KTXC\SessionTenant;
/**
* Security Service
*
* Handles request-level authentication (token validation).
* Authentication orchestration is handled by AuthenticationManager.
*
* This service is used by the Kernel to authenticate incoming requests.
*/
class SecurityService
{
private string $securityCode;
public function __construct(
private readonly TokenService $tokenService,
private readonly UserService $userService,
private readonly SessionTenant $sessionTenant
) {
$this->securityCode = $this->sessionTenant->configuration()->security()->code();
}
/**
* Authenticate a request and return the user if valid
*
* @param Request $request The HTTP request to authenticate
* @return User|null The authenticated user, or null if not authenticated
*/
public function authenticate(Request $request): ?User
{
$authorization = $request->headers->get('Authorization');
$cookieToken = $request->cookies->get('accessToken');
// Cookie token takes precedence
if ($cookieToken) {
return $this->authenticateJWT($cookieToken);
}
if ($authorization) {
if (str_starts_with($authorization, 'Bearer ')) {
$token = substr($authorization, 7);
return $this->authenticateBearer($token);
}
if (str_starts_with($authorization, 'Basic ')) {
$decoded = base64_decode(substr($authorization, 6) ?: '', true);
if ($decoded !== false) {
[$identity, $secret] = array_pad(explode(':', $decoded, 2), 2, null);
if ($identity !== null && $secret !== null) {
return $this->authenticateBasicHeader($identity, $secret);
}
}
}
}
return null;
}
/**
* Authenticate JWT token from cookie or header
*/
public function authenticateJWT(string $token): ?User
{
$payload = $this->tokenService->validateToken($token, $this->securityCode);
if (!$payload) {
return null;
}
// Verify user still exists
if ($this->userService->fetchByIdentifier($payload['identifier']) === null) {
return null;
}
$user = new User();
$user->populate($payload, 'jwt');
return $user;
}
/**
* Authenticate Bearer token
*/
public function authenticateBearer(string $token): ?User
{
return $this->authenticateJWT($token);
}
/**
* Authenticate HTTP Basic header (for API access)
* Note: This is for request authentication, not login
*/
private function authenticateBasicHeader(string $identity, string $credentials): ?User
{
// For Basic auth headers, we need to validate against the provider
// This is a simplified flow for API access
$provider = $this->providerRegistry->resolve('default');
if ($provider === null) {
return null;
}
$result = $provider->authenticate($identity, $credentials);
if (!$result->isSuccess()) {
return null;
}
return $this->getUserByIdentity($identity);
}
// =========================================================================
// Token Operations (delegated to AuthenticationManager for new flows)
// These are kept for backwards compatibility during transition
// =========================================================================
/**
* @deprecated Use AuthenticationManager::createTokens() instead
*/
public function createAccessToken(array $payload): string
{
return $this->tokenService->createToken($payload, $this->securityCode, 900);
}
/**
* @deprecated Use AuthenticationManager::createTokens() instead
*/
public function createRefreshToken(array $payload): string
{
$refreshPayload = [
'tenant' => $payload['tenant'] ?? null,
'identifier' => $payload['identifier'],
'identity' => $payload['identity'],
'type' => 'refresh'
];
return $this->tokenService->createToken($refreshPayload, $this->securityCode, 604800);
}
/**
* @deprecated Use AuthenticationManager::refreshAccessToken() instead
*/
public function validateRefreshToken(string $refreshToken): ?User
{
$payload = $this->tokenService->validateToken($refreshToken, $this->securityCode);
if (!$payload) {
return null;
}
if (!isset($payload['type']) || $payload['type'] !== 'refresh') {
return null;
}
$identifier = $payload['identifier'] ?? null;
if (!$identifier || $this->providerRegistry->validateUser($identifier) === false) {
return null;
}
$user = new User();
$user->populate([
'identifier' => $payload['identifier'],
'identity' => $payload['identity'],
'tenant' => $payload['tenant'] ?? null,
], 'jwt');
return $user;
}
/**
* @deprecated Use AuthenticationManager::logout() instead
*/
public function logout(?string $jti = null, ?int $exp = null): void
{
if ($jti !== null) {
$expiresAt = $exp ?? (time() + 86400);
$this->tokenService->blacklist($jti, $expiresAt);
}
}
/**
* @deprecated Use AuthenticationManager::logoutAll() instead
*/
public function logoutAllDevices(string $identity): void
{
$this->tokenService->blacklistUserTokensBefore($identity, time());
}
/**
* Extract token claims (for logout to get jti/exp)
*/
public function extractTokenClaims(string $token): ?array
{
return $this->tokenService->validateToken($token, $this->securityCode, checkBlacklist: false);
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace KTXC\Service;
use KTXC\Models\Tenant\TenantObject;
use KTXC\Stores\TenantStore;
class TenantService
{
public function __construct(protected readonly TenantStore $store)
{
}
public function fetchByDomain(string $domain): ?TenantObject
{
return $this->store->fetchByDomain($domain);
}
}

View File

@@ -0,0 +1,309 @@
<?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);
}
}

View File

@@ -0,0 +1,137 @@
<?php
namespace KTXC\Service;
use KTXC\Identity\Provider\DefaultIdentityProvider;
use KTXC\Models\Identity\User;
use KTXC\Server;
use KTXC\SessionTenant;
/**
* User Manager Service
* Manages authentication providers and user operations across domains
*/
class UserManagerService
{
private array $availableIdentityProviders = [];
private array $cachedIdentityProviders = [];
public function __construct(
private readonly SessionTenant $tenant,
private readonly UserService $userService
) {
// Register the default identity provider
$this->providerRegister('default', DefaultIdentityProvider::class);
}
/**
* Register an authentication provider
*/
public function providerRegister(string $identifier, string $class): void
{
$this->availableIdentityProviders[$identifier] = $class;
}
public function providerList(?array $filter = null): array
{
$requestedProviders = $filter ? $filter : array_keys($this->availableIdentityProviders);
$result = [];
foreach ($requestedProviders as $identifier) {
// Check if provider is available
if (!isset($this->availableIdentityProviders[$identifier])) {
continue;
}
// Check cache first
if (isset($this->cachedIdentityProviders[$identifier])) {
$result[$identifier] = $this->cachedIdentityProviders[$identifier];
} else {
// Instantiate the provider and cache it
$providerClass = $this->availableIdentityProviders[$identifier];
try {
// Server::get automatically detects context from calling object
$providerInstance = Server::runtimeContainer()->get($providerClass);
// Cache the instance
$this->cachedIdentityProviders[$identifier] = $providerInstance;
$result[$identifier] = $providerInstance;
} catch (\Exception $e) {
// Skip providers that can't be resolved
error_log("Failed to resolve identity provider {$providerClass}: " . $e->getMessage());
continue;
}
}
}
return $result;
}
/**
* Authenticate user against enabled providers
*/
public function authenticate(string $identity, string $credential): User | null
{
// validate identity and credential
if (empty($identity) || empty($credential)) {
return null;
}
// retrieve user by identity
$user = $this->userService->fetchByIdentity($identity);
// determine if user has logged in before
if (!$user) {
return null;
}
// determine if user has a identity provider assigned
if ($user->getProvider() === null) {
return null;
}
$authenticated = $this->authenticateExtant($user->getProvider(), $identity, $credential);
if ($authenticated) {
return $user;
}
return null;
}
public function authenticateExtant(string $provider, string $identity, string $credential): bool {
// determine if provider is enabled
$providers = $this->providerList([$provider]);
if (empty($providers)) {
return false;
}
// Get the first (and should be only) provider
$provider = reset($providers);
// authenticate user against provider
$user = $provider->authenticate($identity, $credential);
return $user;
}
public function validate(string $identifier): Bool
{
$data = $this->userService->fetchByIdentifier($identifier);
if (!$data) {
return false;
}
if ($data['enabled'] !== true) {
return false;
}
if ($data['tid'] !== $this->tenant->identifier()) {
return false;
}
return true;
}
}

View File

@@ -0,0 +1,278 @@
<?php
declare(strict_types=1);
namespace KTXC\Service;
use KTXC\Models\Identity\User;
use KTXC\SessionTenant;
use KTXC\Stores\UserStore;
use KTXC\Stores\ExternalIdentityStore;
use KTXF\Utile\UUID;
/**
* User Provisioning Service
* Handles JIT (Just-In-Time) user provisioning from external identity providers
* and profile synchronization on login
*/
class UserProvisioningService
{
public function __construct(
private readonly SessionTenant $tenant,
private readonly UserStore $userStore,
private readonly ExternalIdentityStore $externalIdentityStore
) { }
/**
* Provision a new user from external provider attributes
*
* @param string $providerId Provider identifier
* @param array $attributes User attributes from provider
* @param array $providerConfig Provider configuration including attribute_map and default_roles
* @return User|null The provisioned user or null on failure
*/
public function provisionUser(string $providerId, array $attributes, array $providerConfig): ?User
{
$tenantId = $this->tenant->identifier();
if (!$tenantId) {
return null;
}
// Map attributes to user fields
$mappedData = $this->mapAttributes($attributes, $providerConfig['attribute_map'] ?? []);
// Validate required fields
$identity = $mappedData['identity'] ?? $attributes['identity'] ?? $attributes['email'] ?? null;
if (!$identity) {
return null;
}
// Generate user ID
$userId = UUID::v4();
// Build user data
$userData = [
'tid' => $tenantId,
'uid' => $userId,
'identity' => $identity,
'label' => $mappedData['label'] ?? $attributes['label'] ?? $attributes['name'] ?? $identity,
'enabled' => true,
'provider' => $providerId,
'external_subject' => $attributes['external_subject'] ?? null,
'roles' => $providerConfig['default_roles'] ?? [],
'profile' => $mappedData['profile'] ?? [],
'settings' => [],
'initial_login' => time(),
'recent_login' => time(),
];
// Create the user
$createdUserId = $this->userStore->create($userData);
if (!$createdUserId) {
return null;
}
// Link external identity if we have an external subject
if (!empty($attributes['external_subject'])) {
$this->externalIdentityStore->linkIdentity(
$tenantId,
$userId,
$providerId,
$attributes['external_subject'],
$attributes['raw'] ?? $attributes
);
}
// Build and return User object
$user = new User();
$user->populate($userData, 'users');
return $user;
}
/**
* Synchronize user profile with attributes from provider
* Called on each login to keep profile data up to date
*
* @param User $user The existing user
* @param array $attributes Attributes from provider
* @param array $attributeMap Attribute mapping configuration
* @return bool Whether sync was successful
*/
public function syncProfile(User $user, array $attributes, array $attributeMap = []): bool
{
$tenantId = $this->tenant->identifier();
$userId = $user->getId();
if (!$tenantId || !$userId) {
return false;
}
// Map attributes
$mappedData = $this->mapAttributes($attributes, $attributeMap);
// Update profile fields if we have mapped profile data
if (!empty($mappedData['profile'])) {
$this->userStore->updateProfile($tenantId, $userId, $mappedData['profile']);
}
// Update label if provided and different
if (!empty($mappedData['label']) && $mappedData['label'] !== $user->getLabel()) {
$this->userStore->updateLabel($tenantId, $userId, $mappedData['label']);
}
// Always update last login
$this->userStore->updateLastLogin($tenantId, $userId);
// Update external identity attributes if applicable
if (!empty($attributes['external_subject'])) {
$this->externalIdentityStore->updateLastLogin(
$tenantId,
$user->getProvider() ?? '',
$attributes['external_subject']
);
$this->externalIdentityStore->updateAttributes(
$tenantId,
$user->getProvider() ?? '',
$attributes['external_subject'],
$attributes['raw'] ?? $attributes
);
}
return true;
}
/**
* Link an external identity to an existing user
*
* @param User $user The user to link
* @param string $providerId Provider identifier
* @param string $externalSubject External subject identifier
* @param array $attributes Optional attributes from provider
* @return bool Whether linking was successful
*/
public function linkExternalIdentity(User $user, string $providerId, string $externalSubject, array $attributes = []): bool
{
$tenantId = $this->tenant->identifier();
$userId = $user->getId();
if (!$tenantId || !$userId) {
return false;
}
return $this->externalIdentityStore->linkIdentity(
$tenantId,
$userId,
$providerId,
$externalSubject,
$attributes
);
}
/**
* Find user by external identity
*
* @param string $providerId Provider identifier
* @param string $externalSubject External subject identifier
* @return User|null The user or null if not found
*/
public function findByExternalIdentity(string $providerId, string $externalSubject): ?User
{
$tenantId = $this->tenant->identifier();
if (!$tenantId) {
return null;
}
// Look up in external identities table
$externalIdentity = $this->externalIdentityStore->findByExternalSubject(
$tenantId,
$providerId,
$externalSubject
);
if (!$externalIdentity) {
return null;
}
// Fetch the linked user
$userData = $this->userStore->fetchByIdentifier($tenantId, $externalIdentity['uid']);
if (!$userData) {
return null;
}
$user = new User();
$user->populate($userData, 'users');
return $user;
}
/**
* Map provider attributes to user fields using attribute map
*
* @param array $attributes Raw attributes from provider
* @param array $attributeMap Mapping configuration {source_attr: target_field}
* @return array Mapped data with 'identity', 'label', 'profile' keys
*/
protected function mapAttributes(array $attributes, array $attributeMap): array
{
$result = [
'identity' => null,
'label' => null,
'profile' => [],
];
foreach ($attributeMap as $sourceAttr => $targetField) {
// Get source value (supports nested attributes with dot notation)
$value = $this->getNestedValue($attributes, $sourceAttr);
if ($value === null) {
continue;
}
// Set target value (supports nested targets with dot notation)
if ($targetField === 'identity') {
$result['identity'] = $value;
} elseif ($targetField === 'label') {
$result['label'] = $value;
} elseif (str_starts_with($targetField, 'profile.')) {
$profileField = substr($targetField, 8);
$result['profile'][$profileField] = $value;
}
}
return $result;
}
/**
* Get nested value from array using dot notation
*
* @param array $array Source array
* @param string $key Key with optional dot notation (e.g., 'user.email')
* @return mixed|null Value or null if not found
*/
protected function getNestedValue(array $array, string $key): mixed
{
$keys = explode('.', $key);
$value = $array;
foreach ($keys as $k) {
if (!is_array($value) || !array_key_exists($k, $value)) {
return null;
}
$value = $value[$k];
}
return $value;
}
/**
* Check if auto-provisioning is enabled for a provider
*
* @param string $providerId Provider identifier
* @return bool
*/
public function isAutoProvisioningEnabled(string $providerId): bool
{
$config = $this->tenant->identityProviderConfig($providerId);
return ($config['provisioning'] ?? 'manual') === 'auto';
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace KTXC\Service;
use KTXC\Models\Identity\User;
use KTXC\SessionIdentity;
use KTXC\SessionTenant;
use KTXC\Stores\UserStore;
class UserService
{
public function __construct(
private readonly SessionTenant $tenantIdentity,
private readonly SessionIdentity $userIdentity,
private readonly UserStore $userStore
) {
}
public function fetchByIdentity(string $identifier): User | null
{
$data = $this->userStore->fetchByIdentity($this->tenantIdentity->identifier(), $identifier);
if (!$data) {
return null;
}
$user = new User();
$user->populate($data, 'users');
return $user;
}
public function fetchByIdentifier(string $identifier): array | null
{
return $this->userStore->fetchByIdentifier($this->tenantIdentity->identifier(), $identifier);
}
public function fetchSettings(array $settings = []): array | null
{
return $this->userStore->fetchSettings($this->tenantIdentity->identifier(), $this->userIdentity->identifier(), $settings);
}
public function storeSettings(array $settings): bool
{
return $this->userStore->storeSettings($this->tenantIdentity->identifier(), $this->userIdentity->identifier(), $settings);
}
}