Skip to content

Commit

Permalink
protect password reset with 2fa
Browse files Browse the repository at this point in the history
This needed some internal changes, because now 2fa data needs to be
checked for a user that is not logged in. Providers may need adjustments
if they access user data. They should use the getUserData() method of
the abstract Provider class to do so.
  • Loading branch information
splitbrain committed Jul 11, 2023
1 parent 131a315 commit c8525a2
Show file tree
Hide file tree
Showing 7 changed files with 247 additions and 51 deletions.
99 changes: 95 additions & 4 deletions Manager.php
Expand Up @@ -4,6 +4,7 @@

use dokuwiki\Extension\Event;
use dokuwiki\Extension\Plugin;
use dokuwiki\Form\Form;

/**
* Manages the child plugins etc.
Expand All @@ -27,6 +28,9 @@ class Manager extends Plugin
/** @var bool */
protected $providersInitialized;

/** @var string */
protected $user;

/**
* Constructor
*/
Expand Down Expand Up @@ -69,6 +73,14 @@ public static function getInstance()
return self::$instance;
}

/**
* Destroy the singleton instance
*/
public static function destroyInstance()
{
self::$instance = null;
}

/**
* Is the plugin ready to be used?
*
Expand Down Expand Up @@ -114,12 +126,28 @@ public function isRequired()
*/
public function getUser()
{
global $INPUT;
$user = $INPUT->server->str('REMOTE_USER');
if (!$user) {
if ($this->user === null) {
global $INPUT;
$this->user = $INPUT->server->str('REMOTE_USER');
}

if (!$this->user) {
throw new \RuntimeException('2fa user specifics used before user available');
}
return $user;
return $this->user;
}

/**
* Set the current user
*
* This is only needed when running 2fa actions for a non-logged-in user (e.g. during password reset)
*/
public function setUser($user)
{
if ($this->user) {
throw new \RuntimeException('2fa user already set, cannot be changed');
}
$this->user = $user;
}

/**
Expand Down Expand Up @@ -254,4 +282,67 @@ protected function loadProviders()
return $this->providers;
}


/**
* Verify a given code
*
* @return bool
* @throws \Exception
*/
public function verifyCode($code, $providerID)
{
if (!$code) return false;
if (!$providerID) return false;
$provider = $this->getUserProvider($providerID);
$ok = $provider->checkCode($code);
if (!$ok) return false;

return true;
}

/**
* Get the form to enter a code for a given provider
*
* Calling this will generate a new code and transmit it.
*
* @param string $providerID
* @return Form
*/
public function getCodeForm($providerID)
{
$providers = $this->getUserProviders();
$provider = $providers[$providerID] ?? $this->getUserDefaultProvider();
// remove current provider from list
unset($providers[$provider->getProviderID()]);

$form = new Form(['method' => 'POST']);
$form->setHiddenField('do', 'twofactor_login');
$form->setHiddenField('2fa_provider', $provider->getProviderID());

$form->addFieldsetOpen($provider->getLabel());
try {
$code = $provider->generateCode();
$info = $provider->transmitMessage($code);
$form->addHTML('<p>' . hsc($info) . '</p>');
$form->addElement(new OtpField('2fa_code'));
$form->addTagOpen('div')->addClass('buttons');
$form->addButton('2fa', $this->getLang('btn_confirm'))->attr('type', 'submit');
$form->addTagClose('div');
} catch (\Exception $e) {
msg(hsc($e->getMessage()), -1); // FIXME better handling
}
$form->addFieldsetClose();

if (count($providers)) {
$form->addFieldsetOpen('Alternative methods')->addClass('list');
foreach ($providers as $prov) {
$form->addButton('2fa_provider', $prov->getLabel())
->attr('type', 'submit')
->attr('value', $prov->getProviderID());
}
$form->addFieldsetClose();
}

return $form;
}
}
16 changes: 16 additions & 0 deletions Provider.php
Expand Up @@ -3,6 +3,7 @@
namespace dokuwiki\plugin\twofactor;

use dokuwiki\Extension\ActionPlugin;
use dokuwiki\Extension\AuthPlugin;
use dokuwiki\Form\Form;
use dokuwiki\Utf8\PhpString;

