Initial commit

This commit is contained in:
root
2025-12-21 10:00:51 -05:00
committed by Sebastian Krupinski
commit b3d456d453
17 changed files with 3693 additions and 0 deletions

29
.gitignore vendored Normal file
View File

@@ -0,0 +1,29 @@
# Frontend development
node_modules/
*.local
.env.local
.env.*.local
.cache/
.vite/
.temp/
.tmp/
# Frontend build
/static/
# Backend development
/lib/vendor/
coverage/
phpunit.xml.cache
.phpunit.result.cache
.php-cs-fixer.cache
.phpstan.cache
.phpactor/
# Editors
.DS_Store
.vscode/
.idea/
# Logs
*.log

13
composer.json Normal file
View File

@@ -0,0 +1,13 @@
{
"name": "ktrix/authentication-provider-totp",
"description": "Ktrix TOTP (Time-based One-Time Password) authentication provider",
"type": "project",
"autoload": {
"psr-4": {
"KTXM\\AuthenticationProviderTotp\\": "lib/"
}
},
"require": {
"php": ">=8.2"
}
}

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

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

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

1868
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "@ktrix/authentication-provider-totp",
"description": "Ktrix TOTP (Time-based One-Time Password) authentication provider",
"version": "1.0.0",
"private": true,
"license": "AGPL-3.0-or-later",
"author": "Ktrix",
"type": "module",
"scripts": {
"build": "vite build --mode production --config vite.config.ts",
"dev": "vite build --mode development --config vite.config.ts",
"watch": "vite build --mode development --watch --config vite.config.ts",
"typecheck": "vue-tsc --noEmit",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
},
"dependencies": {
"@mdi/js": "^7.4.47",
"pinia": "^2.3.1",
"qrcode": "^1.5.4",
"vue": "^3.5.18",
"vue-router": "^4.5.1",
"vuetify": "^3.10.2"
},
"devDependencies": {
"@types/qrcode": "^1.5.5",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.7.0",
"typescript": "~5.8.3",
"vite": "^7.1.2",
"vue-tsc": "^3.0.5"
}
}

View File

