Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions Api/AdminTfa/AuthenticateInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace MageOS\PasskeyAuth\Api\AdminTfa;

use Magento\Framework\DataObject;
use Magento\Framework\Exception\LocalizedException;
use Magento\User\Api\Data\UserInterface;

interface AuthenticateInterface
{
/**
* Generate WebAuthn assertion options for the admin user.
*
* @param UserInterface $user
* @param string $providerCode
* @return array{credentialRequestOptions: array, challengeToken: string}
* @throws LocalizedException
*/
public function getAuthenticationData(UserInterface $user, string $providerCode): array;

/**
* Verify WebAuthn assertion response.
*
* Called by Engine::verify() and by AuthPost controller.
*
* @param UserInterface $user
* @param DataObject $request Must contain 'challenge_token' and 'credential' keys
* @return bool
* @throws LocalizedException
*/
public function verifyAssertion(UserInterface $user, DataObject $request): bool;
}
39 changes: 39 additions & 0 deletions Api/AdminTfa/ConfigureInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

namespace MageOS\PasskeyAuth\Api\AdminTfa;

use Magento\Framework\Exception\LocalizedException;
use Magento\User\Api\Data\UserInterface;

interface ConfigureInterface
{
/**
* Generate WebAuthn registration options for the admin user.
*
* @param UserInterface $user
* @param string $authenticatorPolicy 'all' or 'hardware'
* @return array{publicKey: array, challengeToken: string}
* @throws LocalizedException
*/
public function getRegistrationData(UserInterface $user, string $authenticatorPolicy): array;

/**
* Validate attestation response and store credential.
*
* @param UserInterface $user
* @param string $challengeToken
* @param string $attestationResponseJson
* @param string $providerCode
* @param string|null $friendlyName
* @throws LocalizedException
*/
public function activate(
UserInterface $user,
string $challengeToken,
string $attestationResponseJson,
string $providerCode,
?string $friendlyName = null
): void;
}
15 changes: 15 additions & 0 deletions Api/WebAuthnConfigInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace MageOS\PasskeyAuth\Api;

interface WebAuthnConfigInterface
{
/**
* Get allowed origins for WebAuthn ceremony validation.
*
* @return string[]
*/
public function getAllowedOrigins(): array;
}
96 changes: 96 additions & 0 deletions Console/Command/ResetPasskeyTfaCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php

declare(strict_types=1);

namespace MageOS\PasskeyAuth\Console\Command;

use MageOS\PasskeyAuth\Model\AdminTfa\Engine;
use Magento\TwoFactorAuth\Api\UserConfigManagerInterface;
use Magento\User\Model\ResourceModel\User\CollectionFactory as UserCollectionFactory;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ConfirmationQuestion;

class ResetPasskeyTfaCommand extends Command
{
private const PROVIDER_CODES = [
Engine::PROVIDER_CODE_ALL,
Engine::PROVIDER_CODE_HARDWARE,
];

public function __construct(
private readonly UserConfigManagerInterface $userConfigManager,
private readonly UserCollectionFactory $userCollectionFactory
) {
parent::__construct();
}

protected function configure(): void
{
$this->setName('security:tfa:passkey:reset-all');
$this->setDescription('Reset passkey 2FA configuration for all admin users');
$this->addOption('force', 'f', InputOption::VALUE_NONE, 'Skip confirmation prompt');
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
$users = $this->userCollectionFactory->create();
$resetCount = 0;
$affectedUsers = [];

foreach ($users as $user) {
$userId = (int) $user->getId();
foreach (self::PROVIDER_CODES as $providerCode) {
$config = $this->userConfigManager->getProviderConfig($userId, $providerCode);
if (!empty($config) && isset($config['registration'])) {
$affectedUsers[$userId] = $user->getUserName();
}
}
}

if (empty($affectedUsers)) {
$output->writeln('<info>No admin users have passkey 2FA configured.</info>');
return Command::SUCCESS;
}

$output->writeln(sprintf(
'<comment>Found %d admin user(s) with passkey 2FA configured:</comment>',
count($affectedUsers)
));
foreach ($affectedUsers as $username) {
$output->writeln(' - ' . $username);
}

if (!$input->getOption('force')) {
$helper = $this->getHelper('question');
$question = new ConfirmationQuestion(
'<question>Reset passkey 2FA for all listed users? [y/N]</question> ',
false
);
if (!$helper->ask($input, $output, $question)) {
$output->writeln('<info>Aborted.</info>');
return Command::SUCCESS;
}
}

foreach (array_keys($affectedUsers) as $userId) {
foreach (self::PROVIDER_CODES as $providerCode) {
$config = $this->userConfigManager->getProviderConfig($userId, $providerCode);
if (!empty($config) && isset($config['registration'])) {
$this->userConfigManager->resetProviderConfig($userId, $providerCode);
$resetCount++;
}
}
}

$output->writeln(sprintf(
'<info>Reset %d passkey configuration(s) for %d admin user(s).</info>',
$resetCount,
count($affectedUsers)
));

return Command::SUCCESS;
}
}
57 changes: 57 additions & 0 deletions Controller/Adminhtml/Passkey/Auth.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

declare(strict_types=1);

namespace MageOS\PasskeyAuth\Controller\Adminhtml\Passkey;

use Magento\Backend\App\Action\Context;
use Magento\Backend\Model\Auth\Session;
use Magento\Framework\App\Action\HttpGetActionInterface;
use Magento\Framework\Controller\ResultFactory;
use Magento\Framework\View\Result\Page;
use Magento\TwoFactorAuth\Api\TfaInterface;
use Magento\TwoFactorAuth\Api\UserConfigManagerInterface;
use Magento\TwoFactorAuth\Controller\Adminhtml\AbstractAction;

