Initial Version
This commit is contained in:
228
core/lib/Service/ConfigurationService.php
Normal file
228
core/lib/Service/ConfigurationService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
630
core/lib/Service/FirewallService.php
Normal file
630
core/lib/Service/FirewallService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
145
core/lib/Service/SecurityService.php
Normal file
145
core/lib/Service/SecurityService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
19
core/lib/Service/TenantService.php
Normal file
19
core/lib/Service/TenantService.php
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
309
core/lib/Service/TokenService.php
Normal file
309
core/lib/Service/TokenService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
179
core/lib/Service/UserAccountsService.php
Normal file
179
core/lib/Service/UserAccountsService.php
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
143
core/lib/Service/UserRolesService.php
Normal file
143
core/lib/Service/UserRolesService.php
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user