Initial Version
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXF\Security\Authentication;
|
||||
|
||||
use KTXF\Security\Authentication\ProviderContext;
|
||||
use KTXF\Security\Authentication\ProviderResult;
|
||||
|
||||
/**
|
||||
* Abstract base for authentication provider
|
||||
*/
|
||||
abstract class AuthenticationProviderAbstract implements AuthenticationProviderInterface
|
||||
{
|
||||
|
||||
abstract public function type(): string;
|
||||
|
||||
abstract public function identifier(): string;
|
||||
|
||||
abstract public function label(): string;
|
||||
|
||||
abstract public function description(): string;
|
||||
|
||||
abstract public function method(): string;
|
||||
|
||||
abstract public function icon(): string;
|
||||
|
||||
public function verify(ProviderContext $context, string $secret): ProviderResult
|
||||
{
|
||||
return ProviderResult::failed(
|
||||
ProviderResult::ERROR_INVALID_PROVIDER,
|
||||
'Credential authentication not supported by this provider'
|
||||
);
|
||||
}
|
||||
|
||||
public function beginChallenge(ProviderContext $context): ProviderResult
|
||||
{
|
||||
return ProviderResult::failed(
|
||||
ProviderResult::ERROR_INVALID_PROVIDER,
|
||||
'Challenge authentication not supported by this provider'
|
||||
);
|
||||
}
|
||||
|
||||
public function verifyChallenge(ProviderContext $context, string $code): ProviderResult
|
||||
{
|
||||
return ProviderResult::failed(
|
||||
ProviderResult::ERROR_INVALID_PROVIDER,
|
||||
'Challenge authentication not supported by this provider'
|
||||
);
|
||||
}
|
||||
|
||||
public function beginRedirect(ProviderContext $context, string $callbackUrl, ?string $returnUrl = null): ProviderResult
|
||||
{
|
||||
return ProviderResult::failed(
|
||||
ProviderResult::ERROR_INVALID_PROVIDER,
|
||||
'Redirect authentication not supported by this provider'
|
||||
);
|
||||
}
|
||||
|
||||
public function completeRedirect(ProviderContext $context, array $params): ProviderResult
|
||||
{
|
||||
return ProviderResult::failed(
|
||||
ProviderResult::ERROR_INVALID_PROVIDER,
|
||||
'Redirect authentication not supported by this provider'
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXF\Security\Authentication;
|
||||
|
||||
use KTXF\Resource\Provider\ProviderInterface;
|
||||
use KTXF\Security\Authentication\ProviderContext;
|
||||
use KTXF\Security\Authentication\ProviderResult;
|
||||
|
||||
/**
|
||||
* Unified Authentication Provider Interface
|
||||
*/
|
||||
interface AuthenticationProviderInterface extends ProviderInterface
|
||||
{
|
||||
/** Secret style authentication (e.g. password, TOTP, etc) */
|
||||
public const METHOD_CREDENTIAL = 'credential';
|
||||
|
||||
/** Challenge/response (e.g. SMS code, email code, etc) */
|
||||
public const METHOD_CHALLENGE = 'challenge';
|
||||
|
||||
/** Redirect-based (e.g. OIDC, SAML, OAuth) */
|
||||
public const METHOD_REDIRECT = 'redirect';
|
||||
|
||||
/**
|
||||
* Provider method - one of the METHOD_* constants
|
||||
*/
|
||||
public function method(): string;
|
||||
|
||||
/**
|
||||
* Provider icon
|
||||
*/
|
||||
public function icon(): string;
|
||||
|
||||
// =========================================================================
|
||||
// Credential Authentication
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Authenticate with credentials (username/password style)
|
||||
*
|
||||
* @param ProviderContext $context Authentication context
|
||||
* @param string $secret Password, PIN, or secret
|
||||
*
|
||||
* @return ProviderResult
|
||||
*/
|
||||
public function verify(ProviderContext $context, string $secret): ProviderResult;
|
||||
|
||||
// =========================================================================
|
||||
// Challenge/Response Authentication
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Begin a challenge (send code, prepare for verification)
|
||||
*
|
||||
* For SMS/Email: Sends the code and returns confirmation
|
||||
* For TOTP: Returns challenge metadata (digits, etc.)
|
||||
*
|
||||
* @param ProviderContext $context Authentication context
|
||||
*
|
||||
* @return ProviderResult Contains challenge metadata in clientData
|
||||
*/
|
||||
public function beginChallenge(ProviderContext $context): ProviderResult;
|
||||
|
||||
/**
|
||||
* Verify challenge response
|
||||
*
|
||||
* @param ProviderContext $context Authentication context
|
||||
* @param string $code User's response code
|
||||
*
|
||||
* @return ProviderResult
|
||||
*/
|
||||
public function verifyChallenge(ProviderContext $context, string $code): ProviderResult;
|
||||
|
||||
// =========================================================================
|
||||
// Redirect Authentication (OIDC/SAML)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Begin redirect-based authentication
|
||||
*
|
||||
* @param ProviderContext $context Authentication context (contains config)
|
||||
* @param string $callbackUrl URL to redirect back to
|
||||
* @param string|null $returnUrl Final destination after auth
|
||||
*
|
||||
* @return ProviderResult Contains redirect_url in clientData, state/nonce in sessionData
|
||||
*/
|
||||
public function beginRedirect(ProviderContext $context, string $callbackUrl, ?string $returnUrl = null): ProviderResult;
|
||||
|
||||
/**
|
||||
* Complete redirect-based authentication
|
||||
*
|
||||
* @param ProviderContext $context Authentication context (contains stored state/nonce in metadata)
|
||||
* @param array $params Callback parameters (code, state, etc.)
|
||||
*
|
||||
* @return ProviderResult Contains user attributes in identity on success
|
||||
*/
|
||||
public function completeRedirect(ProviderContext $context, array $params): ProviderResult;
|
||||
|
||||
}
|
||||
217
shared/lib/Security/Authentication/AuthenticationSession.php
Normal file
217
shared/lib/Security/Authentication/AuthenticationSession.php
Normal file
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXF\Security\Authentication;
|
||||
|
||||
/**
|
||||
* Authentication Session
|
||||
*
|
||||
* Represents an in-progress authentication flow
|
||||
*/
|
||||
class AuthenticationSession
|
||||
{
|
||||
// Session states
|
||||
public const STATE_FRESH = 'fresh'; // Fresh session, no auth yet
|
||||
public const STATE_IDENTIFIED = 'identified'; // User identity captured, awaiting auth method
|
||||
public const STATE_AUTHENTICATING = 'authenticating'; // In the process of authenticating
|
||||
public const STATE_COMPLETE = 'complete'; // All factors verified
|
||||
|
||||
// Default TTL: 5 minutes
|
||||
public const DEFAULT_TTL = 300;
|
||||
|
||||
public function __construct(
|
||||
public readonly string $id,
|
||||
protected string $state,
|
||||
public readonly string $tenantIdentifier = '',
|
||||
public ?string $userIdentifier = null,
|
||||
public ?string $userIdentity = null,
|
||||
public int $methodsRequired = 1,
|
||||
public array $methodsAvailable = [],
|
||||
public array $methodsCompleted = [],
|
||||
public array $metadata = [],
|
||||
public readonly int $createdAt = 0,
|
||||
public readonly int $expiresAt = 0,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a new authentication session
|
||||
*/
|
||||
public static function create(
|
||||
string $tenantIdentifier,
|
||||
string $state = self::STATE_FRESH,
|
||||
int $ttl = self::DEFAULT_TTL
|
||||
): self {
|
||||
$now = time();
|
||||
return new self(
|
||||
id: self::generateId(),
|
||||
state: $state,
|
||||
createdAt: $now,
|
||||
expiresAt: $now + $ttl,
|
||||
tenantIdentifier: $tenantIdentifier,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique session ID
|
||||
*/
|
||||
private static function generateId(): string
|
||||
{
|
||||
return 'auth_' . bin2hex(random_bytes(16));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current session state
|
||||
*/
|
||||
public function state(): string {
|
||||
return $this->state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if session has expired
|
||||
*/
|
||||
public function isExpired(): bool
|
||||
{
|
||||
return time() > $this->expiresAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if session is in initial state
|
||||
*/
|
||||
public function isFresh(): bool
|
||||
{
|
||||
return $this->state === self::STATE_FRESH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if session has identity but awaiting authentication
|
||||
*/
|
||||
public function isIdentified(): bool
|
||||
{
|
||||
return $this->state === self::STATE_IDENTIFIED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if session is in the process of authenticating
|
||||
*/
|
||||
public function isAuthenticating(): bool
|
||||
{
|
||||
return $this->state === self::STATE_AUTHENTICATING;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if session is complete
|
||||
*/
|
||||
public function isComplete(): bool
|
||||
{
|
||||
return $this->state === self::STATE_COMPLETE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set user identity (before authentication)
|
||||
*/
|
||||
public function setIdentity(string $value): void
|
||||
{
|
||||
$this->userIdentity = $value;
|
||||
$this->state = self::STATE_IDENTIFIED;
|
||||
}
|
||||
|
||||
public function setMethods(array $methods, int $require = 1): void
|
||||
{
|
||||
$this->methodsAvailable = $methods;
|
||||
$this->methodsRequired = $require;
|
||||
}
|
||||
|
||||
public function methodEligible(string $method): bool
|
||||
{
|
||||
return in_array($method, $this->methodsAvailable, true)
|
||||
&& !in_array($method, $this->methodsCompleted, true);
|
||||
}
|
||||
/**
|
||||
* Mark a method as completed
|
||||
*/
|
||||
public function methodCompleted(string $method): void
|
||||
{
|
||||
if (!in_array($method, $this->methodsCompleted, true)) {
|
||||
$this->methodsCompleted[] = $method;
|
||||
}
|
||||
|
||||
// If we have required factors and all are complete, mark session complete
|
||||
if (count($this->methodsCompleted) >= $this->methodsRequired) {
|
||||
$this->state = self::STATE_COMPLETE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get methods that still need to be completed
|
||||
*/
|
||||
public function methodsRemaining(): array
|
||||
{
|
||||
return array_values(array_diff($this->methodsAvailable, $this->methodsCompleted));
|
||||
}
|
||||
|
||||
/**
|
||||
* Promote session after successful primary auth (set user info)
|
||||
*/
|
||||
public function setUser(string $userIdentifier, string $userIdentity): void
|
||||
{
|
||||
$this->userIdentifier = $userIdentifier;
|
||||
$this->userIdentity = $userIdentity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metadata value
|
||||
*/
|
||||
public function getMeta(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $this->metadata[$key] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set metadata value
|
||||
*/
|
||||
public function setMeta(string $key, mixed $value): void
|
||||
{
|
||||
$this->metadata[$key] = $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize to array for storage
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'state' => $this->state,
|
||||
'tenant_identifier' => $this->tenantIdentifier,
|
||||
'user_identifier' => $this->userIdentifier,
|
||||
'user_identity' => $this->userIdentity,
|
||||
'methods_required' => $this->methodsRequired,
|
||||
'methods_available' => $this->methodsAvailable,
|
||||
'methods_completed' => $this->methodsCompleted,
|
||||
'metadata' => $this->metadata,
|
||||
'created_at' => $this->createdAt,
|
||||
'expires_at' => $this->expiresAt,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize from array
|
||||
*/
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
return new self(
|
||||
id: $data['id'],
|
||||
state: $data['state'],
|
||||
tenantIdentifier: $data['tenant_identifier'],
|
||||
userIdentifier: $data['user_identifier'] ?? null,
|
||||
userIdentity: $data['user_identity'] ?? null,
|
||||
methodsRequired: $data['methods_required'] ?? 1,
|
||||
methodsAvailable: $data['methods_available'] ?? [],
|
||||
methodsCompleted: $data['methods_completed'] ?? [],
|
||||
metadata: $data['metadata'] ?? [],
|
||||
createdAt: $data['created_at'],
|
||||
expiresAt: $data['expires_at'],
|
||||
);
|
||||
}
|
||||
}
|
||||
75
shared/lib/Security/Authentication/ProviderContext.php
Normal file
75
shared/lib/Security/Authentication/ProviderContext.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXF\Security\Authentication;
|
||||
|
||||
/**
|
||||
* Provider Context
|
||||
*
|
||||
* Immutable context passed to authentication providers.
|
||||
* Contains all information a provider needs to perform authentication.
|
||||
*/
|
||||
readonly class ProviderContext
|
||||
{
|
||||
public function __construct(
|
||||
/** Tenant identifier */
|
||||
public string $tenantId,
|
||||
|
||||
/** User's internal identifier (after lookup) */
|
||||
public ?string $userIdentifier = null,
|
||||
|
||||
/** User's identity input (email/username) */
|
||||
public ?string $userIdentity = null,
|
||||
|
||||
/** Provider-specific metadata from session */
|
||||
public array $metadata = [],
|
||||
|
||||
/** Provider configuration */
|
||||
public array $config = [],
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get metadata value
|
||||
*/
|
||||
public function getMeta(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $this->metadata[$key] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get config value
|
||||
*/
|
||||
public function getConfig(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $this->config[$key] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create context with updated metadata
|
||||
*/
|
||||
public function withMetadata(array $metadata): self
|
||||
{
|
||||
return new self(
|
||||
tenantId: $this->tenantId,
|
||||
userIdentifier: $this->userIdentifier,
|
||||
userIdentity: $this->userIdentity,
|
||||
metadata: $metadata,
|
||||
config: $this->config,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create context with user identifier set
|
||||
*/
|
||||
public function withUserIdentifier(string $userIdentifier): self
|
||||
{
|
||||
return new self(
|
||||
tenantId: $this->tenantId,
|
||||
userIdentifier: $userIdentifier,
|
||||
userIdentity: $this->userIdentity,
|
||||
metadata: $this->metadata,
|
||||
config: $this->config,
|
||||
);
|
||||
}
|
||||
}
|
||||
156
shared/lib/Security/Authentication/ProviderResult.php
Normal file
156
shared/lib/Security/Authentication/ProviderResult.php
Normal file
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXF\Security\Authentication;
|
||||
|
||||
/**
|
||||
* Provider Result
|
||||
*
|
||||
* Result returned from authentication providers.
|
||||
* Separates client-facing data from session persistence data.
|
||||
*/
|
||||
readonly class ProviderResult
|
||||
{
|
||||
// Status constants
|
||||
public const SUCCESS = 'success';
|
||||
public const FAILED = 'failed';
|
||||
public const CHALLENGE = 'challenge';
|
||||
public const REDIRECT = 'redirect';
|
||||
|
||||
// Error codes
|
||||
public const ERROR_INVALID_CREDENTIALS = 'invalid_credentials';
|
||||
public const ERROR_INVALID_PROVIDER = 'invalid_provider';
|
||||
public const ERROR_INVALID_FACTOR = 'invalid_factor';
|
||||
public const ERROR_FACTOR_FAILED = 'factor_failed';
|
||||
public const ERROR_NOT_ENROLLED = 'not_enrolled';
|
||||
public const ERROR_RATE_LIMITED = 'rate_limited';
|
||||
public const ERROR_INTERNAL = 'internal_error';
|
||||
|
||||
public function __construct(
|
||||
/** Result status */
|
||||
public string $status,
|
||||
|
||||
/** Error code for failures */
|
||||
public ?string $errorCode = null,
|
||||
|
||||
/** Human-readable error message */
|
||||
public ?string $errorMessage = null,
|
||||
|
||||
/** Verified identity claims (user_id, provider, etc.) */
|
||||
public array $identity = [],
|
||||
|
||||
/** Data to send to client (challenge info, redirect URL, etc.) */
|
||||
public array $clientData = [],
|
||||
|
||||
/** Data to persist in session for subsequent calls */
|
||||
public array $sessionData = [],
|
||||
) {}
|
||||
|
||||
// =========================================================================
|
||||
// Factory Methods
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Create a success result
|
||||
*/
|
||||
public static function success(array $identity = [], array $clientData = [], array $sessionData = []): self
|
||||
{
|
||||
return new self(
|
||||
status: self::SUCCESS,
|
||||
identity: $identity,
|
||||
clientData: $clientData,
|
||||
sessionData: $sessionData,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a failed result
|
||||
*/
|
||||
public static function failed(
|
||||
string $errorCode = self::ERROR_INVALID_CREDENTIALS,
|
||||
?string $errorMessage = null
|
||||
): self {
|
||||
return new self(
|
||||
status: self::FAILED,
|
||||
errorCode: $errorCode,
|
||||
errorMessage: $errorMessage,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a challenge result (for challenge-based auth)
|
||||
*/
|
||||
public static function challenge(array $challengeInfo, array $sessionData = []): self
|
||||
{
|
||||
return new self(
|
||||
status: self::CHALLENGE,
|
||||
clientData: ['challenge' => $challengeInfo],
|
||||
sessionData: $sessionData,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a redirect result (for OIDC/SAML)
|
||||
*/
|
||||
public static function redirect(string $url, array $sessionData): self
|
||||
{
|
||||
return new self(
|
||||
status: self::REDIRECT,
|
||||
clientData: ['redirect_url' => $url],
|
||||
sessionData: $sessionData,
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Status Checks
|
||||
// =========================================================================
|
||||
|
||||
public function isSuccess(): bool
|
||||
{
|
||||
return $this->status === self::SUCCESS;
|
||||
}
|
||||
|
||||
public function isFailed(): bool
|
||||
{
|
||||
return $this->status === self::FAILED;
|
||||
}
|
||||
|
||||
public function isChallenge(): bool
|
||||
{
|
||||
return $this->status === self::CHALLENGE;
|
||||
}
|
||||
|
||||
public function isRedirect(): bool
|
||||
{
|
||||
return $this->status === self::REDIRECT;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Data Access
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Get identity claim
|
||||
*/
|
||||
public function getIdentity(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $this->identity[$key] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client data value
|
||||
*/
|
||||
public function getClientData(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $this->clientData[$key] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session data value
|
||||
*/
|
||||
public function getSessionData(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $this->sessionData[$key] ?? $default;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user