Initial commit
This commit is contained in:
204
lib/Controllers/EnrollmentController.php
Normal file
204
lib/Controllers/EnrollmentController.php
Normal file
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\AuthenticationProviderTotp\Controllers;
|
||||
|
||||
use KTXC\Http\Response\JsonResponse;
|
||||
use KTXC\SessionIdentity;
|
||||
use KTXF\Controller\ControllerAbstract;
|
||||
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
||||
use KTXM\AuthenticationProviderTotp\Services\EnrollmentService;
|
||||
|
||||
/**
|
||||
* TOTP Enrollment Controller
|
||||
*
|
||||
* Handles TOTP device enrollment for users:
|
||||
* - Get enrollment status
|
||||
* - Begin enrollment (generate QR code)
|
||||
* - Complete enrollment (verify code)
|
||||
* - Remove enrollment
|
||||
* - Regenerate recovery codes
|
||||
*/
|
||||
class EnrollmentController extends ControllerAbstract
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SessionIdentity $userIdentity,
|
||||
private readonly EnrollmentService $enrollmentService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get TOTP enrollment status for the current user
|
||||
*/
|
||||
#[AuthenticatedRoute('/totp/status', name: 'totp.status', methods: ['GET'])]
|
||||
public function status(): JsonResponse
|
||||
{
|
||||
$userId = $this->userIdentity->identifier();
|
||||
if (!$userId) {
|
||||
return new JsonResponse(
|
||||
['error' => 'User not authenticated', 'error_code' => 'unauthorized'],
|
||||
JsonResponse::HTTP_UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
$isEnrolled = $this->enrollmentService->isEnrolled($userId);
|
||||
|
||||
return new JsonResponse([
|
||||
'enrolled' => $isEnrolled,
|
||||
'provider' => 'totp',
|
||||
'label' => 'TOTP Code',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin TOTP enrollment - generates secret and QR code
|
||||
*/
|
||||
#[AuthenticatedRoute('/totp/enroll', name: 'totp.enroll.begin', methods: ['POST'])]
|
||||
public function beginEnrollment(string $accountName = ''): JsonResponse
|
||||
{
|
||||
$userId = $this->userIdentity->identifier();
|
||||
if (!$userId) {
|
||||
return new JsonResponse(
|
||||
['error' => 'User not authenticated', 'error_code' => 'unauthorized'],
|
||||
JsonResponse::HTTP_UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
$config = [];
|
||||
if (!empty($accountName)) {
|
||||
$config['account_name'] = $accountName;
|
||||
}
|
||||
|
||||
$result = $this->enrollmentService->beginEnrollment($userId, $config);
|
||||
|
||||
if (!$result['success']) {
|
||||
return new JsonResponse(
|
||||
['error' => $result['error'], 'error_code' => $result['error_code']],
|
||||
JsonResponse::HTTP_BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
$enrollment = $result['enrollment'] ?? [];
|
||||
|
||||
return new JsonResponse([
|
||||
'status' => 'pending',
|
||||
'secret' => $enrollment['secret'] ?? '',
|
||||
'provisioning_uri' => $enrollment['provisioning_uri'] ?? '',
|
||||
'qr_code' => $enrollment['qr_code'] ?? '',
|
||||
'recovery_codes' => $enrollment['recovery_codes'] ?? [],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete TOTP enrollment - verify the initial code
|
||||
*/
|
||||
#[AuthenticatedRoute('/totp/enroll/verify', name: 'totp.enroll.verify', methods: ['POST'])]
|
||||
public function completeEnrollment(string $code): JsonResponse
|
||||
{
|
||||
$userId = $this->userIdentity->identifier();
|
||||
if (!$userId) {
|
||||
return new JsonResponse(
|
||||
['error' => 'User not authenticated', 'error_code' => 'unauthorized'],
|
||||
JsonResponse::HTTP_UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
if (empty($code)) {
|
||||
return new JsonResponse(
|
||||
['error' => 'Verification code is required', 'error_code' => 'invalid_request'],
|
||||
JsonResponse::HTTP_BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
$result = $this->enrollmentService->completeEnrollment($userId, $code);
|
||||
|
||||
if (!$result['success']) {
|
||||
return new JsonResponse(
|
||||
['error' => $result['error'], 'error_code' => $result['error_code']],
|
||||
JsonResponse::HTTP_BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
return new JsonResponse([
|
||||
'status' => 'enrolled',
|
||||
'message' => 'TOTP successfully enabled for your account',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove TOTP enrollment (disable 2FA)
|
||||
*/
|
||||
#[AuthenticatedRoute('/totp/enroll', name: 'totp.enroll.remove', methods: ['DELETE'])]
|
||||
public function removeEnrollment(string $code): JsonResponse
|
||||
{
|
||||
$userId = $this->userIdentity->identifier();
|
||||
if (!$userId) {
|
||||
return new JsonResponse(
|
||||
['error' => 'User not authenticated', 'error_code' => 'unauthorized'],
|
||||
JsonResponse::HTTP_UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
// Verify current code before removing
|
||||
$verifyResult = $this->enrollmentService->verifyCode($userId, $code);
|
||||
if (!$verifyResult['success']) {
|
||||
return new JsonResponse(
|
||||
['error' => 'Invalid verification code', 'error_code' => 'invalid_code'],
|
||||
JsonResponse::HTTP_BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
$result = $this->enrollmentService->removeEnrollment($userId);
|
||||
|
||||
if (!$result['success']) {
|
||||
return new JsonResponse(
|
||||
['error' => $result['error'], 'error_code' => $result['error_code']],
|
||||
JsonResponse::HTTP_BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
return new JsonResponse([
|
||||
'status' => 'removed',
|
||||
'message' => 'TOTP has been disabled for your account',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin endpoint: Get TOTP enrollment status for any user
|
||||
*/
|
||||
#[AuthenticatedRoute('/admin/status/{uid}', name: 'totp.admin.status', methods: ['GET'])]
|
||||
public function adminStatus(string $uid): JsonResponse
|
||||
{
|
||||
// TODO: Add permission check for admin operations
|
||||
|
||||
$isEnrolled = $this->enrollmentService->isEnrolled($uid);
|
||||
|
||||
return new JsonResponse([
|
||||
'enrolled' => $isEnrolled,
|
||||
'provider' => 'totp',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin endpoint: Remove TOTP enrollment for any user (no code verification required)
|
||||
*/
|
||||
#[AuthenticatedRoute('/admin/enrollment/{uid}', name: 'totp.admin.remove', methods: ['DELETE'])]
|
||||
public function adminRemoveEnrollment(string $uid): JsonResponse
|
||||
{
|
||||
// TODO: Add permission check for admin operations
|
||||
|
||||
$result = $this->enrollmentService->removeEnrollment($uid);
|
||||
|
||||
if (!$result['success']) {
|
||||
return new JsonResponse(
|
||||
['error' => $result['error'], 'error_code' => $result['error_code']],
|
||||
JsonResponse::HTTP_BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
return new JsonResponse([
|
||||
'status' => 'removed',
|
||||
'message' => 'TOTP enrollment removed successfully',
|
||||
]);
|
||||
}
|
||||
}
|
||||
73
lib/Module.php
Normal file
73
lib/Module.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\AuthenticationProviderTotp;
|
||||
|
||||
use KTXC\Resource\ProviderManager;
|
||||
use KTXF\Module\ModuleBrowserInterface;
|
||||
use KTXF\Module\ModuleInstanceAbstract;
|
||||
|
||||
/**
|
||||
* TOTP Identity Provider Module
|
||||
*/
|
||||
class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProviderManager $providerManager,
|
||||
) {}
|
||||
|
||||
public function handle(): string
|
||||
{
|
||||
return 'authentication_provider_totp';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'TOTP Authentication Provider';
|
||||
}
|
||||
|
||||
public function author(): string
|
||||
{
|
||||
return 'Ktrix';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Time-based One-Time Password (TOTP) authentication provider - compatible with FreeOTP, Google Authenticator, Authy, and similar apps';
|
||||
}
|
||||
|
||||
public function version(): string
|
||||
{
|
||||
return '0.0.1';
|
||||
}
|
||||
|
||||
public function permissions(): array
|
||||
{
|
||||
return [
|
||||
'authentication_provider_totp' => [
|
||||
'label' => 'Access TOTP Authentication Provider',
|
||||
'description' => 'View and access the TOTP authentication provider module',
|
||||
'group' => 'Authentication Providers'
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
$this->providerManager->register('authentication', 'totp', Provider::class);
|
||||
}
|
||||
|
||||
public function registerBI(): array
|
||||
{
|
||||
return [
|
||||
'handle' => $this->handle(),
|
||||
'namespace' => 'AuthenticationProviderTotp',
|
||||
'version' => $this->version(),
|
||||
'label' => $this->label(),
|
||||
'author' => $this->author(),
|
||||
'description' => $this->description(),
|
||||
'boot' => 'static/module.mjs',
|
||||
];
|
||||
}
|
||||
}
|
||||
127
lib/Provider.php
Normal file
127
lib/Provider.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\AuthenticationProviderTotp;
|
||||
|
||||
use KTXF\Security\Authentication\AuthenticationProviderAbstract;
|
||||
use KTXF\Security\Authentication\ProviderContext;
|
||||
use KTXF\Security\Authentication\ProviderResult;
|
||||
use KTXM\AuthenticationProviderTotp\Services\EnrollmentService;
|
||||
|
||||
/**
|
||||
* TOTP Authentication Provider
|
||||
*
|
||||
* Implements Time-based One-Time Password (TOTP) according to RFC 6238.
|
||||
* Compatible with FreeOTP, Google Authenticator, Authy, Microsoft Authenticator, etc.
|
||||
*/
|
||||
class Provider extends AuthenticationProviderAbstract
|
||||
{
|
||||
private const DEFAULT_DIGITS = 6;
|
||||
|
||||
public function __construct(
|
||||
private readonly EnrollmentService $enrollmentService,
|
||||
) {}
|
||||
|
||||
// =========================================================================
|
||||
// Provider Implementation
|
||||
// =========================================================================
|
||||
|
||||
public function type(): string
|
||||
{
|
||||
return 'authentication';
|
||||
}
|
||||
|
||||
public function identifier(): string
|
||||
{
|
||||
return 'totp';
|
||||
}
|
||||
|
||||
public function method(): string
|
||||
{
|
||||
return self::METHOD_CREDENTIAL;
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'TOTP Code';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Authenticate using Time-based One-Time Passwords (TOTP) from an authenticator app.';
|
||||
}
|
||||
|
||||
public function icon(): string
|
||||
{
|
||||
return 'clock';
|
||||
}
|
||||
|
||||
public function beginChallenge(ProviderContext $context): ProviderResult
|
||||
{
|
||||
$userIdentifier = $context->userIdentifier;
|
||||
|
||||
if (empty($userIdentifier)) {
|
||||
return ProviderResult::failed(
|
||||
ProviderResult::ERROR_INVALID_FACTOR,
|
||||
'User identifier is required'
|
||||
);
|
||||
}
|
||||
|
||||
// Verify user is enrolled
|
||||
if (!$this->enrollmentService->isEnrolled($userIdentifier)) {
|
||||
return ProviderResult::failed(
|
||||
ProviderResult::ERROR_NOT_ENROLLED,
|
||||
'TOTP is not enrolled for this user'
|
||||
);
|
||||
}
|
||||
|
||||
// TOTP doesn't require sending anything to begin challenge
|
||||
// The user generates the code on their device
|
||||
return ProviderResult::challenge([
|
||||
'type' => 'totp',
|
||||
'message' => 'Enter the code from your authenticator app',
|
||||
'digits' => self::DEFAULT_DIGITS,
|
||||
]);
|
||||
}
|
||||
|
||||
public function verifyChallenge(ProviderContext $context, string $code): ProviderResult
|
||||
{
|
||||
$userIdentifier = $context->userIdentifier;
|
||||
|
||||
if (empty($userIdentifier)) {
|
||||
return ProviderResult::failed(
|
||||
ProviderResult::ERROR_INVALID_FACTOR,
|
||||
'User identifier is required'
|
||||
);
|
||||
}
|
||||
|
||||
$result = $this->enrollmentService->verifyCode($userIdentifier, $code);
|
||||
|
||||
if (!$result['success']) {
|
||||
return ProviderResult::failed(
|
||||
ProviderResult::ERROR_FACTOR_FAILED,
|
||||
$result['error'] ?? 'Invalid verification code'
|
||||
);
|
||||
}
|
||||
|
||||
$identity = [
|
||||
'user_id' => $userIdentifier,
|
||||
'provider' => $this->identifier(),
|
||||
];
|
||||
|
||||
if ($result['used_recovery_code'] ?? false) {
|
||||
$identity['used_recovery_code'] = true;
|
||||
}
|
||||
|
||||
return ProviderResult::success($identity);
|
||||
}
|
||||
|
||||
/**
|
||||
* TOTP can also be used as a credential method (verify directly without challenge)
|
||||
*/
|
||||
public function verify(ProviderContext $context, string $secret): ProviderResult
|
||||
{
|
||||
return $this->verifyChallenge($context, $secret);
|
||||
}
|
||||
}
|
||||
409
lib/Services/EnrollmentService.php
Normal file
409
lib/Services/EnrollmentService.php
Normal file
@@ -0,0 +1,409 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\AuthenticationProviderTotp\Services;
|
||||
|
||||
use KTXC\SessionTenant;
|
||||
use KTXM\AuthenticationProviderTotp\Stores\EnrollmentStore;
|
||||
|
||||
/**
|
||||
* TOTP Enrollment Service
|
||||
*
|
||||
* Handles TOTP enrollment lifecycle:
|
||||
* - Checking enrollment status
|
||||
* - Beginning enrollment (generating secrets and QR codes)
|
||||
* - Completing enrollment (verifying initial code)
|
||||
* - Removing enrollment
|
||||
* - Managing recovery codes
|
||||
*/
|
||||
class EnrollmentService
|
||||
{
|
||||
// TOTP Configuration defaults
|
||||
private const DEFAULT_DIGITS = 6;
|
||||
private const DEFAULT_PERIOD = 30;
|
||||
private const DEFAULT_ALGORITHM = 'sha1';
|
||||
private const SECRET_LENGTH = 20; // 160 bits as recommended by RFC 4226
|
||||
|
||||
public function __construct(
|
||||
private readonly SessionTenant $tenant,
|
||||
private readonly EnrollmentStore $enrollmentStore,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Check if user has a verified TOTP enrollment
|
||||
*/
|
||||
public function isEnrolled(string $userId): bool
|
||||
{
|
||||
$tenantId = $this->tenant->identifier();
|
||||
if (!$tenantId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$enrollment = $this->enrollmentStore->getEnrollment($tenantId, $userId);
|
||||
return $enrollment !== null && ($enrollment['verified'] ?? false) === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin TOTP enrollment - generates secret, provisioning URI, and recovery codes
|
||||
*
|
||||
* @param string $userId User identifier
|
||||
* @param array $config Configuration options (issuer, account_name)
|
||||
* @return array{success: bool, error?: string, error_code?: string, enrollment?: array}
|
||||
*/
|
||||
public function beginEnrollment(string $userId, array $config = []): array
|
||||
{
|
||||
$tenantId = $this->tenant->identifier();
|
||||
if (!$tenantId) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Invalid tenant context',
|
||||
'error_code' => 'internal_error',
|
||||
];
|
||||
}
|
||||
|
||||
// Check if already enrolled
|
||||
if ($this->isEnrolled($userId)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'TOTP is already enrolled for this user',
|
||||
'error_code' => 'already_enrolled',
|
||||
];
|
||||
}
|
||||
|
||||
// Generate new secret
|
||||
$secret = $this->generateSecret();
|
||||
|
||||
// Generate recovery codes
|
||||
$recoveryCodes = $this->generateRecoveryCodes();
|
||||
|
||||
// Store pending enrollment (not yet verified)
|
||||
$this->enrollmentStore->storePendingEnrollment($tenantId, $userId, $secret, $recoveryCodes);
|
||||
|
||||
// Generate provisioning URI for QR code
|
||||
$issuer = $config['issuer'] ?? 'Ktrix';
|
||||
$accountName = $config['account_name'] ?? $userId;
|
||||
$provisioningUri = $this->generateProvisioningUri($secret, $issuer, $accountName);
|
||||
|
||||
// Generate QR code data URL (empty for client-side generation)
|
||||
$qrCodeDataUrl = $this->generateQrCodeDataUrl($provisioningUri);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'enrollment' => [
|
||||
'secret' => $secret,
|
||||
'provisioning_uri' => $provisioningUri,
|
||||
'qr_code' => $qrCodeDataUrl,
|
||||
'recovery_codes' => $recoveryCodes,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete TOTP enrollment by verifying the initial code
|
||||
*
|
||||
* @param string $userId User identifier
|
||||
* @param string $code Verification code from authenticator app
|
||||
* @return array{success: bool, error?: string, error_code?: string}
|
||||
*/
|
||||
public function completeEnrollment(string $userId, string $code): array
|
||||
{
|
||||
$tenantId = $this->tenant->identifier();
|
||||
if (!$tenantId) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Invalid tenant context',
|
||||
'error_code' => 'internal_error',
|
||||
];
|
||||
}
|
||||
|
||||
// Get pending enrollment
|
||||
$enrollment = $this->enrollmentStore->getPendingEnrollment($tenantId, $userId);
|
||||
if (!$enrollment) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'No pending TOTP enrollment found',
|
||||
'error_code' => 'no_pending_enrollment',
|
||||
];
|
||||
}
|
||||
|
||||
// Verify the code
|
||||
if (!$this->verifyCode($enrollment['secret'], $code)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Invalid verification code',
|
||||
'error_code' => 'invalid_code',
|
||||
];
|
||||
}
|
||||
|
||||
// Mark enrollment as verified
|
||||
$this->enrollmentStore->confirmEnrollment($tenantId, $userId);
|
||||
|
||||
return ['success' => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove TOTP enrollment for a user
|
||||
*
|
||||
* @param string $userId User identifier
|
||||
* @return array{success: bool, error?: string, error_code?: string}
|
||||
*/
|
||||
public function removeEnrollment(string $userId): array
|
||||
{
|
||||
$tenantId = $this->tenant->identifier();
|
||||
if (!$tenantId) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Invalid tenant context',
|
||||
'error_code' => 'internal_error',
|
||||
];
|
||||
}
|
||||
|
||||
if (!$this->isEnrolled($userId)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'TOTP is not enrolled for this user',
|
||||
'error_code' => 'not_enrolled',
|
||||
];
|
||||
}
|
||||
|
||||
$this->enrollmentStore->removeEnrollment($tenantId, $userId);
|
||||
|
||||
return ['success' => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a TOTP code for an enrolled user
|
||||
*
|
||||
* @param string $userId User identifier
|
||||
* @param string $code Verification code
|
||||
* @return array{success: bool, error?: string, error_code?: string, used_recovery_code?: bool}
|
||||
*/
|
||||
public function verifyCode(string $userId, string $code): array
|
||||
{
|
||||
$tenantId = $this->tenant->identifier();
|
||||
if (!$tenantId) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Invalid tenant context',
|
||||
'error_code' => 'internal_error',
|
||||
];
|
||||
}
|
||||
|
||||
// Get enrollment
|
||||
$enrollment = $this->enrollmentStore->getEnrollment($tenantId, $userId);
|
||||
if (!$enrollment) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'TOTP is not enrolled for this user',
|
||||
'error_code' => 'not_enrolled',
|
||||
];
|
||||
}
|
||||
|
||||
// Check if it's a recovery code
|
||||
if ($this->verifyRecoveryCode($tenantId, $userId, $code)) {
|
||||
return [
|
||||
'success' => true,
|
||||
'used_recovery_code' => true,
|
||||
];
|
||||
}
|
||||
|
||||
// Verify TOTP code
|
||||
if (!$this->verifyTotpCode($enrollment['secret'], $code)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Invalid verification code',
|
||||
'error_code' => 'invalid_code',
|
||||
];
|
||||
}
|
||||
|
||||
return ['success' => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's enrollment secret (for provider verification)
|
||||
*/
|
||||
public function getEnrollmentSecret(string $userId): ?string
|
||||
{
|
||||
$tenantId = $this->tenant->identifier();
|
||||
if (!$tenantId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$enrollment = $this->enrollmentStore->getEnrollment($tenantId, $userId);
|
||||
return $enrollment['secret'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a TOTP code against a secret (for provider use)
|
||||
*/
|
||||
public function verifyTotpCode(string $secret, string $code, int $window = 1): bool
|
||||
{
|
||||
$code = preg_replace('/\s+/', '', $code);
|
||||
|
||||
if (strlen($code) !== self::DEFAULT_DIGITS) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$timestamp = time();
|
||||
$counter = (int) floor($timestamp / self::DEFAULT_PERIOD);
|
||||
|
||||
for ($i = -$window; $i <= $window; $i++) {
|
||||
$expectedCode = $this->generateTotp($secret, $counter + $i);
|
||||
if (hash_equals($expectedCode, $code)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a recovery code
|
||||
*/
|
||||
public function verifyRecoveryCode(string $tenantId, string $userId, string $code): bool
|
||||
{
|
||||
$code = strtoupper(preg_replace('/\s+/', '', $code));
|
||||
$validCodes = $this->enrollmentStore->getRecoveryCodes($tenantId, $userId);
|
||||
|
||||
foreach ($validCodes as $index => $storedCode) {
|
||||
if (hash_equals($storedCode, $code)) {
|
||||
$this->enrollmentStore->markRecoveryCodeUsed($tenantId, $userId, $index);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private Helper Methods
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Generate a cryptographically secure secret
|
||||
*/
|
||||
private function generateSecret(): string
|
||||
{
|
||||
$bytes = random_bytes(self::SECRET_LENGTH);
|
||||
return $this->base32Encode($bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a provisioning URI for QR codes (otpauth:// format)
|
||||
*/
|
||||
private function generateProvisioningUri(string $secret, string $issuer, string $accountName): string
|
||||
{
|
||||
$params = http_build_query([
|
||||
'secret' => $secret,
|
||||
'issuer' => $issuer,
|
||||
'algorithm' => strtoupper(self::DEFAULT_ALGORITHM),
|
||||
'digits' => self::DEFAULT_DIGITS,
|
||||
'period' => self::DEFAULT_PERIOD,
|
||||
]);
|
||||
|
||||
$label = rawurlencode($issuer) . ':' . rawurlencode($accountName);
|
||||
|
||||
return "otpauth://totp/{$label}?{$params}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate QR code as data URL
|
||||
*/
|
||||
private function generateQrCodeDataUrl(string $uri): string
|
||||
{
|
||||
// Return empty for client-side QR generation
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate recovery codes
|
||||
*/
|
||||
private function generateRecoveryCodes(int $count = 10): array
|
||||
{
|
||||
$codes = [];
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$part1 = strtoupper(bin2hex(random_bytes(2)));
|
||||
$part2 = strtoupper(bin2hex(random_bytes(2)));
|
||||
$codes[] = "{$part1}-{$part2}";
|
||||
}
|
||||
return $codes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate TOTP code for a given counter
|
||||
*/
|
||||
private function generateTotp(string $secret, int $counter): string
|
||||
{
|
||||
$secretBytes = $this->base32Decode($secret);
|
||||
$counterBytes = pack('J', $counter);
|
||||
$hash = hash_hmac(self::DEFAULT_ALGORITHM, $counterBytes, $secretBytes, true);
|
||||
|
||||
$offset = ord($hash[strlen($hash) - 1]) & 0x0F;
|
||||
$binary = (ord($hash[$offset]) & 0x7F) << 24
|
||||
| ord($hash[$offset + 1]) << 16
|
||||
| ord($hash[$offset + 2]) << 8
|
||||
| ord($hash[$offset + 3]);
|
||||
|
||||
$otp = $binary % (10 ** self::DEFAULT_DIGITS);
|
||||
|
||||
return str_pad((string) $otp, self::DEFAULT_DIGITS, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Base32 encode (RFC 4648)
|
||||
*/
|
||||
private function base32Encode(string $data): string
|
||||
{
|
||||
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
$encoded = '';
|
||||
$bitLen = 0;
|
||||
$val = 0;
|
||||
$len = strlen($data);
|
||||
|
||||
for ($i = 0; $i < $len; $i++) {
|
||||
$val = ($val << 8) | ord($data[$i]);
|
||||
$bitLen += 8;
|
||||
|
||||
while ($bitLen >= 5) {
|
||||
$bitLen -= 5;
|
||||
$encoded .= $alphabet[($val >> $bitLen) & 0x1F];
|
||||
}
|
||||
}
|
||||
|
||||
if ($bitLen > 0) {
|
||||
$encoded .= $alphabet[($val << (5 - $bitLen)) & 0x1F];
|
||||
}
|
||||
|
||||
return $encoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base32 decode (RFC 4648)
|
||||
*/
|
||||
private function base32Decode(string $data): string
|
||||
{
|
||||
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
$data = strtoupper(str_replace('=', '', $data));
|
||||
|
||||
$decoded = '';
|
||||
$val = 0;
|
||||
$bitLen = 0;
|
||||
$len = strlen($data);
|
||||
|
||||
for ($i = 0; $i < $len; $i++) {
|
||||
$pos = strpos($alphabet, $data[$i]);
|
||||
if ($pos === false) continue;
|
||||
|
||||
$val = ($val << 5) | $pos;
|
||||
$bitLen += 5;
|
||||
|
||||
if ($bitLen >= 8) {
|
||||
$bitLen -= 8;
|
||||
$decoded .= chr(($val >> $bitLen) & 0xFF);
|
||||
}
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
}
|
||||
184
lib/Stores/EnrollmentStore.php
Normal file
184
lib/Stores/EnrollmentStore.php
Normal file
@@ -0,0 +1,184 @@
|
||||
<?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' => [],
|
||||
],
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user