Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial rewrite for changes in the Bootstrapping module #18

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
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
17 changes: 17 additions & 0 deletions .codeclimate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
engines:
duplication:
enabled: true
config:
languages:
- php
fixme:
enabled: true
phpmd:
enabled: true
ratings:
paths:
- "**.php"
exclude_paths:
- docs/*
- tests/*
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't use codeclimate as a part of our regular CI toolchain at the moment

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair enough, it's just a copy-paste from other modules. I don't mind removing it

1 change: 0 additions & 1 deletion .scrutinizer.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
inherit: true

build:
nodes:
analysis:
Expand Down
12 changes: 0 additions & 12 deletions _config/security.yml

This file was deleted.

92 changes: 48 additions & 44 deletions composer.json
Original file line number Diff line number Diff line change
@@ -1,46 +1,50 @@
{
"name": "elliot-sawyer/totp-authenticator",
"description": "Enable 2FA authentication with TOTP",
"type": "silverstripe-vendormodule",
"license": "BSD-3-Clause",
"keywords": [
"silverstripe",
"2-factor",
"authentication",
"module",
"security"
],
"authors": [
{
"name": "Elliot Sawyer"
}
],
"require": {
"firesphere/bootstrapmfa": "dev-master#f0fc8eb861b7e5dca3398dd9f59a382f5cdf5a23",
"endroid/qr-code": "3.2.12",
"lfkeitel/phptotp": "^1.0",
"silverstripe/framework": "^4",
"silverstripe/siteconfig": "^4"
},
"require-dev": {
"phpunit/phpunit": "^5.7",
"squizlabs/php_codesniffer": "^3.0"
},
"autoload": {
"psr-4": {
"ElliotSawyer\\TOTPAuthenticator\\": "src/",
"ElliotSawyer\\TOTPAuthenticator\\Tests\\": "tests/"
}
},
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"support": {
"issues": "https://github.com/elliot-sawyer/totp-authenticator/issues",
"source": "https://github.com/elliot-sawyer/totp-authenticator"
},
"minimum-stability": "dev",
"prefer-stable": true
"name": "elliot-sawyer/totp-authenticator",
"description": "Enable 2FA authentication with TOTP",
"type": "silverstripe-vendormodule",
"license": "BSD-3-Clause",
"keywords": [
"silverstripe",
"2-factor",
"authentication",
"module",
"security",
"TOTP",
"Authy",
"GoogleAuthenticator"
],
"authors": [
{
"name": "Elliot Sawyer"
}
],
"require": {
"firesphere/bootstrapmfa": "dev-extension-hook-before-mfa",
"endroid/qr-code": "3.2.12",
"spomky-labs/otphp": "^9.1",
"silverstripe/framework": "^4"
},
"require-dev": {
"roave/security-advisories": "dev-master",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't a standardised dependency so we shouldn't add it yet (see silverstripe/silverstripe-framework#8548)

"phpunit/phpunit": "^5.7",
"squizlabs/php_codesniffer": "^3.0"
},
"autoload": {
"psr-4": {
"ElliotSawyer\\TOTPAuthenticator\\": "src/",
"ElliotSawyer\\TOTPAuthenticator\\Tests\\": "tests/"
}
},
"extra": {
"installer-name": "totp-authenticator",
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"support": {
"issues": "https://github.com/elliot-sawyer/totp-authenticator/issues",
"source": "https://github.com/elliot-sawyer/totp-authenticator"
},
"minimum-stability": "dev",
"prefer-stable": true
}
80 changes: 38 additions & 42 deletions src/Authenticators/TOTPAuthenticator.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,21 @@
namespace ElliotSawyer\TOTPAuthenticator;

use Firesphere\BootstrapMFA\Authenticators\BootstrapMFAAuthenticator;
use lfkeitel\phptotp\Base32;
use lfkeitel\phptotp\Totp;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Core\Config\Configurable;
use Firesphere\BootstrapMFA\Forms\BootstrapMFALoginForm;
use Firesphere\BootstrapMFA\Handlers\BootstrapMFALoginHandler;
use Firesphere\BootstrapMFA\Interfaces\MFAAuthenticator;
use OTPHP\TOTP;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\ORM\ValidationResult;
use SilverStripe\Security\Member;

/**
* Class TOTPAuthenticator
* @package ElliotSawyer\TOTPAuthenticator
*/
class TOTPAuthenticator extends BootstrapMFAAuthenticator
class TOTPAuthenticator extends BootstrapMFAAuthenticator implements MFAAuthenticator
{
use Configurable;

Expand All @@ -25,44 +28,54 @@ class TOTPAuthenticator extends BootstrapMFAAuthenticator
* a specific TOTP authenticator
*/
private static $algorithm = 'sha1';



/**
* @param string $link
* @return \SilverStripe\Security\MemberAuthenticator\LoginHandler|static
* Get configured algorithm for TOTP Authenticator
*
* Must be one of: "sha1", "sha256", "sha512"
* If not specified or invalid, default to "sha1"
* @return string
*/
public function getLoginHandler($link)
public static function get_algorithm()
robbieaverill marked this conversation as resolved.
Show resolved Hide resolved
{
return TOTPLoginHandler::create($link, $this);
$algorithm = self::config()->get('algorithm');
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should probably be static::config() to allow for late static binding & the ability for subclasses to have their own specific config.


return in_array(strtolower($algorithm), ['sha1', 'sha256', 'sha512'])
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could make this either a config array OR a class constant (if we don't want it to be configurable)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd go with constants, but I think it came from a change @elliot-sawyer made somewhere else

? $algorithm
: 'sha1';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you did go with the above, then this could also be a constant e.g. self::ALGORITHM_DEFAULT

}

/**
* @param array $data
* @param HTTPRequest $request
* @param $token
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor point: missing the type

* @param ValidationResult $result
* @return bool|null|Member
* @throws \Exception
*/
public function validateTOTP($data, $request, &$result)
public function verifyMFA($data, $request, $token, &$result)
{
$memberID = $request->getSession()->get(BootstrapMFAAuthenticator::SESSION_KEY . '.MemberID');

// First, let's see if we know the member
/** @var Member $member */
/** @var Member|null $member */
$member = Member::get()->byID($memberID);

// Continue if we have a valid member
if ($member && $member instanceof Member) {
if (!isset($data['token'])) {
if (!$token) {
$member->registerFailedLogin();

$result->addError(_t(self::class . '.NOTOKEN', 'No token sent'));
} else {
$secret = Base32::decode($member->TOTPSecret);
$key = $this->getTokenFromTOTP($secret);
$user_submitted_key = $data['token'];
/** @var TOTPProvider $provider */
$provider = Injector::inst()->get(TOTPProvider::class);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if this might be better as a dependency?

private static $dependencies = [
    'totpProvider' => TOTPProvider::class,
];

protected $totpProvider;

public function setTOTPProvider(TOTPProvider $provider) ;
public function getTOTPProvider();

$provider->setMember($member);
/** @var TOTP $totp */
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At this point we know that $provider is a TOTPProvider - the return annotation for fetchToken is already bool|TOTP, so I think we can remove this inline annotation.

Overloading it actually points out that the logic below is missing a check for a falsy return, which is a valid part of its API

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not overloading, it's my habit of adding these docs everywhere

$totp = $provider->fetchToken($token);


if ($user_submitted_key !== $key) {
if (!$totp->verify($token)) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (!$totp->verify($token)) {
if (!$totp || !$totp->verify($token)) {

$result->addError(_t(self::class . '.TOTPFAILED', 'TOTP Failed'));
}
}
Expand All @@ -71,43 +84,26 @@ public function validateTOTP($data, $request, &$result)
if ($result->isValid()) {
return $member;
}
} else {
$result->addError(_t(self::class . '.NOMEMBER', 'Member not found'));
}

$result->addError(_t(self::class . '.NOMEMBER', 'Member not found'));

return $result;
}

/**
* Given a TOTP secret, use Totp to resolve to a one time token
*
* @param string $secret
* @param string $algorithm If not provided, will default to the configured algorithm
* @return bool|int|string
* @param BootstrapMFALoginHandler $controller
* @param string $name
* @return TOTPForm|BootstrapMFALoginForm
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could possibly benefit from being a bit tighter. I see two possibilities:

  • TOTPForm is changed to extend BootstrapMFALoginForm
  • We use LoginForm as the return hint instead

*/
protected function getTokenFromTOTP($secret, $algorithm = '')
public function getMFAForm($controller, $name)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
public function getMFAForm($controller, $name)
public function getMFAForm(BootstrapMFALoginHandler $controller, $name)

{
if (!$algorithm) {
$algorithm = self::get_algorithm();
}

$totp = new Totp($algorithm);
return $totp->GenerateToken($secret);
return TOTPForm::create($controller, $name, $this);
}

/**
* Get configured algorithm for TOTP Authenticator
*
* Must be one of: "sha1", "sha256", "sha512"
* If not specified or invalid, default to "sha1"
* @return string
*/
public static function get_algorithm()
public function getTokenField()
{
$algorithm = self::config()->get('algorithm');

return in_array(strtolower($algorithm), ['sha1', 'sha256', 'sha512'])
? $algorithm
: 'sha1';
return 'token';
}
}
48 changes: 19 additions & 29 deletions src/Extensions/MemberExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,21 @@

namespace ElliotSawyer\TOTPAuthenticator;

use Endroid\QrCode\Exception\InvalidWriterException;
use Endroid\QrCode\QrCode;
use lfkeitel\phptotp\Base32;
use lfkeitel\phptotp\Totp;
use OTPHP\TOTP;
use ParagonIE\ConstantTime\Base32;
use SilverStripe\Forms\FieldList;
use SilverStripe\Forms\LiteralField;
use SilverStripe\Forms\ToggleCompositeField;
use SilverStripe\ORM\DataExtension;
use SilverStripe\Security\Member;
use SilverStripe\SiteConfig\SiteConfig;

/**
* Class MemberExtension
*
* @package ElliotSawyer\TOTPAuthenticator
* @property MemberExtension $owner
* @property Member|MemberExtension $owner
* @property string $TOTPSecret
*/
class MemberExtension extends DataExtension
Expand All @@ -36,62 +36,52 @@ public function onBeforeWrite()
// Only regenerate if there is no secret and MFA is not enabled yet
// Inherits MFAEnabled from Bootstrap object extension
if (!$this->owner->TOTPSecret || !$this->owner->MFAEnabled) {
$secret = Totp::GenerateSecret(16);
$secret = Base32::encode($secret);
$secret = Base32::encodeUpper(random_bytes(128)); // We generate our own 1024 bits secret
$this->owner->TOTPSecret = $secret;
}
}

/**
* @param FieldList $fields
* @throws InvalidWriterException
*/
public function updateCMSFields(FieldList $fields)
{
if (!$this->owner->exists()) {
$fields->removeByName('TOTPSecret');
}

if (strlen($this->owner->TOTPSecret)) {
$qrcodeURI = $this->GoogleAuthenticatorQRCode();
if ($this->owner->TOTPSecret !== '') {
$qrcodeURI = $this->getQRCode();
$fields->addFieldToTab('Root.Main', ToggleCompositeField::create(
null,
_t(self::class . '.CMSTOGGLEQRCODELABEL', 'Second Factor Token Secret'),
LiteralField::create(null, sprintf("<img src=\"%s\" />", $qrcodeURI))
'Second Factor Token Secret',
robbieaverill marked this conversation as resolved.
Show resolved Hide resolved
LiteralField::create(null, sprintf('<img src="%s" />', $qrcodeURI))
));
$fields->removeByName('TOTPSecret');
}
}

/**
* @return string
* @throws InvalidWriterException
*/
public function GoogleAuthenticatorQRCode()
protected function getQRCode()
{
$qrCode = new QrCode($this->generateOTPAuthString());
$qrCode->setSize(300);
robbieaverill marked this conversation as resolved.
Show resolved Hide resolved
$qrCode->setWriterByName('png');
$qrcodeURI = $qrCode->writeDataUri();

return $qrcodeURI;
return $qrCode->writeDataUri();
}

/**
* @return string
*/
public function generateOTPAuthString()
protected function generateOTPAuthString()
{
$label = urlencode(SiteConfig::current_site_config()->Title);
$issuer = SiteConfig::current_site_config()->Title;
$secret = $this->owner->TOTPSecret;
$email = $this->owner->Email;
$label = $this->owner->Email;

$totp = TOTP::create($secret);
$totp->setIssuer($issuer);
$totp->setLabel($label);

return sprintf(
'otpauth://totp/%s:%s?secret=%s&issuer=%s',
$label,
$email,
$secret,
$label
);
return $totp->getProvisioningUri();
}
}
Loading