Skip to content

Commit

Permalink
Merge pull request #165 from creative-commoners/pulls/1.0/process-acc…
Browse files Browse the repository at this point in the history
…ount-reset

Group AccountReset extensions, implement full Account Reset flow
  • Loading branch information
robbieaverill committed Jun 11, 2019
2 parents f0b03a0 + 9c64aeb commit fa0cb88
Show file tree
Hide file tree
Showing 13 changed files with 436 additions and 31 deletions.
2 changes: 1 addition & 1 deletion _config/admin.yml
Expand Up @@ -3,7 +3,7 @@ Name: mfa-admin
---
SilverStripe\Admin\SecurityAdmin:
extensions:
accountResetExtension: SilverStripe\MFA\Extension\SecurityAdminAccountResetExtension
accountResetExtension: SilverStripe\MFA\Extension\AccountReset\SecurityAdminExtension
mfaRequirementsExtension: SilverStripe\MFA\Extension\RequirementsExtension
SilverStripe\Admin\CMSProfileController:
extensions:
Expand Down
8 changes: 7 additions & 1 deletion _config/config.yml
Expand Up @@ -14,4 +14,10 @@ name: mfa-memberextension
SilverStripe\Security\Member:
extensions:
mfaExtension: SilverStripe\MFA\Extension\MemberExtension
accountResetExtension: SilverStripe\MFA\Extension\MemberResetExtension
accountResetExtension: SilverStripe\MFA\Extension\AccountReset\MemberExtension
SilverStripe\Security\Security:
extensions:
accountResetExtension: SilverStripe\MFA\Extension\AccountReset\SecurityExtension
SilverStripe\MFA\Extension\AccountReset\SecurityExtension:
extensions:
mfaResetExtension: SilverStripe\MFA\Extension\AccountReset\MFAResetExtension
30 changes: 30 additions & 0 deletions src/Extension/AccountReset/MFAResetExtension.php
@@ -0,0 +1,30 @@
<?php declare(strict_types=1);

namespace SilverStripe\MFA\Extension\AccountReset;

use SilverStripe\Core\Extension;
use SilverStripe\MFA\Extension\MemberExtension as MemberExtension;
use SilverStripe\Security\Member;

/**
* Handles removing a member's registered MFA methods during Account Reset. Also
* resets the 'MFA Skipped' flag on the member so that they are prompted to
* set up MFA again when they next log in.
*
* @package SilverStripe\MFA\Extension\AccountReset
*/
class MFAResetExtension extends Extension
{
/**
* @param Member&MemberExtension $member
*/
public function handleAccountReset(Member $member)
{
foreach ($member->RegisteredMFAMethods() as $method) {
$method->delete();
}

$member->HasSkippedMFARegistration = false;
$member->write();
}
}
@@ -1,23 +1,23 @@
<?php declare(strict_types=1);

namespace SilverStripe\MFA\Extension;
namespace SilverStripe\MFA\Extension\AccountReset;

use SilverStripe\Forms\FieldList;
use SilverStripe\ORM\DataExtension;
use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\FieldType\DBDatetime;
use SilverStripe\ORM\ValidationException;
use SilverStripe\Security\Member;
use SilverStripe\Security\PasswordEncryptor_NotFoundException;
use SilverStripe\Security\RandomGenerator;

/**
* Provides DB columns / methods for account resets on Members
*
* @package SilverStripe\MFA\Extension
* @property Member owner
* @property Member&MemberExtension owner
* @property string AccountResetHash
* @property DBDatetime AccountResetExpired
*/
class MemberResetExtension extends DataExtension
class MemberExtension extends DataExtension
{
private static $db = [
'AccountResetHash' => 'Varchar(160)',
Expand Down Expand Up @@ -50,7 +50,7 @@ public function generateAccountResetTokenAndStoreHash(): string
]));

$this->owner->AccountResetHash = $hash;
$this->owner->AccountResetExpired = DBDatetime::create(
$this->owner->AccountResetExpired = DBDatetime::create()->setValue(
DBDatetime::now()->getTimestamp() + $lifetime
)->Rfc2822();

Expand All @@ -63,18 +63,19 @@ public function generateAccountResetTokenAndStoreHash(): string
* Based on Member::validateAutoLoginToken() and Member::member_from_autologinhash().
*
* @param string $token
* @return Member
* @return bool
*/
public function getMemberByAccountResetToken(string $token): ?Member
public function verifyAccountResetToken(string $token): bool
{
$hash = $this->owner->encryptWithUserSettings($token);
if (!$this->owner->exists()) {
return false;
}

/** @var Member $member */
$member = Member::get()->filter([
'AutoLoginHash' => $hash,
'AutoLoginExpired:GreaterThan' => DBDatetime::now()->getValue(),
])->first();
$hash = $this->owner->encryptWithUserSettings($token);

return $member;
return (
$this->owner->AccountResetHash === $hash &&
$this->owner->AccountResetExpired >= DBDatetime::now()->getValue()
);
}
}
@@ -1,19 +1,22 @@
<?php declare(strict_types=1);