class Auth extends AbstractAction implements HttpGetActionInterface
{
public function __construct(
Context $context,
private readonly Session $session,
private readonly TfaInterface $tfa,
private readonly UserConfigManagerInterface $userConfigManager
) {
parent::__construct($context);
}

public function execute(): Page
{
$providerCode = $this->getRequest()->getParam('provider', 'passkey');
$this->userConfigManager->setDefaultProvider(
(int) $this->session->getUser()->getId(),
$providerCode
);

/** @var Page $page */
$page = $this->resultFactory->create(ResultFactory::TYPE_PAGE);
$page->getConfig()->getTitle()->set(__('Passkey Authentication'));
return $page;
}

protected function _isAllowed(): bool
{
$user = $this->session->getUser();
if (!$user) {
return false;
}
$userId = (int) $user->getId();
$providerCode = $this->getRequest()->getParam('provider', 'passkey');

try {
$provider = $this->tfa->getProvider($providerCode);
return $provider->isEnabled() && $provider->isActive($userId);
} catch (\Exception $e) {
return false;
}
}
}
82 changes: 82 additions & 0 deletions Controller/Adminhtml/Passkey/AuthPost.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<?php

declare(strict_types=1);

namespace MageOS\PasskeyAuth\Controller\Adminhtml\Passkey;

use Magento\Backend\App\Action\Context;
use Magento\Backend\Model\Auth\Session;
use Magento\Framework\App\Action\HttpPostActionInterface;
use Magento\Framework\Controller\Result\JsonFactory;
use Magento\Framework\Controller\ResultInterface;
use Magento\Framework\DataObjectFactory;
use Magento\TwoFactorAuth\Api\TfaSessionInterface;
use Magento\TwoFactorAuth\Controller\Adminhtml\AbstractAction;
use Magento\TwoFactorAuth\Model\AlertInterface;
use MageOS\PasskeyAuth\Api\AdminTfa\AuthenticateInterface;
use MageOS\PasskeyAuth\Model\AdminTfa\Engine;

class AuthPost extends AbstractAction implements HttpPostActionInterface
{
public function __construct(
Context $context,
private readonly Session $session,
private readonly JsonFactory $jsonFactory,
private readonly TfaSessionInterface $tfaSession,
private readonly AuthenticateInterface $authenticate,
private readonly DataObjectFactory $dataObjectFactory,
private readonly AlertInterface $alert
) {
parent::__construct($context);
}

public function execute(): ResultInterface
{
$result = $this->jsonFactory->create();
$user = $this->session->getUser();

try {
$providerCode = $this->getRequest()->getParam('provider', Engine::PROVIDER_CODE_ALL);
$credentialJson = $this->getRequest()->getParam('credential');

if ($credentialJson) {
// Phase 2: Verify assertion
$request = $this->dataObjectFactory->create(['data' => [
'challenge_token' => $this->getRequest()->getParam('challenge_token'),
'credential' => $credentialJson,
]]);

$this->authenticate->verifyAssertion($user, $request);
$this->tfaSession->grantAccess();

return $result->setData([
'success' => true,
'redirect_url' => $this->getUrl('adminhtml/dashboard'),
]);
}

// Phase 1: Get authentication options
$authData = $this->authenticate->getAuthenticationData($user, $providerCode);

return $result->setData($authData);
} catch (\Exception $e) {
$this->alert->event(
'MageOS_PasskeyAuth',
'Passkey authentication failed for admin user ' . $user->getUserName()
. ': ' . $e->getMessage(),
AlertInterface::LEVEL_WARNING
);

return $result->setData([
'success' => false,
'message' => $e->getMessage(),
]);
}
}

protected function _isAllowed(): bool
{
$user = $this->session->getUser();
return $user !== null;
}
}
69 changes: 69 additions & 0 deletions Controller/Adminhtml/Passkey/Configure.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

declare(strict_types=1);

namespace MageOS\PasskeyAuth\Controller\Adminhtml\Passkey;

use Magento\Backend\App\Action\Context;
use Magento\Backend\Model\Auth\Session;
use Magento\Framework\App\Action\HttpGetActionInterface;
use Magento\Framework\Controller\ResultFactory;
use Magento\Framework\View\Result\Page;
use Magento\TwoFactorAuth\Api\TfaInterface;
use Magento\TwoFactorAuth\Controller\Adminhtml\AbstractConfigureAction;
use Magento\TwoFactorAuth\Model\UserConfig\HtmlAreaTokenVerifier;

class Configure extends AbstractConfigureAction implements HttpGetActionInterface
{
private Session $session;
private TfaInterface $tfa;

public function __construct(
Context $context,
Session $session,
TfaInterface $tfa,
HtmlAreaTokenVerifier $tokenVerifier
) {
parent::__construct($context, $tokenVerifier);
$this->session = $session;
$this->tfa = $tfa;
}

public function execute(): Page
{
/** @var Page $page */
$page = $this->resultFactory->create(ResultFactory::TYPE_PAGE);
$page->getConfig()->getTitle()->set(__('Passkey Configuration'));
return $page;
}

protected function _isAllowed()
{
if (!parent::_isAllowed()) {
return false;
}

$user = $this->session->getUser();
if (!$user) {
return false;
}
$userId = (int) $user->getId();
$providerCode = $this->getProviderCode();

try {
$provider = $this->tfa->getProvider($providerCode);
return $provider->isEnabled() && !$provider->isActive($userId);
} catch (\Exception $e) {
return false;
}
}

private function getProviderCode(): string
{
$code = $this->getRequest()->getParam('provider');
if (in_array($code, ['passkey', 'passkey_hardware'], true)) {
return $code;
}
return 'passkey';
}
}
Loading
Loading