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

807 lines
27 KiB
PHP

<?php
declare(strict_types=1);
namespace KTXC\Security;
use KTXC\Models\Identity\User;
use KTXC\Resource\ProviderManager;
use KTXC\Security\Authentication\AuthenticationRequest;
use KTXC\Security\Authentication\AuthenticationResponse;
use KTXC\Service\TokenService;
use KTXC\Service\UserAccountsService;
use KTXC\SessionTenant;
use KTXF\Cache\CacheScope;
use KTXF\Cache\EphemeralCacheInterface;
use KTXF\Security\Authentication\AuthenticationProviderInterface;
use KTXF\Security\Authentication\AuthenticationSession;
use KTXF\Security\Authentication\ProviderContext;
/**
* Authentication Manager
*/
class AuthenticationManager
{
private const CACHE_USAGE = 'auth';
private string $securityCode;
public function __construct(
private readonly SessionTenant $tenant,
private readonly EphemeralCacheInterface $cache,
private readonly ProviderManager $providerManager,
private readonly TokenService $tokenService,
private readonly UserAccountsService $userService,
) {
$this->securityCode = $this->tenant->configuration()->security()->code();
}
// =========================================================================
// Main Entry Point
// =========================================================================
/**
* Handle an authentication request
*/
public function handle(AuthenticationRequest $request): AuthenticationResponse
{
return match ($request->action) {
AuthenticationRequest::ACTION_START => $this->handleStart(),
AuthenticationRequest::ACTION_IDENTIFY => $this->handleIdentify($request),
AuthenticationRequest::ACTION_VERIFY => $this->handleVerify($request),
AuthenticationRequest::ACTION_CHALLENGE => $this->handleChallenge($request),
AuthenticationRequest::ACTION_REDIRECT => $this->handleRedirect($request),
AuthenticationRequest::ACTION_CALLBACK => $this->handleCallback($request),
AuthenticationRequest::ACTION_STATUS => $this->handleStatus($request),
AuthenticationRequest::ACTION_CANCEL => $this->handleCancel($request),
AuthenticationRequest::ACTION_REFRESH => $this->handleRefresh($request),
AuthenticationRequest::ACTION_LOGOUT => $this->handleLogout($request),
default => AuthenticationResponse::failed(
AuthenticationResponse::ERROR_INVALID_REQUEST,
'Unknown action',
400
),
};
}
// =========================================================================
// Action Handlers
// =========================================================================
/**
* Start a new authentication session
*/
private function handleStart(): AuthenticationResponse
{
$methods = $this->methodsConfigured();
$session = AuthenticationSession::create(
$this->tenant->identifier(),
AuthenticationSession::STATE_FRESH
);
$this->saveSession($session);
return AuthenticationResponse::started($session->id, $methods);
}
/**
* Identify user (identity-first flow)
*/
private function handleIdentify(AuthenticationRequest $request): AuthenticationResponse
{
$session = $this->retrieveSession($request->sessionId);
if ($session === null) {
return AuthenticationResponse::failed(
AuthenticationResponse::ERROR_SESSION_EXPIRED,
'Invalid or expired session',
401
);
}
// Return all tenant methods to prevent enumeration
// Filter to non-redirect methods since redirects don't need identity first
$methods = $this->methodsConfigured();
$methods = array_values(array_filter($methods, fn($m) => $m['method'] !== 'redirect'));
$require = $this->tenant->configuration()->authentication()->methodsMinimal();
// Store identity in session without validating to prevent enumeration
$session->setMethods(array_column($methods, 'id'), $require);
$session->setIdentity($request->identity);
$this->saveSession($session);
return AuthenticationResponse::identified($session->id, $session->state(), $methods);
}
/**
* Verify credentials or challenge response
*/
private function handleVerify(AuthenticationRequest $request): AuthenticationResponse
{
$session = $this->retrieveSession($request->sessionId);
if ($session === null) {
return AuthenticationResponse::failed(
AuthenticationResponse::ERROR_SESSION_EXPIRED,
'Invalid or expired session',
401
);
}
if (empty($session->userIdentity)) {
return AuthenticationResponse::failed(
AuthenticationResponse::ERROR_INVALID_SESSION,
'Identity is required',
400
);
}
$method = $request->method;
if (!$session->methodEligible($method)) {
return AuthenticationResponse::failed(
AuthenticationResponse::ERROR_INVALID_REQUEST,
'Method not available',
400
);
}
$provider = $this->providerManager->resolve('authentication', $method);
if (!$provider instanceof AuthenticationProviderInterface) {
return AuthenticationResponse::failed(
AuthenticationResponse::ERROR_INVALID_PROVIDER,
'Provider not available',
400
);
}
// Build provider context
$context = $this->buildProviderContext($session, $method);
// Call appropriate provider method based on provider type
$providerMethod = $provider->method();
if ($providerMethod === AuthenticationProviderInterface::METHOD_CREDENTIAL) {
$result = $provider->verify($context, $request->secret);
} elseif ($providerMethod === AuthenticationProviderInterface::METHOD_CHALLENGE) {
$result = $provider->verifyChallenge($context, $request->secret);
} else {
return AuthenticationResponse::failed(
AuthenticationResponse::ERROR_INVALID_PROVIDER,
'Provider cannot be used for direct verification',
400
);
}
// Store any session data from provider
if (!empty($result->sessionData)) {
$session->setMeta("provider:{$method}", $result->sessionData);
}
if (!$result->isSuccess()) {
$this->saveSession($session);
return AuthenticationResponse::failed(
AuthenticationResponse::ERROR_INVALID_CREDENTIALS,
'Authentication failed. If you haven\'t set up this method, try another option.',
401
);
}
// Resolve user if not yet set
if ($session->userIdentifier === null) {
$user = $this->userService->fetchByIdentity($session->userIdentity);
if ($user === null) {
return AuthenticationResponse::failed(
AuthenticationResponse::ERROR_USER_NOT_FOUND,
'User not found',
401
);
}
$session->userIdentifier = $user->getId();
}
// Mark method complete
$session->methodCompleted($method);
$this->saveSession($session);
// Check if all required factors are complete
if ($session->state() !== AuthenticationSession::STATE_COMPLETE) {
$remainingMethods = $this->methodsConfigured($session->methodsCompleted);
// Filter out redirect methods - they can't be used as secondary factors
$remainingMethods = array_values(array_filter(
$remainingMethods,
fn($m) => $m['method'] !== 'redirect'
));
return AuthenticationResponse::pending($session->id, $remainingMethods);
}
// Authentication complete - issue tokens
return $this->completeAuthentication($session);
}
/**
* Begin a challenge (SMS, email, TOTP preparation)
*/
private function handleChallenge(AuthenticationRequest $request): AuthenticationResponse
{
$session = $this->retrieveSession($request->sessionId);
if ($session === null) {
return AuthenticationResponse::failed(
AuthenticationResponse::ERROR_SESSION_EXPIRED,
'Invalid or expired session',
401
);
}
$method = $request->method;
// Resolve user identifier if needed
if ($session->userIdentifier === null && $session->userIdentity) {
$user = $this->userService->fetchByIdentity($session->userIdentity);
if ($user) {
$session->userIdentifier = $user->getId();
$this->saveSession($session);
}
}
$provider = $this->providerManager->resolve('authentication', $method);
if (!$provider instanceof AuthenticationProviderInterface) {
return AuthenticationResponse::failed(
AuthenticationResponse::ERROR_INVALID_PROVIDER,
'Provider not available',
400
);
}
$context = $this->buildProviderContext($session, $method);
$result = $provider->beginChallenge($context);
// Store any session data from provider
if (!empty($result->sessionData)) {
$session->setMeta("provider:{$method}", $result->sessionData);
$this->saveSession($session);
}
if ($result->isChallenge()) {
return AuthenticationResponse::challenge(
$session->id,
$result->getClientData('challenge', [])
);
}
if ($result->isFailed()) {
// Generic error to prevent enumeration
return AuthenticationResponse::failed(
AuthenticationResponse::ERROR_INVALID_CREDENTIALS,
'Authentication failed. If you haven\'t set up this method, try another option.',
401
);
}
// Unexpected result
return AuthenticationResponse::failed(
AuthenticationResponse::ERROR_INTERNAL,
'Unexpected provider response',
500
);
}
/**
* Begin redirect-based authentication (OIDC/SAML)
*/
private function handleRedirect(AuthenticationRequest $request): AuthenticationResponse
{
$session = $this->retrieveSession($request->sessionId);
if ($session === null) {
return AuthenticationResponse::failed(
AuthenticationResponse::ERROR_SESSION_EXPIRED,
'Invalid or expired session',
401
);
}
$method = $request->method;
$provider = $this->providerManager->resolve('authentication', $method);
if (!$provider instanceof AuthenticationProviderInterface) {
return AuthenticationResponse::failed(
AuthenticationResponse::ERROR_INVALID_PROVIDER,
'Provider not available',
400
);
}
if ($provider->method() !== AuthenticationProviderInterface::METHOD_REDIRECT) {
return AuthenticationResponse::failed(
AuthenticationResponse::ERROR_INVALID_PROVIDER,
'Provider does not support redirect authentication',
400
);
}
$context = $this->buildProviderContext($session, $method);
$result = $provider->beginRedirect($context, $request->callbackUrl, $request->returnUrl);
if ($result->isFailed()) {
return AuthenticationResponse::failed(
$result->errorCode ?? AuthenticationResponse::ERROR_INTERNAL,
$result->errorMessage ?? 'Failed to initiate redirect authentication',
500
);
}
// Store provider session data (state, nonce, etc.)
$session->setMeta("provider:{$method}", $result->sessionData);
$session->setMeta('redirect_method', $method);
$this->saveSession($session);
return AuthenticationResponse::redirect(
$session->id,
$result->getClientData('redirect_url')
);
}
/**
* Complete redirect-based authentication (callback from IdP)
*/
private function handleCallback(AuthenticationRequest $request): AuthenticationResponse
{
$session = $this->retrieveSession($request->sessionId);
if ($session === null) {
return AuthenticationResponse::failed(
AuthenticationResponse::ERROR_SESSION_EXPIRED,
'Invalid or expired session',
401
);
}
$method = $request->method;
$expectedMethod = $session->getMeta('redirect_method');
if ($expectedMethod !== $method) {
return AuthenticationResponse::failed(
AuthenticationResponse::ERROR_INVALID_SESSION,
'Provider mismatch',
400
);
}
$provider = $this->providerManager->resolve('authentication', $method);
if (!$provider instanceof AuthenticationProviderInterface) {
return AuthenticationResponse::failed(
AuthenticationResponse::ERROR_INVALID_PROVIDER,
'Provider not available',
400
);
}
$context = $this->buildProviderContext($session, $method);
$result = $provider->completeRedirect($context, $request->params);
if ($result->isFailed()) {
$this->deleteSession($session->id);
return AuthenticationResponse::failed(
AuthenticationResponse::ERROR_INVALID_CREDENTIALS,
$result->errorMessage ?? 'Authentication failed',
401
);
}
// Provider has already provisioned the user - just get user identifier
$userIdentifier = $result->identity['user_identifier'] ?? null;
if (!$userIdentifier) {
$this->deleteSession($session->id);
return AuthenticationResponse::failed(
AuthenticationResponse::ERROR_INTERNAL,
'User provisioning failed',
500
);
}
// Load user
$userData = $this->userService->fetchByIdentifier($userIdentifier);
if (!$userData) {
$this->deleteSession($session->id);
return AuthenticationResponse::failed(
AuthenticationResponse::ERROR_USER_NOT_FOUND,
'User not found after provisioning',
401
);
}
$user = new User();
$user->populate($userData, 'users');
// Set user in session
$session->userIdentifier = $user->getId();
$session->userIdentity = $user->getIdentity();
$session->methodCompleted($method);
// Check if MFA is required
$require = $this->tenant->configuration()->authentication()->methodsMinimal();
if ($require > 1) {
$remainingMethods = $this->methodsConfigured([$method]);
// Filter out redirect methods - they can't be used as secondary factors
$remainingMethods = array_values(array_filter(
$remainingMethods,
fn($m) => $m['method'] !== 'redirect'
));
$session->setMethods(array_column($remainingMethods, 'id'), $require);
$this->saveSession($session);
return AuthenticationResponse::pending($session->id, $remainingMethods);
}
// Authentication complete
return $this->completeAuthentication($session);
}
/**
* Get session status
*/
private function handleStatus(AuthenticationRequest $request): AuthenticationResponse
{
$session = $this->retrieveSession($request->sessionId);
if ($session === null) {
return AuthenticationResponse::failed(
AuthenticationResponse::ERROR_SESSION_EXPIRED,
'Session not found or expired',
404
);
}
$methods = $this->methodsConfigured($session->methodsCompleted);
return AuthenticationResponse::status(
$session->id,
$session->state(),
$methods,
$session->userIdentity
);
}
/**
* Cancel session
*/
private function handleCancel(AuthenticationRequest $request): AuthenticationResponse
{
if ($request->sessionId) {
$this->deleteSession($request->sessionId);
}
return AuthenticationResponse::cancelled();
}
/**
* Refresh access token
*/
private function handleRefresh(AuthenticationRequest $request): AuthenticationResponse
{
$payload = $this->tokenService->validateToken($request->token, $this->securityCode);
if (!$payload) {
return AuthenticationResponse::failed(
AuthenticationResponse::ERROR_INVALID_CREDENTIALS,
'Invalid or expired refresh token',
401
);
}
if (($payload['type'] ?? null) !== 'refresh') {
return AuthenticationResponse::failed(
AuthenticationResponse::ERROR_INVALID_CREDENTIALS,
'Invalid token type',
401
);
}
$identifier = $payload['identifier'] ?? null;
$userData = $this->userService->fetchByIdentifier($identifier);
if ($userData === null) {
return AuthenticationResponse::failed(
AuthenticationResponse::ERROR_USER_NOT_FOUND,
'User not found',
401
);
}
$user = new User();
$user->populate($userData, 'users');
$accessToken = $this->tokenService->createToken(
[
'tenant' => $this->tenant->identifier(),
'identifier' => $user->getId(),
'identity' => $user->getIdentity(),
'label' => $user->getLabel(),
'permissions' => $user->getPermissions(),
'mfa_verified' => true,
],
$this->securityCode,
900
);
return AuthenticationResponse::success(
$this->buildUserData($user),
['access' => $accessToken]
);
}
/**
* Logout
*/
private function handleLogout(AuthenticationRequest $request): AuthenticationResponse
{
$allDevices = $request->params['all_devices'] ?? false;
if ($request->token) {
$payload = $this->tokenService->validateToken($request->token, $this->securityCode);
if ($payload) {
if ($allDevices && isset($payload['identity'])) {
$this->tokenService->blacklistUserTokensBefore($payload['identity'], time());
} elseif (isset($payload['jti'], $payload['exp'])) {
$this->tokenService->blacklist($payload['jti'], $payload['exp']);
}
}
}
return AuthenticationResponse::cancelled();
}
// =========================================================================
// Helper Methods
// =========================================================================
/**
* Build provider context from session
*/
private function buildProviderContext(AuthenticationSession $session, string $method): ProviderContext
{
return new ProviderContext(
tenantId: $session->tenantIdentifier,
userIdentifier: $session->userIdentifier,
userIdentity: $session->userIdentity,
metadata: $session->getMeta("provider:{$method}") ?? [],
config: $this->getProviderConfig($method),
);
}
/**
* Get provider configuration
*/
private function getProviderConfig(string $method): array
{
$providers = $this->tenant->configuration()->authentication()->providers();
return $providers[$method]['config'] ?? [];
}
/**
* Complete authentication and issue tokens
*/
private function completeAuthentication(AuthenticationSession $session): AuthenticationResponse
{
$userData = $this->userService->fetchByIdentifier($session->userIdentifier);
if ($userData === null) {
return AuthenticationResponse::failed(
AuthenticationResponse::ERROR_USER_NOT_FOUND,
'User not found',
401
);
}
$user = new User();
$user->populate($userData, 'users');
$tokens = $this->createTokens($user, count($session->methodsCompleted) > 1);
$this->deleteSession($session->id);
return AuthenticationResponse::success(
$this->buildUserData($user),
$tokens
);
}
/**
* Build user data for response
*/
private function buildUserData(User $user): array
{
return [
'identifier' => $user->getId(),
'identity' => $user->getIdentity(),
'label' => $user->getLabel(),
'permissions' => $user->getPermissions(),
];
}
/**
* Get configured authentication methods
*/
private function methodsConfigured(array $methodsCompleted = []): array
{
$tenantProviders = $this->tenant->configuration()->authentication()->providers();
$methods = [];
foreach ($tenantProviders as $providerId => $providerConfiguration) {
if (!($providerConfiguration['enabled'] ?? false)) {
continue;
}
if (in_array($providerId, $methodsCompleted, true)) {
continue;
}
$provider = $this->providerManager->resolve('authentication', $providerId);
if (!$provider instanceof AuthenticationProviderInterface) {
continue;
}
$methods[] = [
'id' => $providerId,
'method' => $provider->method(),
'label' => $providerConfiguration['label'] ?? $provider->label(),
'icon' => $providerConfiguration['icon'] ?? $provider->icon() ?? null,
];
}
return $methods;
}
/**
* Create JWT tokens
*/
private function createTokens(User $user, bool $mfaVerified = false): array
{
$payload = [
'tenant' => $this->tenant->identifier(),
'identifier' => $user->getId(),
'identity' => $user->getIdentity(),
'label' => $user->getLabel(),
'permissions' => $user->getPermissions(),
'mfa_verified' => $mfaVerified,
];
return [
'access' => $this->tokenService->createToken($payload, $this->securityCode, 900),
'refresh' => $this->tokenService->createToken(
[
'tenant' => $payload['tenant'],
'identifier' => $payload['identifier'],
'identity' => $payload['identity'],
'type' => 'refresh',
],
$this->securityCode,
604800
),
];
}
/**
* Find or provision user from external identity
*/
private function findOrProvisionUser(
string $providerId,
array $identity,
array $providerConfig
): ?User {
$userIdentity = $identity['email'] ?? $identity['identity'] ?? null;
$externalSubject = $identity['subject'] ?? $identity['sub'] ?? null;
$attributes = $identity['attributes'] ?? [];
$attributes['identity'] = $userIdentity;
$attributes['external_subject'] = $externalSubject;
/*
// Try to find by external subject first
if ($externalSubject) {
$user = $this->provisioningService->findByExternalIdentity($providerId, $externalSubject);
if ($user) {
$this->provisioningService->syncProfile(
$user,
$attributes,
$providerConfig['attribute_map'] ?? []
);
return $user;
}
}
// Try to find by identity
if ($userIdentity) {
$existingUser = $this->userService->fetchByIdentity($userIdentity);
if ($existingUser) {
if ($existingUser->getProvider() === $providerId) {
if ($externalSubject) {
$this->provisioningService->linkExternalIdentity(
$existingUser,
$providerId,
$externalSubject,
$attributes
);
}
$this->provisioningService->syncProfile(
$existingUser,
$attributes,
$providerConfig['attribute_map'] ?? []
);
return $existingUser;
}
return null;
}
}
// Auto-provision if enabled
if ($this->provisioningService->isAutoProvisioningEnabled($providerId)) {
return $this->provisioningService->provisionUser(
$providerId,
$attributes,
$providerConfig
);
}
*/
return null;
}
// =========================================================================
// Session Cache Helpers
// =========================================================================
/**
* Retrieve authentication session from cache
*/
private function retrieveSession(?string $sessionId): ?AuthenticationSession
{
if (empty($sessionId)) {
return null;
}
$data = $this->cache->get($sessionId, CacheScope::Tenant, self::CACHE_USAGE);
if ($data === null) {
return null;
}
if ($data instanceof AuthenticationSession) {
if ($data->isExpired()) {
$this->deleteSession($sessionId);
return null;
}
return $data;
}
return null;
}
/**
* Save authentication session to cache
*/
private function saveSession(AuthenticationSession $session): bool
{
$ttl = $session->expiresAt > 0 ? $session->expiresAt - time() : AuthenticationSession::DEFAULT_TTL;
return $this->cache->set(
$session->id,
$session,
CacheScope::Tenant,
self::CACHE_USAGE,
max($ttl, 60)
);
}
/**
* Delete authentication session from cache
*/
private function deleteSession(string $sessionId): bool
{
return $this->cache->delete($sessionId, CacheScope::Tenant, self::CACHE_USAGE);
}
}