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 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 $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' => [], ], ] ); } }