diff --git a/.codeclimate.yml b/.codeclimate.yml new file mode 100644 index 0000000..afed90f --- /dev/null +++ b/.codeclimate.yml @@ -0,0 +1,17 @@ +--- +engines: + duplication: + enabled: true + config: + languages: + - php + fixme: + enabled: true + phpmd: + enabled: true +ratings: + paths: + - "**.php" +exclude_paths: +- docs/* +- tests/* diff --git a/.scrutinizer.yml b/.scrutinizer.yml index 051ef9a..674c555 100644 --- a/.scrutinizer.yml +++ b/.scrutinizer.yml @@ -1,5 +1,4 @@ inherit: true - build: nodes: analysis: diff --git a/_config/security.yml b/_config/security.yml deleted file mode 100644 index 39d08c7..0000000 --- a/_config/security.yml +++ /dev/null @@ -1,12 +0,0 @@ ---- -Name: totpsecurity -After: - - '#coresecurity' ---- -SilverStripe\Core\Injector\Injector: - SilverStripe\Security\Security: - properties: - Authenticators: - totp: %$ElliotSawyer\TOTPAuthenticator\TOTPAuthenticator -ElliotSawyer\TOTPAuthenticator\TOTPLoginForm: - authenticator_class: ElliotSawyer\TOTPAuthenticator\TOTPAuthenticator diff --git a/composer.json b/composer.json index 2c8c4f1..77f9e15 100644 --- a/composer.json +++ b/composer.json @@ -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", + "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 } diff --git a/src/Authenticators/TOTPAuthenticator.php b/src/Authenticators/TOTPAuthenticator.php index df730db..4e67c83 100644 --- a/src/Authenticators/TOTPAuthenticator.php +++ b/src/Authenticators/TOTPAuthenticator.php @@ -3,10 +3,13 @@ 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; @@ -14,7 +17,7 @@ * Class TOTPAuthenticator * @package ElliotSawyer\TOTPAuthenticator */ -class TOTPAuthenticator extends BootstrapMFAAuthenticator +class TOTPAuthenticator extends BootstrapMFAAuthenticator implements MFAAuthenticator { use Configurable; @@ -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() { - return TOTPLoginHandler::create($link, $this); + $algorithm = self::config()->get('algorithm'); + + return in_array(strtolower($algorithm), ['sha1', 'sha256', 'sha512']) + ? $algorithm + : 'sha1'; } /** * @param array $data * @param HTTPRequest $request + * @param $token * @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); + $provider->setMember($member); + /** @var TOTP $totp */ + $totp = $provider->fetchToken($token); + - if ($user_submitted_key !== $key) { + if (!$totp->verify($token)) { $result->addError(_t(self::class . '.TOTPFAILED', 'TOTP Failed')); } } @@ -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 */ - protected function getTokenFromTOTP($secret, $algorithm = '') + public function getMFAForm($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'; } } diff --git a/src/Extensions/MemberExtension.php b/src/Extensions/MemberExtension.php index 6228d6d..3a7f533 100644 --- a/src/Extensions/MemberExtension.php +++ b/src/Extensions/MemberExtension.php @@ -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 @@ -36,28 +36,22 @@ 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("", $qrcodeURI)) + 'Second Factor Token Secret', + LiteralField::create(null, sprintf('', $qrcodeURI)) )); $fields->removeByName('TOTPSecret'); } @@ -65,33 +59,29 @@ public function updateCMSFields(FieldList $fields) /** * @return string - * @throws InvalidWriterException */ - public function GoogleAuthenticatorQRCode() + protected function getQRCode() { $qrCode = new QrCode($this->generateOTPAuthString()); $qrCode->setSize(300); $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(); } } diff --git a/src/Forms/TOTPForm.php b/src/Forms/TOTPForm.php index 8e17b51..cac1173 100644 --- a/src/Forms/TOTPForm.php +++ b/src/Forms/TOTPForm.php @@ -2,35 +2,38 @@ namespace ElliotSawyer\TOTPAuthenticator; -use Firesphere\BootstrapMFA\Forms\BootstrapMFALoginForm; use SilverStripe\Control\RequestHandler; use SilverStripe\Forms\FieldList; use SilverStripe\Forms\FormAction; use SilverStripe\Forms\HiddenField; use SilverStripe\Forms\PasswordField; +use SilverStripe\Forms\RequiredFields; +use SilverStripe\Security\LoginForm; /** * Class TOTPForm * @package ElliotSawyer\TOTPAuthenticator */ -class TOTPForm extends BootstrapMFALoginForm +class TOTPForm extends LoginForm { /** * TOTPForm constructor. * @param RequestHandler|null $controller - * @param null $validator * @param string $name + * @param null|TOTPAuthenticator $authenticator */ public function __construct( RequestHandler $controller = null, - $validator = null, - $name = self::DEFAULT_NAME + $name = self::DEFAULT_NAME, + $authenticator = null ) { $this->controller = $controller; $fields = $this->getFormFields(); $actions = $this->getFormActions(); + $validator = RequiredFields::create(['token']); - parent::__construct($controller, $validator, $name, $fields, $actions); + parent::__construct($controller, $name, $fields, $actions, $validator); + $this->setAuthenticatorClass(get_class($authenticator)); } /** @@ -38,8 +41,10 @@ public function __construct( */ public function getFormFields() { - $fields = FieldList::create(); - $fields->push(PasswordField::create('token', _t(self::class . '.TOTPCODE', 'TOTP Code'))); + $fields = FieldList::create([ + PasswordField::create('token', _t(self::class . '.TOTPCODE', 'TOTP Code')), + HiddenField::create('AuthenticationMethod', $this->authenticator_class) + ]); $backURL = $this->controller->getRequest()->getVar('BackURL'); if ($backURL) { @@ -64,6 +69,9 @@ public function getFormActions() } /** + * Return the title of the form for use in the frontend + * For tabs with multiple login methods, for example. + * This replaces the old `get_name` method * @return string */ public function getAuthenticatorName() diff --git a/src/Handlers/TOTPLoginHandler.php b/src/Handlers/TOTPLoginHandler.php deleted file mode 100644 index 9ad91ec..0000000 --- a/src/Handlers/TOTPLoginHandler.php +++ /dev/null @@ -1,78 +0,0 @@ -get(ValidationResult::class); - $session = $request->getSession(); - - $this->request['BackURL'] = !empty($session->get('MFALogin.BackURL')) ? $session->get('MFALogin.BackURL') : ''; - $member = $this->authenticator->validateTOTP($data, $request, $result); - - if (!$member instanceof Member) { - $member = parent::validate($data, $form, $request, $result); - } - - if ($member instanceof Member && $result->isValid()) { - $member->MFAEnabled = true; - $member->write(); - $memberData = $session->get('MFALogin'); - - $this->performLogin($member, $memberData, $request); - Security::setCurrentUser($member); - $session->clear('MFAForm'); - - return $this->redirectAfterSuccessfulLogin(); - } - - return $this->redirect($this->link()); - } - - /** - * @return static|TOTPForm - */ - public function MFAForm() - { - return TOTPForm::create( - $this, - get_class($this->authenticator), - 'MFAForm' - ); - } -} diff --git a/src/Providers/TOTPProvider.php b/src/Providers/TOTPProvider.php index 7a17cf6..2cb6720 100644 --- a/src/Providers/TOTPProvider.php +++ b/src/Providers/TOTPProvider.php @@ -3,16 +3,9 @@ namespace ElliotSawyer\TOTPAuthenticator; +use Firesphere\BootstrapMFA\Interfaces\MFAProvider; use Firesphere\BootstrapMFA\Providers\BootstrapMFAProvider; -use Firesphere\BootstrapMFA\Providers\MFAProvider; -use lfkeitel\phptotp\Base32; -use lfkeitel\phptotp\Totp; -use SilverStripe\Core\Injector\Injector; -use SilverStripe\ORM\ValidationException; -use SilverStripe\ORM\ValidationResult; -use SilverStripe\Security\Member; -use SilverStripe\Security\PasswordEncryptor_NotFoundException; -use ElliotSawyer\TOTPAuthenticator\TOTPAuthenticator; +use OTPHP\TOTP; /** * Class TOTPProvider @@ -22,38 +15,18 @@ class TOTPProvider extends BootstrapMFAProvider implements MFAProvider { /** * @param string $token - * @param null $result - * @return bool|Member - * @throws ValidationException - * @throws PasswordEncryptor_NotFoundException + * @return bool|TOTP * @throws \Exception */ - public function verifyToken($token, &$result = null) + public function fetchToken($token = null) { - if (!$result) { - $result = Injector::inst()->get(ValidationResult::class); - } $member = $this->getMember(); if ($member && $member->ID) { - if (!$token) { - $result->addError(_t(self::class . '.INVALIDORMISSINGTOKEN', 'Invalid or missing second factor token')); - } else { - $secret = Base32::decode($member->TOTPSecret); - $algorithm = TOTPAuthenticator::get_algorithm(); + $algorithm = TOTPAuthenticator::get_algorithm(); - $totp = new Totp($algorithm); - $key = $totp->GenerateToken($secret); - $user_submitted_key = $token; - if ($user_submitted_key !== $key) { - $result->addError( - _t(self::class . '.INVALIDORMISSINGTOKEN', 'Invalid or missing second factor token') - ); - } else { - return $this->member; - } - } + return TOTP::create($member->TOTPSecret, 30, $algorithm); } - return parent::verifyToken($token, $result); + return false; } }