Files
authentication_provider_totp/lib/Stores/EnrollmentStore.php
2026-02-10 20:06:34 -05:00

185 lines
5.0 KiB
PHP

<?php
declare(strict_types=1);
namespace KTXM\AuthenticationProviderTotp\Stores;
use KTXC\Db\DataStore;
/**
* Store for TOTP enrollments and recovery codes
*
* Stores TOTP secrets and enrollment state for users.
*
* Collection: totp_enrollments
* Schema: {
* tid: string, // Tenant identifier
* uid: string, // User identifier
* secret: string, // Base32 encoded TOTP secret
* recovery_codes: array, // Array of recovery codes
* used_recovery_codes: array, // Indices of used recovery codes
* verified: bool, // Whether enrollment is verified
* created_at: int, // Creation timestamp
* verified_at: int|null // Verification timestamp
* }
*/
class EnrollmentStore
{
protected const COLLECTION_NAME = 'totp_enrollments';
public function __construct(protected DataStore $store)
{}
/**
* Get verified enrollment for a user
*/
public function getEnrollment(string $tenantId, string $userId): ?array
{
$entry = $this->store->selectCollection(self::COLLECTION_NAME)->findOne([
'tid' => $tenantId,
'uid' => $userId,
'verified' => true,
]);
return $entry ?: null;
}
/**
* Get pending (unverified) enrollment
*/
public function getPendingEnrollment(string $tenantId, string $userId): ?array
{
$entry = $this->store->selectCollection(self::COLLECTION_NAME)->findOne([
'tid' => $tenantId,
'uid' => $userId,
'verified' => false,
]);
return $entry ?: null;
}
/**
* Store a pending enrollment (not yet verified)
*/
public function storePendingEnrollment(string $tenantId, string $userId, string $secret, array $recoveryCodes = []): void
{
$collection = $this->store->selectCollection(self::COLLECTION_NAME);
// Remove any existing pending enrollment
$collection->deleteMany([
'tid' => $tenantId,
'uid' => $userId,
'verified' => false,
]);
$collection->insertOne([
'tid' => $tenantId,
'uid' => $userId,
'secret' => $secret,
'recovery_codes' => $recoveryCodes,
'used_recovery_codes' => [],
'verified' => false,
'created_at' => time(),
'verified_at' => null,
]);
}
/**
* Confirm enrollment (mark as verified)
*/
public function confirmEnrollment(string $tenantId, string $userId): void
{
$this->store->selectCollection(self::COLLECTION_NAME)->updateOne(
[
'tid' => $tenantId,
'uid' => $userId,
'verified' => false,
],
[
'$set' => [
'verified' => true,
'verified_at' => time(),
],
]
);
}
/**
* Remove enrollment (for unenrollment)
*/
public function removeEnrollment(string $tenantId, string $userId): void
{
$this->store->selectCollection(self::COLLECTION_NAME)->deleteMany([
'tid' => $tenantId,
'uid' => $userId,
]);
}
/**
* Get unused recovery codes for a user
*
* @return array<string> Unused recovery codes
*/
public function getRecoveryCodes(string $tenantId, string $userId): array
{
$enrollment = $this->getEnrollment($tenantId, $userId);
if (!$enrollment) {
return [];
}
$codes = $enrollment['recovery_codes'] ?? [];
$usedIndices = $enrollment['used_recovery_codes'] ?? [];
// Filter out used codes
$result = [];
foreach ($codes as $index => $code) {
if (!in_array($index, $usedIndices, true)) {
$result[] = $code;
}
}
return $result;
}
/**
* Mark a recovery code as used
*/
public function markRecoveryCodeUsed(string $tenantId, string $userId, int $codeIndex): void
{
$this->store->selectCollection(self::COLLECTION_NAME)->updateOne(
[
'tid' => $tenantId,
'uid' => $userId,
'verified' => true,
],
[
'$push' => [
'used_recovery_codes' => $codeIndex,
],
]
);
}
/**
* Generate new recovery codes (replaces existing)
*
* @param array<string> $codes New recovery codes
*/
public function replaceRecoveryCodes(string $tenantId, string $userId, array $codes): void
{
$this->store->selectCollection(self::COLLECTION_NAME)->updateOne(
[
'tid' => $tenantId,
'uid' => $userId,
'verified' => true,
],
[
'$set' => [
'recovery_codes' => $codes,
'used_recovery_codes' => [],
],
]
);
}
}