185 lines
5.0 KiB
PHP
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' => [],
|
|
],
|
|
]
|
|
);
|
|
}
|
|
}
|