Initial Version

This commit is contained in:
root
2025-12-21 10:09:54 -05:00
commit 4ae6befc7b
422 changed files with 47225 additions and 0 deletions

View File

@@ -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'
);
}
}

View File

@@ -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;
}

View 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'],
);
}
}

View 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,
);
}
}

View 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;
}
}

View File

@@ -0,0 +1,170 @@
<?php
namespace KTXF\Security;
use KTXC\SessionTenant;
use phpseclib3\Crypt\AES;
/**
* Symmetric encryption helper
*/
class Crypto
{
private const NONCE_LEN = 16; // 128-bit nonce for GCM (phpseclib allows 12-16)
private const KEY_LEN = 32; // 256-bit key
private const TAG_LEN = 16; // 128-bit auth tag
// Binary envelope constants
// Layout:
// 0-3: magic 'KXCG' (4 bytes)
// 4: version (1 byte)
// 5: flags (1 byte)
// 6-7: nonce length (uint16 big-endian, 2 bytes)
// 8-9: tag length (uint16 big-endian, 2 bytes)
// 10-13: data length (uint32 big-endian, 4 bytes)
// 14...: nonce || tag || encryptedData
private const ENCODING_HEADER_TAG = "KXCG";
private const ENCODING_HEADER_VERSION = 1;
private const ENCODING_HEADER_LEN = 14;
public function __construct(protected SessionTenant $sessionTenant)
{ }
/**
* Encrypt arbitrary string data.
* Returns hex string of length-prefixed binary envelope.
*/
public function encrypt(string $data, ?string $password = null): string
{
if ($password === null) {
$password = $this->tenantSecret();
if ($password === null) {
throw new \RuntimeException('Tenant secret unavailable for encryption');
}
}
$nonce = random_bytes(self::NONCE_LEN);
$key = hash_hkdf('sha256', $password);
if ($key === false || strlen($key) !== self::KEY_LEN) {
throw new \RuntimeException('Key derivation failed');
}
$aes = new AES('gcm');
$aes->setKey($key);
$aes->setNonce($nonce);
$encryptedData = $aes->encrypt($data);
if ($encryptedData === false) {
throw new \RuntimeException('Encryption failed');
}
$tag = $aes->getTag(self::TAG_LEN);
if ($tag === false || strlen($tag) !== self::TAG_LEN) {
throw new \RuntimeException('Authentication tag retrieval failed');
}
$nonceLen = strlen($nonce);
$tagLen = strlen($tag);
$dataLen = strlen($encryptedData);
$header = self::ENCODING_HEADER_TAG
. chr(self::ENCODING_HEADER_VERSION)
. chr(0x00) // flags
. pack('n', $nonceLen) // uint16 BE
. pack('n', $tagLen) // uint16 BE
. pack('N', $dataLen); // uint32 BE
$binary = $header . $nonce . $tag . $encryptedData;
return bin2hex($binary);
}
/**
* Decrypt hex-encoded length-prefixed binary envelope.
*/
public function decrypt(string $data, ?string $password = null): string
{
if ($password === null) {
$password = $this->tenantSecret();
if ($password === null) {
throw new \RuntimeException('Tenant secret unavailable for decryption');
}
}
if (!ctype_xdigit($data) || strlen($data) % 2 !== 0) {
throw new \InvalidArgumentException('Invalid data format');
}
$binary = hex2bin($data);
if ($binary === false || strlen($binary) < self::ENCODING_HEADER_LEN) {
throw new \InvalidArgumentException('Invalid data format');
}
if (substr($binary, 0, 4) !== self::ENCODING_HEADER_TAG) {
throw new \InvalidArgumentException('Invalid data format');
}
if (ord($binary[4]) !== self::ENCODING_HEADER_VERSION) {
throw new \InvalidArgumentException('Unsupported version');
}
$flags = ord($binary[5]); // currently unused; reserved for future
$nonceLen = unpack('n', substr($binary, 6, 2))[1];
$tagLen = unpack('n', substr($binary, 8, 2))[1];
$dataLen = unpack('N', substr($binary, 10, 4))[1];
if (strlen($binary) !== (self::ENCODING_HEADER_LEN + $nonceLen + $tagLen + $dataLen)) {
throw new \InvalidArgumentException('Invalid data format');
}
$nonce = substr($binary, 14, $nonceLen);
$tag = substr($binary, 14 + $nonceLen, $tagLen);
$encryptedData = substr($binary, 14 + $nonceLen + $tagLen, $dataLen);
$key = hash_hkdf('sha256', $password);
if ($key === false || strlen($key) !== self::KEY_LEN) {
throw new \RuntimeException('Key derivation failed');
}
$aes = new AES('gcm');
$aes->setKey($key);
$aes->setNonce($nonce);
$aes->setTag($tag);
$plainData = $aes->decrypt($encryptedData);
if ($plainData === false) {
throw new \RuntimeException('Decryption failed (auth)');
}
return $plainData;
}
private function tenantSecret(): ?string
{
$config = $this->sessionTenant->configuration();
return $config->security()->code();
}
// =========================================================================
// Password Hashing
// =========================================================================
/**
* Hash a password using bcrypt
*/
public function hashPassword(string $password): string
{
return password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
}
/**
* Verify a password against a hash
*/
public function verifyPassword(string $password, string $hash): bool
{
return password_verify($password, $hash);
}
/**
* Check if a password hash needs to be rehashed
*/
public function needsRehash(string $hash): bool
{
return password_needs_rehash($hash, PASSWORD_BCRYPT, ['cost' => 12]);
}
}