@@ -0,0 +1,169 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
interface User {
uid: string;
identity: string;
label: string;
}
const props = defineProps<{
user: User;
}>();
const emit = defineEmits<{
update: [];
}>();
const loading = ref(false);
const statusLoading = ref(false);
const error = ref<string | null>(null);
const success = ref<string | null>(null);
const isEnrolled = ref(false);
const removeDialog = ref(false);
const loadStatus = async () => {
statusLoading.value = true;
try {
const response = await fetch(`/m/authentication_provider_totp/admin/status/${props.user.uid}`, {
credentials: 'include',
});
if (response.ok) {
const data = await response.json();
isEnrolled.value = data.enrolled ?? false;
}
} catch (err) {
console.error('Failed to load TOTP status:', err);
} finally {
statusLoading.value = false;
}
};
const removeEnrollment = async () => {
loading.value = true;
error.value = null;
try {
const response = await fetch(`/m/authentication_provider_totp/admin/enrollment/${props.user.uid}`, {
method: 'DELETE',
credentials: 'include',
});
if (response.ok) {
success.value = 'TOTP enrollment removed successfully';
removeDialog.value = false;
await loadStatus();
emit('update');
} else {
const data = await response.json();
error.value = data.error || 'Failed to remove TOTP enrollment';
}
} catch (err: any) {
error.value = err.message || 'Failed to remove TOTP enrollment';
} finally {
loading.value = false;
}
};
onMounted(() => {
loadStatus();
});
</script>
<template>
<VCard variant="outlined">
<VCardTitle class="d-flex align-center">
<VIcon icon="mdi-two-factor-authentication" class="mr-2" />
Two-Factor Authentication (TOTP)
</VCardTitle>
<VCardText>
<VAlert
v-if="error"
type="error"
closable
class="mb-4"
@click:close="error = null"
>
{{ error }}
</VAlert>
<VAlert
v-if="success"
type="success"
closable
class="mb-4"
@click:close="success = null"
>
{{ success }}
</VAlert>
<div v-if="statusLoading" class="text-center py-4">
<VProgressCircular indeterminate size="small" />
</div>
<template v-else>
<p class="mb-2">
<strong>Status:</strong>
<VChip
:color="isEnrolled ? 'success' : 'grey'"
size="small"
class="ml-2"
>
{{ isEnrolled ? 'Enrolled' : 'Not Enrolled' }}
</VChip>
</p>
<p class="mb-4">
{{ isEnrolled ? 'Remove TOTP enrollment for this user. They will need to re-enroll if they want to use TOTP again.' : 'User has not enrolled in TOTP authentication. They can enroll through their account settings.' }}
</p>
<VBtn
v-if="isEnrolled"
color="warning"
@click="removeDialog = true"
>
Remove TOTP Enrollment
</VBtn>
<div v-else class="text-caption text-grey">
Users can enable TOTP from their security settings
</div>
</template>
</VCardText>
<!-- Remove Enrollment Dialog -->
<VDialog v-model="removeDialog" max-width="500">
<VCard>
<VCardTitle class="d-flex align-center">
<VIcon icon="mdi-alert" color="warning" class="mr-2" />
Remove TOTP Enrollment
</VCardTitle>
<VCardText>
<VAlert type="warning" class="mb-4">
<strong>Warning:</strong> This will remove the TOTP enrollment for this user.
They will lose access to their configured authenticator app and recovery codes.
</VAlert>
<p>
Are you sure you want to remove TOTP enrollment for <strong>{{ user.label }}</strong>?
</p>
<p class="mt-2 text-caption">
The user will need to re-enroll if they want to use TOTP authentication again.
</p>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn @click="removeDialog = false">Cancel</VBtn>
<VBtn
color="warning"
:loading="loading"
@click="removeEnrollment"
>
Remove Enrollment
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</VCard>
</template>

24
src/integrations.ts Normal file
View File

@@ -0,0 +1,24 @@
import type { ModuleIntegrations } from "@KTXC/types/moduleTypes";
const integrations: ModuleIntegrations = {
user_settings_security: [
{
id: 'totp-enrollment',
label: 'Two-Factor Authentication',
icon: 'mdi-shield-check',
priority: 20,
component: () => import('@/views/UserSettingsSecurityPanel.vue'),
},
],
user_manager_security_panels: [
{
id: 'totp-management',
label: 'Two-Factor Authentication',
icon: 'mdi-two-factor-authentication',
priority: 20,
component: () => import('@/components/AdminSecurityPanel.vue'),
},
],
};
export default integrations;

3
src/main.ts Normal file
View File

@@ -0,0 +1,3 @@
import integrations from '@/integrations'
export { integrations }

View File

