Initial commit
This commit is contained in:
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal 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
13
composer.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
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' => [],
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
1868
package-lock.json
generated
Normal file
1868
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
package.json
Normal file
32
package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
169
src/components/AdminSecurityPanel.vue
Normal file
169
src/components/AdminSecurityPanel.vue
Normal 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
24
src/integrations.ts
Normal 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
3
src/main.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import integrations from '@/integrations'
|
||||||
|
|
||||||
|
export { integrations }
|
||||||
491
src/views/UserSettingsSecurityPanel.vue
Normal file
491
src/views/UserSettingsSecurityPanel.vue
Normal 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
15
tsconfig.app.json
Normal 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
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
19
tsconfig.node.json
Normal file
19
tsconfig.node.json
Normal 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
26
vite.config.ts
Normal 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'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user