Expand Down Expand Up @@ -52,6 +53,21 @@ public function init($user)

// region Introspection methods

/**
* The user data for the current user
* @return array (user=>'login', name=>'full name', mail=>'user@example.com', grps=>['group1', 'group2',...])
*/
public function getUserData()
{
/** @var AuthPlugin $auth */
global $auth;
$user = $this->settings->getUser();
$userdata = $auth->getUserData($user);
if (!$userdata) throw new \RuntimeException('2fa: Failed to get user details from auth backend');
$userdata['user'] = $user;
return $userdata;
}

/**
* The ID of this provider
*
Expand Down
11 changes: 11 additions & 0 deletions Settings.php
Expand Up @@ -27,6 +27,7 @@ public function __construct($module, $user)
{
$this->attribute = plugin_load('helper', 'attribute');
if ($this->attribute === null) throw new \RuntimeException('attribute plugin not found');
$this->attribute->setSecure(false);

$this->providerID = $module;
$this->user = $user;
Expand All @@ -47,6 +48,16 @@ static public function findUsers($module)
return $attribute->enumerateUsers($module);
}

/**
* Get the user these settings are for
*
* @return string
*/
public function getUser()
{
return $this->user;
}

/**
* Check if a setting exists
*
Expand Down
54 changes: 9 additions & 45 deletions action/login.php
Expand Up @@ -52,6 +52,11 @@ public function register(Doku_Event_Handler $controller)
*/
public function handleActionPreProcess(Doku_Event $event)
{
if ($event->data === 'resendpwd') {
// this is completely handled in resendpwd.php
return;
}

$manager = Manager::getInstance();
if (!$manager->isReady()) return;

Expand Down Expand Up @@ -116,52 +121,12 @@ public function handleLoginDisplay(Doku_Event $event)
$event->stopPropagation();

global $INPUT;
global $ID;

$providerID = $INPUT->str('2fa_provider');
$providers = $manager->getUserProviders();
$provider = $providers[$providerID] ?? $manager->getUserDefaultProvider();
// remove current provider from list
unset($providers[$provider->getProviderID()]);

echo '<div class="plugin_twofactor_login">';
echo inlineSVG(__DIR__ . '/../admin.svg');
echo $this->locale_xhtml('login');
$form = new dokuwiki\Form\Form(['method' => 'POST']);
$form->setHiddenField('do', 'twofactor_login');
$form->setHiddenField('2fa_provider', $provider->getProviderID());
$form->addFieldsetOpen($provider->getLabel());
try {
$code = $provider->generateCode();
$info = $provider->transmitMessage($code);
$form->addHTML('<p>' . hsc($info) . '</p>');
$form->addElement(new OtpField('2fa_code'));
$form->addTagOpen('div')->addClass('buttons');
$form->addButton('2fa', $this->getLang('btn_confirm'))->attr('type', 'submit');
$form->addTagClose('div');
} catch (Exception $e) {
msg(hsc($e->getMessage()), -1); // FIXME better handling
}
$form->addFieldsetClose();

if (count($providers)) {
$form->addFieldsetOpen('Alternative methods')->addClass('list');
$form->addTagOpen('ul');
foreach ($providers as $prov) {
$url = wl($ID, [
'do' => 'twofactor_login',
'2fa_provider' => $prov->getProviderID(),
]);
$form->addHTML(
'<li><div class="li"><a href="' . $url . '">' . hsc($prov->getLabel()) . '</a></div></li>'
);
}

$form->addTagClose('ul');
$form->addFieldsetClose();
}

echo $form->toHTML();
echo $manager->getCodeForm($providerID)->toHTML();
echo '</div>';
}

