Initial Version
This commit is contained in:
806
core/lib/Security/AuthenticationManager.php
Normal file
806
core/lib/Security/AuthenticationManager.php
Normal file
@@ -0,0 +1,806 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user