Skip to content

Commit

Permalink
Add passkeys as a login method #11
Browse files Browse the repository at this point in the history
Forced passkey setup

Signed-off-by: Nicholas K. Dionysopoulos <nicholas@akeeba.com>
  • Loading branch information
nikosdion committed Oct 2, 2024
1 parent 939bb11 commit 7ed02c2
Show file tree
Hide file tree
Showing 9 changed files with 207 additions and 39 deletions.
5 changes: 3 additions & 2 deletions ViewTemplates/Users/form.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
name="adminForm" id="adminForm"
class="row g-2"
>
<div class="row g-2 {{ $this->collapseForMFA ? 'collapse' : '' }}">
<div class="row g-2 {{ $this->collapseForMFA || $this->collapseForPasskey ? 'collapse' : '' }}">
<div class="col-12 col-lg-6">
<div class="card card-body">
<p class="card-title fs-5 fw-semibold mt-1 mb-3">
Expand Down Expand Up @@ -276,7 +276,7 @@ class="row g-2"

{{-- Multi-factor Authentication administration --}}
@if ($this->canEditMFA)
<div class="row g-2">
<div class="row g-2 {{ $this->collapseForPasskey ? 'collapse' : '' }}">
<div class="col-12">
@include('Users/form_mfa')
</div>
Expand All @@ -287,5 +287,6 @@ class="row g-2"
<input type="hidden" name="token" value="@token()">
<input type="hidden" name="task" id="task" value="browse">
<input type="hidden" name="collapseForMFA" id="collapseForMFA" value="{{ $this->collapseForMFA ? 1 : 0 }}">
<input type="hidden" name="collapseForPasskey" id="collapseForPasskey" value="{{ $this->collapseForPasskey ? 1 : 0 }}">

</form>
21 changes: 17 additions & 4 deletions ViewTemplates/Users/form_passkeys.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,23 @@
<span class="fa fa-key" aria-hidden="true"></span>
@lang('PANOPTICON_PASSKEYS_TITLE')
</h3>
{{-- What is this --}}
<div class="form-text mb-2 p-2">
@lang('PANOPTICON_PASSKEYS_DESCRIPTION')
</div>

@if(($this->collapseForPasskey ?? false) && !count($this->passkeyVariables['credentials']))
<div class="alert alert-warning">
<span class="fa fa-warning" aria-hidden="true"></span>
@lang('PANOPTICON_PASSKEYS_LBL_FORCED_NEEDED')
</div>
@elseif(($this->collapseForPasskey ?? false))
<div class="alert alert-info">
<span class="fa fa-info-circle" aria-hidden="true"></span>
@lang('PANOPTICON_PASSKEYS_LBL_FORCED_COMPLETE')
</div>
@else
{{-- What is this --}}
<div class="form-text mb-2 p-2">
@lang('PANOPTICON_PASSKEYS_DESCRIPTION')
</div>
@endif

<div>
@if (is_string($error ?? '') && !empty($error ?? ''))
Expand Down
5 changes: 4 additions & 1 deletion languages/en-GB.ini
Original file line number Diff line number Diff line change
Expand Up @@ -1617,7 +1617,6 @@ PANOPTICON_USERS_LBL_FIELD_MAIN_LAYOUT_HELP="The Dashboard layout is simpler to
PANOPTICON_USERS_LBL_FORCED_MFA_NEEDED="You must set up Multi-factor Authentication to be allowed further access to this application."
PANOPTICON_USERS_LBL_FORCED_MFA_COMPLETE="You can now click on the Cancel button in the toolbar to return to the application."


[About]
PANOPTICON_ABOUT_TITLE="About"
PANOPTICON_ABOUT_LBL_LICENSE="License"
Expand Down Expand Up @@ -2150,6 +2149,10 @@ PANOPTICON_PASSKEYS_MANAGE_BTN_SAVE_LABEL="Save"
PANOPTICON_PASSKEYS_MANAGE_BTN_CANCEL_LABEL="Cancel"
PANOPTICON_PASSKEYS_MANAGE_LBL_WHATISTHIS="What is this?"

PANOPTICON_PASSKEYS_LBL_FORCED_NEEDED="You must set up a passkey to be allowed further access to this application."
PANOPTICON_PASSKEYS_LBL_FORCED_COMPLETE="You can now click on the Cancel button in the toolbar to return to the application."