@@ -0,0 +1,491 @@
<script lang="ts" setup>
import { ref, onMounted, computed } from 'vue'
import QRCode from 'qrcode'
// Enrollment state
const isLoading = ref(true)
const isEnrolled = ref(false)
const enrollmentStep = ref<'status' | 'setup' | 'verify' | 'success' | 'disable'>('status')
// Enrollment data
const secret = ref('')
const provisioningUri = ref('')
const qrCodeDataUrl = ref('')
const recoveryCodes = ref<string[]>([])
// Verification
const verificationCode = ref('')
const isVerifying = ref(false)
const verificationError = ref('')
// Disable
const disableCode = ref('')
const isDisabling = ref(false)
const disableError = ref('')
// Dialog state
const showRecoveryCodesDialog = ref(false)
const recoveryCodesCopied = ref(false)
// API base URL - module routes are prefixed with /m/{module_handle}
const apiBase = '/m/authentication_provider_totp'
// Load initial status
onMounted(async () => {
await loadStatus()
})
async function loadStatus() {
isLoading.value = true
try {
const response = await fetch(`${apiBase}/totp/status`, {
credentials: 'include',
})
const data = await response.json()
isEnrolled.value = data.enrolled ?? false
enrollmentStep.value = isEnrolled.value ? 'status' : 'status'
} catch (error) {
console.error('Failed to load TOTP status:', error)
} finally {
isLoading.value = false
}
}
async function startEnrollment() {
isLoading.value = true
verificationError.value = ''
try {
const response = await fetch(`${apiBase}/totp/enroll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({}),
})
const data = await response.json()
if (!response.ok) {
verificationError.value = data.error || 'Failed to start enrollment'
return
}
secret.value = data.secret
provisioningUri.value = data.provisioning_uri
recoveryCodes.value = data.recovery_codes || []
// Generate QR code client-side
if (provisioningUri.value) {
qrCodeDataUrl.value = await QRCode.toDataURL(provisioningUri.value, {
width: 256,
margin: 2,
color: {
dark: '#000000',
light: '#ffffff',
},
})
}
enrollmentStep.value = 'setup'
} catch (error) {
console.error('Failed to start enrollment:', error)
verificationError.value = 'Failed to start enrollment. Please try again.'
} finally {
isLoading.value = false
}
}
async function verifyEnrollment() {
if (!verificationCode.value || verificationCode.value.length !== 6) {
verificationError.value = 'Please enter a valid 6-digit code'
return
}
isVerifying.value = true
verificationError.value = ''
try {
const response = await fetch(`${apiBase}/totp/enroll/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ code: verificationCode.value }),
})
const data = await response.json()
if (!response.ok) {
verificationError.value = data.error || 'Invalid verification code'
return
}
isEnrolled.value = true
enrollmentStep.value = 'success'
showRecoveryCodesDialog.value = true
} catch (error) {
console.error('Failed to verify enrollment:', error)
verificationError.value = 'Verification failed. Please try again.'
} finally {
isVerifying.value = false
}
}
async function disableTotp() {
if (!disableCode.value || disableCode.value.length !== 6) {
disableError.value = 'Please enter a valid 6-digit code'
return
}
isDisabling.value = true
disableError.value = ''
try {
const response = await fetch(`${apiBase}/totp/enroll?code=${encodeURIComponent(disableCode.value)}`, {
method: 'DELETE',
credentials: 'include',
})
const data = await response.json()
if (!response.ok) {
disableError.value = data.error || 'Invalid verification code'
return
}
isEnrolled.value = false
enrollmentStep.value = 'status'
disableCode.value = ''
// Reset enrollment data
secret.value = ''
provisioningUri.value = ''
qrCodeDataUrl.value = ''
recoveryCodes.value = []
verificationCode.value = ''
} catch (error) {
console.error('Failed to disable TOTP:', error)
disableError.value = 'Failed to disable TOTP. Please try again.'
} finally {
isDisabling.value = false
}
}
function cancelEnrollment() {
enrollmentStep.value = 'status'
verificationCode.value = ''
verificationError.value = ''
}
function cancelDisable() {
enrollmentStep.value = 'status'
disableCode.value = ''
disableError.value = ''
}
function showDisableDialog() {
enrollmentStep.value = 'disable'
}
async function copyRecoveryCodes() {
const codesText = recoveryCodes.value.join('\n')
await navigator.clipboard.writeText(codesText)
recoveryCodesCopied.value = true
setTimeout(() => {
recoveryCodesCopied.value = false
}, 2000)
}
function downloadRecoveryCodes() {
const codesText = recoveryCodes.value.join('\n')
const blob = new Blob([codesText], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'totp-recovery-codes.txt'
a.click()
URL.revokeObjectURL(url)
}
function finishSetup() {
showRecoveryCodesDialog.value = false
enrollmentStep.value = 'status'
}
const formattedSecret = computed(() => {
// Format secret in groups of 4 for easier reading
return secret.value.match(/.{1,4}/g)?.join(' ') || secret.value
})
</script>
<template>
<VCol cols="12" md="8" lg="4">
<VCard>
<VCardTitle class="d-flex align-center">
<VIcon start icon="mdi-shield-check" />
Two-Factor Authentication (TOTP)
</VCardTitle>
<VCardText>
<!-- Loading State -->
<div v-if="isLoading" class="text-center py-8">
<VProgressCircular indeterminate color="primary" />
<p class="mt-4 text-body-2">Loading...</p>
</div>
<!-- Status View (Not Enrolled) -->
<template v-else-if="enrollmentStep === 'status' && !isEnrolled">
<VAlert
type="info"
variant="tonal"
class="mb-4"
>
<VAlertTitle>Two-factor authentication is not enabled</VAlertTitle>
<p class="mb-0">
Add an extra layer of security to your account by enabling two-factor authentication
using an authenticator app like Google Authenticator, Authy, or Microsoft Authenticator.
</p>
</VAlert>
<VBtn
color="primary"
@click="startEnrollment"
>
<VIcon start icon="mdi-shield-plus" />
Enable Two-Factor Authentication
</VBtn>
</template>
<!-- Status View (Enrolled) -->
<template v-else-if="enrollmentStep === 'status' && isEnrolled">
<VAlert
type="success"
variant="tonal"
class="mb-4"
>
<VAlertTitle>Two-factor authentication is enabled</VAlertTitle>
<p class="mb-0">
Your account is protected with TOTP two-factor authentication.
</p>
</VAlert>
<VBtn
color="error"
variant="outlined"
@click="showDisableDialog"
>
<VIcon start icon="mdi-shield-off" />
Disable Two-Factor Authentication
</VBtn>
</template>
<!-- Setup View -->
<template v-else-if="enrollmentStep === 'setup'">
<div class="mb-4">
<h3 class="text-h6 mb-2">Step 1: Scan QR Code</h3>
<p class="text-body-2 mb-4">
Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.)
</p>
<div class="d-flex justify-center mb-4">
<VCard variant="outlined" class="pa-4">
<img
v-if="qrCodeDataUrl"
:src="qrCodeDataUrl"
alt="TOTP QR Code"
width="256"
height="256"
/>
<VProgressCircular v-else indeterminate color="primary" />
</VCard>
</div>
<VExpansionPanels variant="accordion">
<VExpansionPanel>
<VExpansionPanelTitle>
<VIcon start icon="mdi-keyboard" />
Can't scan? Enter manually
</VExpansionPanelTitle>
<VExpansionPanelText>
<p class="text-body-2 mb-2">Enter this secret key in your authenticator app:</p>
<VTextField
:model-value="formattedSecret"
readonly
variant="outlined"
density="compact"
append-inner-icon="mdi-content-copy"
@click:append-inner="navigator.clipboard.writeText(secret)"
/>
</VExpansionPanelText>
</VExpansionPanel>
</VExpansionPanels>
</div>
<VDivider class="my-4" />
<div>
<h3 class="text-h6 mb-2">Step 2: Verify Setup</h3>
<p class="text-body-2 mb-4">
Enter the 6-digit code from your authenticator app to verify the setup.
</p>
<VTextField
v-model="verificationCode"
label="Verification Code"
placeholder="000000"
maxlength="6"
variant="outlined"
:error-messages="verificationError"
class="mb-4"
@keyup.enter="verifyEnrollment"
/>
<div class="d-flex gap-2">
<VBtn
color="primary"
:loading="isVerifying"
:disabled="verificationCode.length !== 6"
@click="verifyEnrollment"
>
<VIcon start icon="mdi-check" />
Verify and Enable
</VBtn>
<VBtn
variant="outlined"
@click="cancelEnrollment"
>
Cancel
</VBtn>
</div>
</div>
</template>
<!-- Success View (after enrollment, before closing dialog) -->
<template v-else-if="enrollmentStep === 'success'">
<VAlert
type="success"
variant="tonal"
class="mb-4"
>
<VAlertTitle>Two-factor authentication enabled!</VAlertTitle>
<p class="mb-0">
Your account is now protected with TOTP two-factor authentication.
</p>
</VAlert>
</template>
<!-- Disable View -->
<template v-else-if="enrollmentStep === 'disable'">
<VAlert
type="warning"
variant="tonal"
class="mb-4"
>
<VAlertTitle>Disable Two-Factor Authentication</VAlertTitle>
<p class="mb-0">
This will remove the extra security layer from your account.
Enter your current 6-digit code to confirm.
</p>
</VAlert>
<VTextField
v-model="disableCode"
label="Current Verification Code"
placeholder="000000"
maxlength="6"
variant="outlined"
:error-messages="disableError"
class="mb-4"
@keyup.enter="disableTotp"
/>
<div class="d-flex gap-2">
<VBtn
color="error"
:loading="isDisabling"
:disabled="disableCode.length !== 6"
@click="disableTotp"
>
<VIcon start icon="mdi-shield-off" />
Disable TOTP
</VBtn>
<VBtn
variant="outlined"
@click="cancelDisable"
>
Cancel
</VBtn>
</div>
</template>
</VCardText>
</VCard>
<!-- Recovery Codes Dialog -->
<VDialog
v-model="showRecoveryCodesDialog"
max-width="500"
persistent
>
<VCard>
<VCardTitle class="d-flex align-center">
<VIcon start icon="mdi-key" color="warning" />
Save Your Recovery Codes
</VCardTitle>
<VCardText>
<VAlert
type="warning"
variant="tonal"
class="mb-4"
>
<p class="mb-0">
<strong>Important:</strong> Save these recovery codes in a safe place.
If you lose access to your authenticator app, you can use one of these codes to regain access to your account.
Each code can only be used once.
</p>
</VAlert>
<VCard variant="outlined" class="pa-4 mb-4">
<div class="d-flex flex-wrap gap-2 justify-center">
<VChip
v-for="(code, index) in recoveryCodes"
:key="index"
variant="tonal"
class="font-weight-medium"
>
{{ code }}
</VChip>
</div>
</VCard>
<div class="d-flex gap-2 justify-center">
<VBtn
variant="outlined"
size="small"
@click="copyRecoveryCodes"
>
<VIcon start :icon="recoveryCodesCopied ? 'mdi-check' : 'mdi-content-copy'" />
{{ recoveryCodesCopied ? 'Copied!' : 'Copy Codes' }}
</VBtn>
<VBtn
variant="outlined"
size="small"
@click="downloadRecoveryCodes"
>
<VIcon start icon="mdi-download" />
Download
</VBtn>
</div>
</VCardText>
<VCardActions class="justify-end pa-4">
<VBtn
color="primary"
@click="finishSetup"
>
I've Saved My Codes
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</VCol>
</template>

15
tsconfig.app.json Normal file
View File

@@ -0,0 +1,15 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@KTXC/*": ["../../core/src/*"]
}
}
}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

19
tsconfig.node.json Normal file
View File

@@ -0,0 +1,19 @@
{
"extends": "@tsconfig/node20/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*"
],
"compilerOptions": {
"composite": true,
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"]
}
}

26
vite.config.ts Normal file
View File

@@ -0,0 +1,26 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@KTXC': path.resolve(__dirname, '../../core/src')
},
},
build: {
outDir: 'static',
sourcemap: true,
lib: {
entry: path.resolve(__dirname, 'src/main.ts'),
formats: ['es'],
fileName: () => 'module.mjs',
},
rollupOptions: {
external: ['vue', 'vue-router', 'pinia'],
},
},
})