Initial Version

This commit is contained in:
root
2025-12-21 10:09:54 -05:00
commit 4ae6befc7b
422 changed files with 47225 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,145 @@
<?php
declare(strict_types=1);
namespace KTXC\Service;
use KTXC\Http\Request\Request;
use KTXC\Models\Identity\User;
use KTXC\Resource\ProviderManager;
use KTXC\SessionTenant;
use KTXF\Security\Authentication\AuthenticationProviderInterface;
/**
* 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 SessionTenant $sessionTenant,
private readonly TokenService $tokenService,
private readonly UserAccountsService $userService,
private readonly ProviderManager $providerManager,
) {
$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->authenticateBasic($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 authenticateBasic(string $identity, string $credentials): ?User
{
// For Basic auth headers, we need to validate against the provider
// This is a simplified flow for API access
$providers = $this->providerManager->providers(AuthenticationProviderInterface::TYPE_AUTHENTICATION);
if ($providers === []) {
return null;
}
foreach ($providers as $provider) {
if ($provider instanceof AuthenticationProviderInterface === false) {
continue;
}
if ($provider->method() !== AuthenticationProviderInterface::METHOD_CREDENTIAL) {
continue;
}
$context = new \KTXF\Security\Authentication\ProviderContext(
tenantId: $this->sessionTenant->identifier(),
userIdentity: $identity,
);
$result = $provider->verify($context, $credentials);
if ($result->isSuccess()) {
break;
}
}
if (isset($result) && $result->isSuccess()) {
return $this->userService->fetchByIdentity($identity);
}
return null;
}
/**
* 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,179 @@
<?php
namespace KTXC\Service;
use KTXC\Models\Identity\User;
use KTXC\SessionIdentity;
use KTXC\SessionTenant;
use KTXC\Stores\UserAccountsStore;
class UserAccountsService
{
public function __construct(
private readonly SessionTenant $tenantIdentity,
private readonly SessionIdentity $userIdentity,
private readonly UserAccountsStore $userStore
) {
}
// =========================================================================
// User Operations
// =========================================================================
/**
* List all users with optional filters
*/
public function listUsers(array $filters = []): array
{
$users = $this->userStore->listUsers($this->tenantIdentity->identifier(), $filters);
// Remove sensitive data
foreach ($users as &$user) {
unset($user['settings']);
}
return $users;
}
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 fetchByIdentityRaw(string $identifier): array | null
{
return $this->userStore->fetchByIdentity($this->tenantIdentity->identifier(), $identifier);
}
public function fetchByProviderSubject(string $provider, string $subject): ?array
{
return $this->userStore->fetchByProviderSubject($this->tenantIdentity->identifier(), $provider, $subject);
}
public function createUser(array $userData): array
{
return $this->userStore->createUser($this->tenantIdentity->identifier(), $userData);
}
public function updateUser(string $uid, array $updates): bool
{
return $this->userStore->updateUser($this->tenantIdentity->identifier(), $uid, $updates);
}
public function deleteUser(string $uid): bool
{
return $this->userStore->deleteUser($this->tenantIdentity->identifier(), $uid);
}
// =========================================================================
// Profile Operations
// =========================================================================
public function fetchProfile(string $uid): ?array
{
return $this->userStore->fetchProfile($this->tenantIdentity->identifier(), $uid);
}
public function storeProfile(string $uid, array $profileFields): bool
{
// Get managed fields to filter out read-only fields
$user = $this->fetchByIdentifier($uid);
if (!$user) {
return false;
}
$managedFields = $user['provider_managed_fields'] ?? [];
$editableFields = [];
// Only include fields that are not managed by provider
foreach ($profileFields as $field => $value) {
if (!in_array($field, $managedFields)) {
$editableFields[$field] = $value;
}
}
if (empty($editableFields)) {
return false;
}
return $this->userStore->storeProfile($this->tenantIdentity->identifier(), $uid, $editableFields);
}
// =========================================================================
// Settings Operations
// =========================================================================
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);
}
// =========================================================================
// Helper Methods
// =========================================================================
/**
* Check if a profile field is editable by the user
*
* @param string $uid User identifier
* @param string $field Profile field name
* @return bool True if field is editable, false if managed by provider
*/
public function isFieldEditable(string $uid, string $field): bool
{
$user = $this->fetchByIdentifier($uid);
if (!$user) {
return false;
}
$managedFields = $user['provider_managed_fields'] ?? [];
return !in_array($field, $managedFields);
}
/**
* Get editable fields for a user
*
* @param string $uid User identifier
* @return array Array with field => ['value' => ..., 'editable' => bool, 'provider' => ...]
*/
public function getEditableFields(string $uid): array
{
$user = $this->fetchByIdentifier($uid);
if (!$user || !isset($user['profile'])) {
return [];
}
$managedFields = $user['provider_managed_fields'] ?? [];
$provider = $user['provider'] ?? null;
$editable = [];
foreach ($user['profile'] as $field => $value) {
$editable[$field] = [
'value' => $value,
'editable' => !in_array($field, $managedFields),
'provider' => in_array($field, $managedFields) ? $provider : null,
];
}
return $editable;
}
}