PANOPTICON_PASSKEYS_ERR_DISABLED="Passkey login is disabled"
PANOPTICON_PASSKEYS_ERR_NO_STORED_CREDENTIAL="Cannot find the stored credentials for your passkey."
PANOPTICON_PASSKEYS_ERR_CORRUPT_STORED_CREDENTIAL="The stored credentials are corrupt for your user account. Log in using another method, then remove and add again your passkey."
Expand Down
37 changes: 28 additions & 9 deletions src/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use Akeeba\Panopticon\Library\MultiFactorAuth\MFATrait;
use Akeeba\Panopticon\Library\MultiFactorAuth\Plugin\PassKeys;
use Akeeba\Panopticon\Library\MultiFactorAuth\Plugin\TOTP;
use Akeeba\Panopticon\Library\Passkey\PasskeyTrait;
use Akeeba\Panopticon\Library\Version\Version;
use Awf\Application\Application as AWFApplication;
use Awf\Application\TransparentAuthentication;
Expand All @@ -25,6 +26,7 @@
class Application extends AWFApplication
{
use MFATrait;
use PasskeyTrait;

/**
* List of view names we're allowed to access directly, without a login, and without redirection to the setup view
Expand Down Expand Up @@ -403,6 +405,7 @@ public function initialise()
if (!$this->needsMFA())
{
$this->conditionalRedirectToCaptiveSetup();
$this->conditionalRedirectToPasskeySetup();
$this->conditionalRedirectToCronSetup();

if (
Expand Down Expand Up @@ -836,15 +839,31 @@ private function conditionalRedirectToCaptiveSetup(): void
return;
}

$user = $this->getContainer()->userManager->getUser();
$captiveUrl = $this->getContainer()
->router
->route(
sprintf(
"index.php?view=users&task=edit&id=%s&collapseForMFA=1",
$user->getId()
)
);
$user = $this->getContainer()->userManager->getUser();
$captiveUrl = $this->getContainer()->router->route(
sprintf(
"index.php?view=users&task=edit&id=%s&collapseForMFA=1",
$user->getId()
)
);

$this->redirect($captiveUrl);
}

private function conditionalRedirectToPasskeySetup(): void
{
if (!$this->needsPasskeyForcedSetup())
{
return;
}

$user = $this->getContainer()->userManager->getUser();
$captiveUrl = $this->getContainer()->router->route(
sprintf(
"index.php?view=users&task=edit&id=%s&collapseForPasskey=1",
$user->getId()
)
);

$this->redirect($captiveUrl);
}
Expand Down
79 changes: 60 additions & 19 deletions src/Controller/Users.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
defined('AKEEBA') || die;

use Akeeba\Panopticon\Controller\Trait\ACLTrait;
use Akeeba\Panopticon\Library\Passkey\Authentication;
use Awf\Mvc\DataController;
use Awf\Utils\ArrayHelper;
use RuntimeException;
Expand All @@ -27,7 +28,8 @@ public function execute($task)

protected function onBeforeEdit()
{
$this->getView()->collapseForMFA = $this->input->get('collapseForMFA', 0);
$this->getView()->collapseForMFA = $this->input->get('collapseForMFA', 0);
$this->getView()->collapseForPasskey = $this->input->get('collapseForPasskey', 0);

return $this->isThisMyOwnUserOrAmISuper();
}
Expand Down Expand Up @@ -75,7 +77,10 @@ protected function onBeforeApply()

protected function onAfterApply()
{
if (!$this->input->get('collapseForMFA', 0))
$collapseForMFA = $this->input->get('collapseForMFA', 0);
$collapseForPasskey = $this->input->get('collapseForPasskey', 0);

if (!$collapseForMFA && !$collapseForPasskey)
{
return true;
}
Expand Down Expand Up @@ -120,14 +125,14 @@ protected function applySave()
];

$params = [
'language' => $this->input->post->getCmd('language', ''),
'main_layout' => $this->input->post->getCmd('main_layout', 'default'),
'language' => $this->input->post->getCmd('language', ''),
'main_layout' => $this->input->post->getCmd('main_layout', 'default'),
'passkey_login_no_password' => $this->input->post->getBool('passkey_login_no_password', false),
];

// Only allow setting passkey_login_no_password if passkeys are enabled, and the user is allowed to decide.
$canDecide = $this->getContainer()->mvcFactory->makeTempModel('Passkeys')->isEnabled()
&& $this->getContainer()->appConfig->get('passkey_login_no_password', 'user') === 'user';
&& $this->getContainer()->appConfig->get('passkey_login_no_password', 'user') === 'user';

if (!$canDecide)
{
Expand Down Expand Up @@ -157,7 +162,9 @@ protected function applySave()

if (empty($username))
{
throw new RuntimeException($this->getLanguage()->text('PANOPTICON_SETUP_ERR_USER_EMPTYUSERNAME'), 403);
throw new RuntimeException(
$this->getLanguage()->text('PANOPTICON_SETUP_ERR_USER_EMPTYUSERNAME'), 403
);
}

// Is there another user by the same username?
Expand All @@ -167,7 +174,8 @@ protected function applySave()
) !== null)
{
throw new RuntimeException(
$this->getLanguage()->sprintf('PANOPTICON_USERS_ERR_USERNAME_EXISTS', htmlentities($username)), 403
$this->getLanguage()->sprintf('PANOPTICON_USERS_ERR_USERNAME_EXISTS', htmlentities($username)),
403
);
}

Expand All @@ -190,7 +198,9 @@ protected function applySave()
}
elseif (!$passwordsMatch)
{
throw new RuntimeException($this->getLanguage()->text('PANOPTICON_USERS_ERR_PASSWORD_MISMATCH'), 403);
throw new RuntimeException(
$this->getLanguage()->text('PANOPTICON_USERS_ERR_PASSWORD_MISMATCH'), 403
);
}

$savedUser->setPassword($password);
Expand Down Expand Up @@ -235,7 +245,9 @@ protected function applySave()
'panopticon.super', $permissions
))
{
throw new RuntimeException($this->getLanguage()->text('PANOPTICON_USERS_ERR_CANT_REMOVE_SELF_SUPER'), 403);
throw new RuntimeException(
$this->getLanguage()->text('PANOPTICON_USERS_ERR_CANT_REMOVE_SELF_SUPER'), 403
);
}

foreach (['super', 'admin', 'view', 'run', 'addown', 'editown'] as $k)
Expand Down Expand Up @@ -354,29 +366,58 @@ private function overrideRedirectForNonSuper()

private function overrideRedirectForForcedMFA()
{
if (!$this->input->get('collapseForMFA', 0))
$collapseForMFA = $this->input->get('collapseForMFA', 0);
$collapseForPasskey = $this->input->get('collapseForPasskey', 0);

if (!$collapseForMFA && !$collapseForPasskey)
{
return;
}

if (!$this->getContainer()->application->userNeedsMFARecords())
// $collapseForMFA is TRUE
if ($collapseForMFA)
{
$returnUrl = $this->getContainer()->router->route("index.php");
$this->input->set('returnurl', base64_encode($returnUrl));
if (!$this->getContainer()->application->userNeedsMFARecords())
{
$returnUrl = $this->getContainer()->router->route("index.php");
$this->input->set('returnurl', base64_encode($returnUrl));

return;
}
return;
}

$user = $this->getContainer()->userManager->getUser();
$returnUrl = $this->getContainer()
->router
->route(
$user = $this->getContainer()->userManager->getUser();
$returnUrl = $this->getContainer()->router->route(
sprintf(
"index.php?view=users&task=edit&id=%s&collapseForMFA=1",
$user->getId()
)
);

$this->input->set('returnurl', base64_encode($returnUrl));

return;
}

// $collapseForPasskey is TRUE
$user = $this->getContainer()->userManager->getUser();
$needsPasskey = count((new Authentication())->getCredentialsRepository()->getAll($user->getId())) == 0;

if (!$needsPasskey)
{
$returnUrl = $this->getContainer()->router->route("index.php");
$this->input->set('returnurl', base64_encode($returnUrl));

return;
}

$user = $this->getContainer()->userManager->getUser();
$returnUrl = $this->getContainer()->router->route(
sprintf(
"index.php?view=users&task=edit&id=%s&collapseForPasskey=1",
$user->getId()
)
);

$this->input->set('returnurl', base64_encode($returnUrl));
}
}
2 changes: 1 addition & 1 deletion src/Library/MultiFactorAuth/MFATrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

trait MFATrait
{
private $mfaAllowedViews = ['cron', 'captive', 'mfamethods', 'login', 'logout'];
private $mfaAllowedViews = ['cron', 'captive', 'mfamethods', 'passkeys', 'login', 'logout'];

/**
* Does the user need to complete MFA authentication before being allowed to access the site?
Expand Down
2 changes: 1 addition & 1 deletion src/Library/Passkey/Authentication.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ final class Authentication implements AuthenticationInterface
public function __construct(?PublicKeyCredentialSourceRepository $credRepo = null, ?LoggerInterface $logger = null)
{
$this->logger = $logger ?? Factory::getContainer()->loggerFactory->get('passkey');
$this->credentialsRepository = $credRepo;
$this->credentialsRepository = $credRepo ?? new CredentialRepository();
}

final public static function create(
Expand Down
Loading

0 comments on commit 7ed02c2

Please sign in to comment.