namespace SilverStripe\MFA\Extension;
namespace SilverStripe\MFA\Extension\AccountReset;

use Exception;
use Psr\Log\LoggerInterface;
use SilverStripe\Control\Controller;
use SilverStripe\Control\Email\Email;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Control\HTTPResponse;
use SilverStripe\Core\Extension;
use SilverStripe\Admin\SecurityAdmin;
use SilverStripe\MFA\Extension\MemberExtension as BaseMFAMemberExtension;
use SilverStripe\MFA\JSONResponse;
use SilverStripe\ORM\ValidationException;
use SilverStripe\Security\Member;
use SilverStripe\Security\PasswordEncryptor_NotFoundException;
use SilverStripe\Security\Permission;
use SilverStripe\Security\Security;
use SilverStripe\Security\SecurityToken;

/**
Expand All @@ -23,7 +26,7 @@
* @package SilverStripe\MFA\Extension
* @property SecurityAdmin $owner
*/
class SecurityAdminAccountResetExtension extends Extension
class SecurityAdminExtension extends Extension
{
use JSONResponse;

Expand Down Expand Up @@ -65,7 +68,7 @@ public function reset(HTTPRequest $request): HTTPResponse
);
}

if (!Permission::check(MemberExtension::MFA_ADMINISTER_REGISTERED_METHODS)) {
if (!Permission::check(BaseMFAMemberExtension::MFA_ADMINISTER_REGISTERED_METHODS)) {
return $this->jsonResponse(
[
'error' => _t(
Expand Down Expand Up @@ -110,10 +113,10 @@ public function reset(HTTPRequest $request): HTTPResponse
}

/**
* @param Member&MemberResetExtension $member
* Prepares and attempts to send the Account Reset request email.
*
* @param Member&MemberExtension $member
* @return bool
* @throws ValidationException
* @throws PasswordEncryptor_NotFoundException
*/
protected function sendResetEmail($member)
{
Expand Down Expand Up @@ -147,10 +150,12 @@ protected function sendResetEmail($member)
* @param Member $member
* @param string $token
* @return string
* @todo Implement when Account Reset Handler is built
*/
protected function getAccountResetLink(Member $member, string $token): string
public function getAccountResetLink(Member $member, string $token): string
{
return '';
return Controller::join_links(
Security::singleton()->Link('resetaccount'),
"?m={$member->ID}&t={$token}"
);
}
}
204 changes: 204 additions & 0 deletions src/Extension/AccountReset/SecurityExtension.php
@@ -0,0 +1,204 @@
<?php declare(strict_types=1);

namespace SilverStripe\MFA\Extension\AccountReset;

use SilverStripe\Control\HTTPRequest;
use SilverStripe\Control\HTTPResponse;
use SilverStripe\Core\Extensible;
use SilverStripe\Core\Extension;
use SilverStripe\Forms\FieldList;
use SilverStripe\Forms\Form;
use SilverStripe\Forms\FormAction;
use SilverStripe\Forms\PasswordField;
use SilverStripe\Forms\RequiredFields;
use SilverStripe\MFA\JSONResponse;
use SilverStripe\MFA\RequestHandler\BaseHandlerTrait;
use SilverStripe\ORM\FieldType\DBDatetime;
use SilverStripe\ORM\ValidationResult;
use SilverStripe\Security\Member;
use SilverStripe\Security\Security;

/**
* Extends the Security controller to support Account Resets. This extension can
* itself be extended to add procedures to the reset action (such as removing
* additional authentication factors, sending alerts, etc.)
*
* @package SilverStripe\MFA\Extension
* @property Security owner
*/
class SecurityExtension extends Extension
{
use BaseHandlerTrait;
use Extensible;

private static $url_handlers = [
'GET reset-account' => 'resetaccount',
];

private static $allowed_actions = [
'resetaccount',
'ResetAccountForm',
];

public function resetaccount(HTTPRequest $request)
{
if (Security::getCurrentUser()) {
$output = $this->owner->renderWith(
'Security',
[
'Title' => _t(
__CLASS__ . '.ALREADYAUTHENTICATEDTITLE',
'Already authenticated'
),
'Content' => _t(
__CLASS__ . '.ALREADYAUTHENTICATEDBODY',
'You must be logged out to reset your account.'
),
]
);
return $this->owner->getResponse()->setBody($output)->setStatusCode(400);
}

$vars = $request->getVars();

/** @var Member|MemberExtension $member */
$member = Member::get()->byID(intval($vars['m'] ?? 0));

if (is_null($member) || $member->verifyAccountResetToken($vars['t'] ?? '') === false) {
$output = $this->owner->renderWith(
'Security',
[
'Title' => _t(
__CLASS__ . '.INVALIDTOKENTITLE',
'Invalid member or token'
),
'Content' => _t(
__CLASS__ . '.INVALIDTOKENBODY',
'Your account reset token may have expired. Please contact an administrator.'
)
]
);
return $this->owner->getResponse()->setBody($output)->setStatusCode(400);
}

$request->getSession()->set('MemberID', $member->ID);

return $this->owner->getResponse()->setBody($this->owner->renderWith(
'Security',
[
'Title' => _t(
__CLASS__ . '.ACCOUNT_RESET_TITLE',
'Reset account'
),
'Message' => _t(
__CLASS__ . '.ACCOUNT_RESET_DESCRIPTION',
'Your password will be changed, and any registered MFA methods will be removed.'
),
'Form' => $this->ResetAccountForm(),
]
));
}

public function ResetAccountForm(): Form
{
$fields = FieldList::create([
PasswordField::create(
'NewPassword1',
_t(
'SilverStripe\\Security\\Member.NEWPASSWORD',
'New password'
)
),
PasswordField::create(
'NewPassword2',
_t(
'SilverStripe\\Security\\Member.CONFIRMNEWPASSWORD',
'Confirm new password'
)
),
]);

$actions = FieldList::create([
FormAction::create('doResetAccount', 'Reset account'),
]);

$validation = RequiredFields::create(['NewPassword1', 'NewPassword2']);

$form = Form::create($this->owner, 'ResetAccountForm', $fields, $actions, $validation);

$this->owner->extend('updateResetAccountForm', $form);

return $form;
}

/**
* Resets the user's password, and triggers other account reset procedures
*
* @param array $data
* @param Form $form
* @return HTTPResponse
*/
public function doResetAccount(array $data, Form $form): HTTPResponse
{
$memberID = $this->owner->getRequest()->getSession()->get('MemberID');

// If the ID isn't in the session, politely assume the session has expired
if (!$memberID) {
$form->sessionMessage(
_t(
__CLASS__ . '.RESETTIMEDOUT',
"The account reset process timed out. Please click the link in the email and try again."
),
ValidationResult::TYPE_ERROR
);

return $this->owner->redirectBack();
}

/** @var Member&MemberExtension $member */
$member = Member::get()->byID((int) $memberID);

// Fail if passwords do not match
if ($data['NewPassword1'] !== $data['NewPassword2']) {
$form->sessionMessage(
_t(
'SilverStripe\\Security\\Member.ERRORNEWPASSWORD',
'You have entered your new password differently, try again'
),
ValidationResult::TYPE_ERROR
);

return $this->owner->redirectBack();
}

// Check if the new password is accepted
$validationResult = $member->changePassword($data['NewPassword1']);
if (!$validationResult->isValid()) {
$form->setSessionValidationResult($validationResult);

return $this->owner->redirectBack();
}

// Clear locked out status
$member->LockedOutUntil = null;
$member->FailedLoginCount = null;

// Clear account reset data
$member->AccountResetHash = null;
$member->AccountResetExpired = DBDatetime::create()->now();
$member->write();

// Pass off to extensions to perform any additional reset actions
$this->extend('handleAccountReset', $member);

// Send the user along to the login form (allowing any additional factors to kick in as needed)
$this->owner->setSessionMessage(
_t(
__CLASS__ . '.RESETSUCCESSMESSAGE',
'Reset complete. Please log in with your new password.'
),
ValidationResult::TYPE_GOOD
);
return $this->owner->redirect($this->owner->Link('login'));
}
}

0 comments on commit fa0cb88

Please sign in to comment.