View File

@@ -0,0 +1,143 @@
<?php
namespace KTXC\Service;
use KTXC\SessionTenant;
use KTXC\Stores\UserRolesStore;
use Psr\Log\LoggerInterface;
/**
* User Roles Service - Business logic for user role management
*/
class UserRolesService
{
public function __construct(
private readonly SessionTenant $tenantIdentity,
private readonly UserRolesStore $roleStore,
private readonly LoggerInterface $logger
) {}
// =========================================================================
// Role Operations
// =========================================================================
/**
* List all roles for current tenant
*/
public function listRoles(): array
{
return $this->roleStore->listRoles($this->tenantIdentity->identifier());
}
/**
* Get role by ID
*/
public function getRole(string $rid): ?array
{
return $this->roleStore->fetchByRid($this->tenantIdentity->identifier(), $rid);
}
/**
* Create a new role
*/
public function createRole(array $roleData): array
{
$this->validateRoleData($roleData);
$this->logger->info('Creating role', [
'tenant' => $this->tenantIdentity->identifier(),
'label' => $roleData['label'] ?? 'Unnamed'
]);
return $this->roleStore->createRole($this->tenantIdentity->identifier(), $roleData);
}
/**
* Update existing role
*/
public function updateRole(string $rid, array $updates): bool
{
// Verify role exists and is not system role
$role = $this->getRole($rid);
if (!$role) {
throw new \InvalidArgumentException('Role not found');
}
if ($role['system'] ?? false) {
throw new \InvalidArgumentException('Cannot modify system roles');
}
$this->validateRoleData($updates, false);
$this->logger->info('Updating role', [
'tenant' => $this->tenantIdentity->identifier(),
'rid' => $rid
]);
return $this->roleStore->updateRole($this->tenantIdentity->identifier(), $rid, $updates);
}
/**
* Delete a role
*/
public function deleteRole(string $rid): bool
{
// Verify role exists and is not system role
$role = $this->getRole($rid);
if (!$role) {
throw new \InvalidArgumentException('Role not found');
}
if ($role['system'] ?? false) {
throw new \InvalidArgumentException('Cannot delete system roles');
}
// Check if role is assigned to users
$userCount = $this->roleStore->countUsersInRole($this->tenantIdentity->identifier(), $rid);
if ($userCount > 0) {
throw new \InvalidArgumentException("Cannot delete role assigned to {$userCount} user(s)");
}
$this->logger->info('Deleting role', [
'tenant' => $this->tenantIdentity->identifier(),
'rid' => $rid
]);
return $this->roleStore->deleteRole($this->tenantIdentity->identifier(), $rid);
}
/**
* Get user count for a role
*/
public function getRoleUserCount(string $rid): int
{
return $this->roleStore->countUsersInRole($this->tenantIdentity->identifier(), $rid);
}
/**
* Get all available permissions from modules
* Grouped by category with metadata
*/
public function availablePermissions(): array
{
return $this->roleStore->availablePermissions();
}
// =========================================================================
// Validation
// =========================================================================
/**
* Validate role data
*/
private function validateRoleData(array $data, bool $isCreate = true): void
{
if ($isCreate && empty($data['label'])) {
throw new \InvalidArgumentException('Role label is required');
}
if (isset($data['permissions']) && !is_array($data['permissions'])) {
throw new \InvalidArgumentException('Permissions must be an array');
}
}
}