Expand Down Expand Up @@ -244,11 +209,10 @@ protected function verify($code, $providerID, $sticky)
{
global $conf;

if (!$code) return false;
if (!$providerID) return false;
$manager = Manager::getInstance();
if (!$manager->verifyCode($code, $providerID)) return false;

$provider = (Manager::getInstance())->getUserProvider($providerID);
$ok = $provider->checkCode($code);
if (!$ok) return false;

// store cookie
$hash = $this->cookieHash($provider);
Expand Down
113 changes: 113 additions & 0 deletions action/resendpwd.php
@@ -0,0 +1,113 @@
<?php

use dokuwiki\plugin\twofactor\Manager;

/**
* DokuWiki Plugin twofactor (Action Component)
*
* This adds 2fa handling to the resendpwd action. It will interrupt the normal, first step of the
* flow and insert our own 2fa form, initialized with the user provided in the reset form. When the user
* has successfully authenticated, the normal flow will continue. All within the do?do=resendpwd action.
*
* @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
* @author Andreas Gohr <dokuwiki@cosmocode.de>
*/
class action_plugin_twofactor_resendpwd extends \dokuwiki\Extension\ActionPlugin
{
/** @inheritDoc */
public function register(Doku_Event_Handler $controller)
{
$controller->register_hook(
'ACTION_ACT_PREPROCESS',
'BEFORE',
$this,
'handleActionPreProcess',
null,
Manager::EVENT_PRIORITY - 1
);

$controller->register_hook(
'TPL_ACT_UNKNOWN',
'BEFORE',
$this,
'handleTplActUnknown',
null,
Manager::EVENT_PRIORITY - 1
);
}

/**
* Event handler for ACTION_ACT_PREPROCESS
*
* @see https://www.dokuwiki.org/devel:events:ACTION_ACT_PREPROCESS
* @param Doku_Event $event Event object
* @param mixed $param optional parameter passed when event was registered
* @return void
*/
public function handleActionPreProcess(Doku_Event $event, $param)
{
if ($event->data !== 'resendpwd') return;

global $INPUT;
if ($INPUT->has('pwauth')) return; // we're already in token phase, don't interrupt
if (!$INPUT->str('login')) return; // no user given yet, don't interrupt

$user = $INPUT->str('login');
$manager = Manager::getInstance();
$manager->setUser($user);

if (!$manager->isReady()) return; // no 2fa setup, don't interrupt
if (!count($manager->getUserProviders())) return; // no 2fa for this user, don't interrupt

$code = $INPUT->post->str('2fa_code');
$providerID = $INPUT->post->str('2fa_provider');
if ($code && $manager->verifyCode($code, $providerID)) {
// all is good, don't interrupt
Manager::destroyInstance(); // remove our instance so login.php can create a new one
return;
}

// we're still here, so we need to interrupt
$event->preventDefault();
$event->stopPropagation();

// next, we will overwrite the resendpwd form with our own in TPL_ACT_UNKNOWN
}

/**
* Event handler for TPL_ACT_UNKNOWN
*
* This is executed only when we prevented the default action in handleActionPreProcess()
*
* @see https://www.dokuwiki.org/devel:events:TPL_ACT_UNKNOWN
* @param Doku_Event $event Event object
* @param mixed $param optional parameter passed when event was registered
* @return void
*/
public function handleTplActUnknown(Doku_Event $event, $param)
{
if ($event->data !== 'resendpwd') return;
$event->stopPropagation();
$event->preventDefault();

global $INPUT;

$providerID = $INPUT->post->str('2fa_provider');

$manager = Manager::getInstance();
$form = $manager->getCodeForm($providerID);

// overwrite form defaults, to redo the resendpwd action but with the code supplied
$form->setHiddenField('do', 'resendpwd');
$form->setHiddenField('login', $INPUT->str('login'));
$form->setHiddenField('save', 1);


echo '<div class="plugin_twofactor_login">';
echo inlineSVG(__DIR__ . '/../admin.svg');
echo $this->locale_xhtml('resendpwd');
echo $form->toHTML();
echo '</div>';
}
}

3 changes: 3 additions & 0 deletions lang/en/resendpwd.txt
@@ -0,0 +1,3 @@
====== Two-Factor Confirmation ======

Please provide your second factor data below to reset your password.
2 changes: 0 additions & 2 deletions style.less
Expand Up @@ -22,8 +22,6 @@
legend {
text-align: center;
}

text-align: left;
}

.buttons {
Expand Down

0 comments on commit c8525a2

Please sign in to comment.