Initial Version
This commit is contained in:
180
core/lib/Security/Authentication/AuthenticationRequest.php
Normal file
180
core/lib/Security/Authentication/AuthenticationRequest.php
Normal file
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Security\Authentication;
|
||||
|
||||
/**
|
||||
* Authentication Request
|
||||
*
|
||||
* Request DTO from controller to AuthenticationManager.
|
||||
* Encapsulates all input data for authentication operations.
|
||||
*/
|
||||
readonly class AuthenticationRequest
|
||||
{
|
||||
// Action types
|
||||
public const ACTION_START = 'start';
|
||||
public const ACTION_IDENTIFY = 'identify';
|
||||
public const ACTION_VERIFY = 'verify';
|
||||
public const ACTION_CHALLENGE = 'challenge';
|
||||
public const ACTION_REDIRECT = 'redirect';
|
||||
public const ACTION_CALLBACK = 'callback';
|
||||
public const ACTION_STATUS = 'status';
|
||||
public const ACTION_CANCEL = 'cancel';
|
||||
public const ACTION_REFRESH = 'refresh';
|
||||
public const ACTION_LOGOUT = 'logout';
|
||||
|
||||
public function __construct(
|
||||
/** Action to perform */
|
||||
public string $action,
|
||||
|
||||
/** Session ID (for ongoing auth flows) */
|
||||
public ?string $sessionId = null,
|
||||
|
||||
/** User identity (email/username) */
|
||||
public ?string $identity = null,
|
||||
|
||||
/** Authentication method/provider ID */
|
||||
public ?string $method = null,
|
||||
|
||||
/** Secret/code/password */
|
||||
public ?string $secret = null,
|
||||
|
||||
/** Callback URL for redirect flows */
|
||||
public ?string $callbackUrl = null,
|
||||
|
||||
/** Return URL after authentication */
|
||||
public ?string $returnUrl = null,
|
||||
|
||||
/** Additional parameters (OIDC callback params, etc.) */
|
||||
public array $params = [],
|
||||
|
||||
/** Token for refresh/logout operations */
|
||||
public ?string $token = null,
|
||||
) {}
|
||||
|
||||
// =========================================================================
|
||||
// Factory Methods
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Create a start request
|
||||
*/
|
||||
public static function start(): self
|
||||
{
|
||||
return new self(action: self::ACTION_START);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an identify request
|
||||
*/
|
||||
public static function identify(string $sessionId, string $identity): self
|
||||
{
|
||||
return new self(
|
||||
action: self::ACTION_IDENTIFY,
|
||||
sessionId: $sessionId,
|
||||
identity: $identity,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a verify request (password, TOTP code, etc.)
|
||||
*/
|
||||
public static function verify(string $sessionId, string $method, string $secret): self
|
||||
{
|
||||
return new self(
|
||||
action: self::ACTION_VERIFY,
|
||||
sessionId: $sessionId,
|
||||
method: $method,
|
||||
secret: $secret,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a begin challenge request
|
||||
*/
|
||||
public static function challenge(string $sessionId, string $method): self
|
||||
{
|
||||
return new self(
|
||||
action: self::ACTION_CHALLENGE,
|
||||
sessionId: $sessionId,
|
||||
method: $method,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a begin redirect request
|
||||
*/
|
||||
public static function redirect(
|
||||
string $sessionId,
|
||||
string $method,
|
||||
string $callbackUrl,
|
||||
?string $returnUrl = null
|
||||
): self {
|
||||
return new self(
|
||||
action: self::ACTION_REDIRECT,
|
||||
sessionId: $sessionId,
|
||||
method: $method,
|
||||
callbackUrl: $callbackUrl,
|
||||
returnUrl: $returnUrl,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a callback request (OIDC/SAML return)
|
||||
*/
|
||||
public static function callback(string $sessionId, string $method, array $params): self
|
||||
{
|
||||
return new self(
|
||||
action: self::ACTION_CALLBACK,
|
||||
sessionId: $sessionId,
|
||||
method: $method,
|
||||
params: $params,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a status request
|
||||
*/
|
||||
public static function status(string $sessionId): self
|
||||
{
|
||||
return new self(
|
||||
action: self::ACTION_STATUS,
|
||||
sessionId: $sessionId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a cancel request
|
||||
*/
|
||||
public static function cancel(string $sessionId): self
|
||||
{
|
||||
return new self(
|
||||
action: self::ACTION_CANCEL,
|
||||
sessionId: $sessionId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a refresh token request
|
||||
*/
|
||||
public static function refresh(string $token): self
|
||||
{
|
||||
return new self(
|
||||
action: self::ACTION_REFRESH,
|
||||
token: $token,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a logout request
|
||||
*/
|
||||
public static function logout(?string $token = null, bool $allDevices = false): self
|
||||
{
|
||||
return new self(
|
||||
action: self::ACTION_LOGOUT,
|
||||
token: $token,
|
||||
params: ['all_devices' => $allDevices],
|
||||
);
|
||||
}
|
||||
}
|
||||
272
core/lib/Security/Authentication/AuthenticationResponse.php
Normal file
272
core/lib/Security/Authentication/AuthenticationResponse.php
Normal file
@@ -0,0 +1,272 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Security\Authentication;
|
||||
|
||||
/**
|
||||
* Authentication Response
|
||||
*
|
||||
* Response DTO from AuthenticationManager to controller.
|
||||
* Contains all data needed to build the HTTP response.
|
||||
*/
|
||||
readonly class AuthenticationResponse
|
||||
{
|
||||
// Status constants
|
||||
public const STATUS_SUCCESS = 'success';
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_CHALLENGE = 'challenge';
|
||||
public const STATUS_REDIRECT = 'redirect';
|
||||
public const STATUS_FAILED = 'failed';
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
|
||||
// Error codes
|
||||
public const ERROR_INVALID_REQUEST = 'invalid_request';
|
||||
public const ERROR_INVALID_CREDENTIALS = 'invalid_credentials';
|
||||
public const ERROR_INVALID_PROVIDER = 'invalid_provider';
|
||||
public const ERROR_INVALID_SESSION = 'invalid_session';
|
||||
public const ERROR_SESSION_EXPIRED = 'session_expired';
|
||||
public const ERROR_USER_NOT_FOUND = 'user_not_found';
|
||||
public const ERROR_USER_DISABLED = 'user_disabled';
|
||||
public const ERROR_ACCOUNT_LOCKED = 'account_locked';
|
||||
public const ERROR_RATE_LIMITED = 'rate_limited';
|
||||
public const ERROR_INTERNAL = 'internal_error';
|
||||
|
||||
public function __construct(
|
||||
/** Response status */
|
||||
public string $status,
|
||||
|
||||
/** Suggested HTTP status code */
|
||||
public int $httpStatus = 200,
|
||||
|
||||
/** Session ID (for ongoing flows) */
|
||||
public ?string $sessionId = null,
|
||||
|
||||
/** Current session state */
|
||||
public ?string $sessionState = null,
|
||||
|
||||
/** Serialized user data (on success) */
|
||||
public ?array $user = null,
|
||||
|
||||
/** Auth tokens (on success) */
|
||||
public ?array $tokens = null,
|
||||
|
||||
/** Available authentication methods */
|
||||
public ?array $methods = null,
|
||||
|
||||
/** Challenge information */
|
||||
public ?array $challenge = null,
|
||||
|
||||
/** Redirect URL (for OIDC/SAML) */
|
||||
public ?string $redirectUrl = null,
|
||||
|
||||
/** Return URL (after redirect auth) */
|
||||
public ?string $returnUrl = null,
|
||||
|
||||
/** Error code */
|
||||
public ?string $errorCode = null,
|
||||
|
||||
/** Error message */
|
||||
public ?string $errorMessage = null,
|
||||
) {}
|
||||
|
||||
// =========================================================================
|
||||
// Factory Methods
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Session started response
|
||||
*/
|
||||
public static function started(string $sessionId, array $methods): self
|
||||
{
|
||||
return new self(
|
||||
status: self::STATUS_SUCCESS,
|
||||
sessionId: $sessionId,
|
||||
methods: $methods,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* User identified response
|
||||
*/
|
||||
public static function identified(string $sessionId, string $state, array $methods): self
|
||||
{
|
||||
return new self(
|
||||
status: self::STATUS_SUCCESS,
|
||||
sessionId: $sessionId,
|
||||
sessionState: $state,
|
||||
methods: $methods,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication successful
|
||||
*/
|
||||
public static function success(array $user, array $tokens): self
|
||||
{
|
||||
return new self(
|
||||
status: self::STATUS_SUCCESS,
|
||||
user: $user,
|
||||
tokens: $tokens,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* MFA/additional factor required
|
||||
*/
|
||||
public static function pending(string $sessionId, array $methods): self
|
||||
{
|
||||
return new self(
|
||||
status: self::STATUS_PENDING,
|
||||
sessionId: $sessionId,
|
||||
methods: $methods,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Challenge sent (SMS, email, etc.)
|
||||
*/
|
||||
public static function challenge(string $sessionId, array $challengeInfo): self
|
||||
{
|
||||
return new self(
|
||||
status: self::STATUS_CHALLENGE,
|
||||
sessionId: $sessionId,
|
||||
challenge: $challengeInfo,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect required (OIDC/SAML)
|
||||
*/
|
||||
public static function redirect(string $sessionId, string $redirectUrl): self
|
||||
{
|
||||
return new self(
|
||||
status: self::STATUS_REDIRECT,
|
||||
sessionId: $sessionId,
|
||||
redirectUrl: $redirectUrl,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication failed
|
||||
*/
|
||||
public static function failed(
|
||||
string $errorCode,
|
||||
?string $errorMessage = null,
|
||||
int $httpStatus = 401
|
||||
): self {
|
||||
return new self(
|
||||
status: self::STATUS_FAILED,
|
||||
httpStatus: $httpStatus,
|
||||
errorCode: $errorCode,
|
||||
errorMessage: $errorMessage,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Session cancelled
|
||||
*/
|
||||
public static function cancelled(): self
|
||||
{
|
||||
return new self(
|
||||
status: self::STATUS_CANCELLED,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Status check response
|
||||
*/
|
||||
public static function status(
|
||||
string $sessionId,
|
||||
string $state,
|
||||
array $methods,
|
||||
?string $identity = null
|
||||
): self {
|
||||
return new self(
|
||||
status: self::STATUS_SUCCESS,
|
||||
sessionId: $sessionId,
|
||||
sessionState: $state,
|
||||
methods: $methods,
|
||||
user: $identity ? ['identity' => $identity] : null,
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Status Checks
|
||||
// =========================================================================
|
||||
|
||||
public function isSuccess(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_SUCCESS;
|
||||
}
|
||||
|
||||
public function isPending(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_PENDING;
|
||||
}
|
||||
|
||||
public function isRedirect(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_REDIRECT;
|
||||
}
|
||||
|
||||
public function isFailed(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_FAILED;
|
||||
}
|
||||
|
||||
public function hasTokens(): bool
|
||||
{
|
||||
return $this->tokens !== null && !empty($this->tokens);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Serialization
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Convert to array for JSON response
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
$result = ['status' => $this->status];
|
||||
|
||||
if ($this->sessionId !== null) {
|
||||
$result['session'] = $this->sessionId;
|
||||
}
|
||||
|
||||
if ($this->sessionState !== null) {
|
||||
$result['state'] = $this->sessionState;
|
||||
}
|
||||
|
||||
if ($this->user !== null) {
|
||||
$result['user'] = $this->user;
|
||||
}
|
||||
|
||||
if ($this->methods !== null) {
|
||||
$result['methods'] = $this->methods;
|
||||
}
|
||||
|
||||
if ($this->challenge !== null) {
|
||||
$result['challenge'] = $this->challenge;
|
||||
}
|
||||
|
||||
if ($this->redirectUrl !== null) {
|
||||
$result['redirect_url'] = $this->redirectUrl;
|
||||
}
|
||||
|
||||
if ($this->returnUrl !== null) {
|
||||
$result['return_url'] = $this->returnUrl;
|
||||
}
|
||||
|
||||
if ($this->errorCode !== null) {
|
||||
$result['error_code'] = $this->errorCode;
|
||||
}
|
||||
|
||||
if ($this->errorMessage !== null) {
|
||||
$result['error'] = $this->errorMessage;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
124
core/lib/Security/Authorization/PermissionChecker.php
Normal file
124
core/lib/Security/Authorization/PermissionChecker.php
Normal file
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Security\Authorization;
|
||||
|
||||
use KTXC\SessionIdentity;
|
||||
|
||||
/**
|
||||
* Permission Checker
|
||||
* Provides granular permission checking with support for wildcards
|
||||
*/
|
||||
class PermissionChecker
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SessionIdentity $sessionIdentity
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Check if user has a specific permission
|
||||
* Supports wildcards: user_manager.users.* matches all user actions
|
||||
*
|
||||
* @param string $permission Permission to check (e.g., "user_manager.users.create")
|
||||
* @param mixed $resource Optional resource for resource-based permissions
|
||||
* @return bool
|
||||
*/
|
||||
public function can(string $permission, mixed $resource = null): bool
|
||||
{
|
||||
$identity = $this->sessionIdentity->identity();
|
||||
|
||||
if (!$identity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get user permissions from identity
|
||||
$userPermissions = $identity->getPermissions() ?? [];
|
||||
|
||||
// Super admin bypass - check for admin role
|
||||
$roles = $identity->getRoles() ?? [];
|
||||
if (in_array('admin', $roles) || in_array('system.admin', $roles)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Exact match
|
||||
if (in_array($permission, $userPermissions)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Wildcard match: user_manager.users.* allows user_manager.users.create
|
||||
foreach ($userPermissions as $userPerm) {
|
||||
if (str_ends_with($userPerm, '.*')) {
|
||||
$prefix = substr($userPerm, 0, -2);
|
||||
if (str_starts_with($permission, $prefix . '.')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Full wildcard: * grants all permissions
|
||||
if (in_array('*', $userPermissions)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has ANY of the permissions (OR logic)
|
||||
*
|
||||
* @param array $permissions Array of permissions to check
|
||||
* @param mixed $resource Optional resource for resource-based permissions
|
||||
* @return bool
|
||||
*/
|
||||
public function canAny(array $permissions, mixed $resource = null): bool
|
||||
{
|
||||
if (empty($permissions)) {
|
||||
return true; // No permissions required
|
||||
}
|
||||
|
||||
foreach ($permissions as $permission) {
|
||||
if ($this->can($permission, $resource)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has ALL permissions (AND logic)
|
||||
*
|
||||
* @param array $permissions Array of permissions to check
|
||||
* @param mixed $resource Optional resource for resource-based permissions
|
||||
* @return bool
|
||||
*/
|
||||
public function canAll(array $permissions, mixed $resource = null): bool
|
||||
{
|
||||
if (empty($permissions)) {
|
||||
return true; // No permissions required
|
||||
}
|
||||
|
||||
foreach ($permissions as $permission) {
|
||||
if (!$this->can($permission, $resource)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all permissions for the current user
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getUserPermissions(): array
|
||||
{
|
||||
$identity = $this->sessionIdentity->identity();
|
||||
|
||||
if (!$identity) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $identity->getPermissions() ?? [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user