Skip to content
Snippets Groups Projects
Unverified Commit 7514a04d authored by ufundo's avatar ufundo Committed by GitHub
Browse files

Merge pull request #31130 from artfulrobot/standalone-mfa

Standalone multifactor authentication
parents 60130c26 76bec3b4
Branches
Tags
No related merge requests found
Showing
with 690 additions and 5 deletions
<?php
use CRM_Standaloneusers_ExtensionUtil as E;
class CRM_Standaloneusers_BAO_Totp extends CRM_Standaloneusers_DAO_Totp {
}
<?php
use CRM_Standaloneusers_ExtensionUtil as E;
/**
* DAOs provide an OOP-style facade for reading and writing database records.
*
* DAOs are a primary source for metadata in several versions of CiviCRM (<5.75)
* and are required for some subsystems (such as APIv3).
*
* This stub provides compatibility. It is not intended to be modified in a
* substantive way. However, you may add comments and annotations.
*/
class CRM_Standaloneusers_DAO_Totp extends CRM_Standaloneusers_DAO_Base {
public static $_tableName = 'civicrm_totp';
}
......@@ -16,8 +16,8 @@ class CRM_Standaloneusers_Page_Login extends CRM_Core_Page {
$this->assign('forgottenPasswordURL', CRM_Utils_System::url('civicrm/login/password'));
// Remove breadcrumb for login page.
$this->assign('breadcrumb', NULL);
$this->assign('justLoggedOut', isset($_GET['justLoggedOut']));
$this->assign('sessionLost', isset($_GET['sessionLost']));
parent::run();
}
......
<?php
use CRM_Standaloneusers_ExtensionUtil as E;
use Civi\Standalone\MFA\Base as MFABase;
class CRM_Standaloneusers_Page_MFA extends CRM_Core_Page {
public function run() {
$mfas = ['TOTP'];
// Create an event object with all the data you wan to pass in.
$event = Civi\Core\Event\GenericHookEvent::create(['mfaClasses' => &$mfas]);
Civi::dispatcher()->dispatch('civi.standalone.altermfaclasses', $event);
// Check the list looks ok.
$legit = [['name' => '', 'label' => E::ts("Disable MFA"), 'selected' => empty($configuredMfas)]];
$configuredMfas = MFABase::getAvailableClasses(TRUE);
foreach ($mfas as $shortClassName) {
$mfaClass = "Civi\\Standalone\\MFA\\$shortClassName";
if (is_subclass_of($mfaClass, 'Civi\\Standalone\\MFA\\MFAInterface') && class_exists($mfaClass)) {
// The code is available, all good.
$legit[] = ['name' => $shortClassName, 'label' => $shortClassName, 'selected' => in_array($shortClassName, $configuredMfas)];
}
}
// Remove breadcrumb for login page.
$this->assign('breadcrumb', NULL);
$this->assign('mfas', $legit);
// $this->assign('pageTitle', 'Configu');
parent::run();
}
}
<?php
use CRM_Standaloneusers_ExtensionUtil as E;
use Civi\Standalone\MFA\Base as MFABase;
/**
* Page for /civicrm/mfa/totp
*/
class CRM_Standaloneusers_Page_TOTP extends CRM_Core_Page {
public function run() {
// Nb. Get SQL from schema like so:
// echo E::schema('totp')->generateInstallSql(); exit;
if (CRM_Core_Session::getLoggedInContactID()) {
// Already logged in.
CRM_Utils_System::redirect('/civicrm');
}
$pending = MFABase::getPendingLogin();
if (!$pending || !is_array($pending)
|| (($pending['expiry'] ?? 0) < time())
) {
// Invalid, send user back to login.
$pending = CRM_Core_Session::singleton()->set('pendingLogin', []);
CRM_Utils_System::redirect('/civicrm/login');
}
$this->assign('pageTitle', '');
$this->assign('logoUrl', E::url('images/civicrm-logo.png'));
$this->assign('breadcrumb', NULL);
parent::run();
}
}
<?php
use Civi\Api4\Totp;
use CRM_Standaloneusers_ExtensionUtil as E;
use Civi\Standalone\MFA\Base as MFABase;
/**
* Page for /civicrm/mfa/totp-setup
*/
class CRM_Standaloneusers_Page_TOTPSetup extends CRM_Core_Page {
public function run() {
if (CRM_Core_Session::getLoggedInContactID()) {
// Already logged in.
CRM_Utils_System::redirect('/civicrm');
}
$pending = MFABase::getPendingLogin();
if (!$pending || empty($pending['userID'])) {
// Invalid, send user back to login.
CRM_Core_Session::singleton()->set('pendingLogin', []);
CRM_Utils_System::redirect('/civicrm/login');
}
// Check that the pending UserID does not have TOTP already set up,
// to prevent them being able to access this URL and set up a new one,
// thereby bypassing MFA!
$preExistingTotp = Totp::get(FALSE)
->addWhere('user_id', '=', $pending['userID'])
->execute()->first();
if ($preExistingTotp) {
\Civi::log()->notice("Possibly malicious: Attempt to access TOTP setup during login, when TOTP is already set up.", [
'pendingLogin' => $pending,
]);
CRM_Utils_System::redirect('/civicrm/mfa/totp');
}
$totp = new \Civi\Standalone\MFA\TOTP($pending['userID']);
$seed = $totp->generateNew();
// Allow 10mins for them to set up their TOPT app.
$totp->updatePendingLogin(['expiry' => time() + 60 * 10, 'seed' => $seed]);
$this->assign('totpseed', $seed);
// Generate QR code
$domain = CRM_Core_BAO_Domain::getDomain()->name;
$url = 'otpauth://totp/' . rawurlencode(str_replace(':', '', $domain))
. ':' . rawurlencode(str_replace(':', '', $pending['username']))
. '?' . http_build_query([
'secret' => $seed,
'digits' => 6,
'period' => 30,
'issuer' => $domain,
]);
$barcodeobj = new TCPDF2DBarcode($url, 'QRCODE,H');
$this->assign('totpqr', $barcodeobj->getBarcodeHTML(6, 6, 'black'));
$this->assign('logoUrl', E::url('images/civicrm-logo.png'));
$this->assign('pageTitle', '');
$this->assign('breadcrumb', NULL);
parent::run();
}
}
......@@ -123,6 +123,21 @@ class CRM_Standaloneusers_Upgrader extends CRM_Extension_Upgrader_Base {
->execute();
}
public function upgrade_5692(): bool {
CRM_Core_DAO::executeQuery(<<<SQL
CREATE TABLE IF NOT EXISTS `civicrm_totp` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT 'Unique TOTP ID',
`user_id` int(10) unsigned NOT NULL COMMENT 'Reference to User (UFMatch) ID',
`seed` varchar(512) NOT NULL,
`hash` varchar(20) NOT NULL DEFAULT '\"sha1\"',
`period` INT(1) UNSIGNED NOT NULL DEFAULT '30',
`length` INT(1) UNSIGNED NOT NULL DEFAULT '6',
PRIMARY KEY (`id`)
)
SQL);
return TRUE;
}
/**
* Example: Run a couple simple queries.
*
......
<?php
namespace Civi\Api4\Action\Totp;
use Civi\Api4\Generic\AbstractAction;
use Civi\Api4\Generic\Result;
use Civi\Standalone\MFA\Base as MFABase;
use Civi\Api4\Totp;
/**
* Verify that the user correctly applied the seed to their authenticator app.
* Store the seed if so.
*
* This is a public API that depends on session data.
*
*/
class ConfirmSeed extends AbstractAction {
/**
*
* @var string
*/
protected string $seed;
/**
* TOTP code; if this matches, we store the seed.
*
* @var string
*/
protected string $code;
public function _run(Result $result) {
$pending = MFABase::getPendingLogin();
if (!$pending || !is_array($pending)
|| (($pending['expiry'] ?? 0) < time())
) {
$result['success'] = FALSE;
$result['error'] = 'Possibly expired session.';
// Clear our session data completely.
\CRM_Core_Session::singleton()->set('pendingLogin', []);
return;
}
$userID = $pending['userID'];
// Check that the pending UserID does not have TOTP already set up,
// to prevent them being able to access this URL and set up a new one,
// thereby bypassing MFA!
$preExistingTotp = Totp::get(FALSE)
->addWhere('user_id', '=', $pending['userID'])
->execute()->first();
if ($preExistingTotp) {
\Civi::log()->notice("Possibly malicious: Attempt to access Totp.ConfirmSeed API, when TOTP is already set up.", [
'pendingLogin' => $pending,
]);
$result['error'] = 'TOTP already enabled.';
return;
}
$t = new \Civi\Standalone\MFA\TOTP($userID);
$result['success'] = $t->verifyCode($this->seed, $this->code);
if ($result['success']) {
$t->storeSeed($userID, $this->seed);
}
else {
$result['error'] = 'Code did not match.';
}
}
}
<?php
namespace Civi\Api4\Action\User;
use Civi\Api4\Generic\AbstractAction;
use Civi\Api4\Generic\Result;
use Civi\Standalone\MFA\Base as MFABase;
use Civi\Standalone\Security;
class Login extends AbstractAction {
/**
* Username to authenticate.
*
* @var string
* @default NULL
*/
protected ?string $username = NULL;
/**
* Password to authenticate.
*
* @var string
* @default NULL
*/
protected ?string $password = NULL;
/**
* MFA Class.
*
* Used when trying to complete a login via an MFA class.
*
* @var string
* @default NULL
*/
protected ?string $mfaClass = NULL;
/**
* MFA data.
*
* Used when trying to complete a login via an MFA class.
*
* @var mixed
* @default NULL
*/
protected $mfaData = NULL;
/**
* URL that was used to access the login page.
*
* @var string
* @default NULL
*/
protected ?string $originalUrl = NULL;
public function _run(Result $result) {
if (empty($this->mfaClass)) {
// Initial call with username, password.
return $this->passwordCheck($result);
}
else {
// This call is from an MFA class, needing to check the mfaData.
$mfaClass = MFABase::classIsAvailable($this->mfaClass);
if (!$mfaClass) {
\CRM_Core_Session::singleton()->set('pendingLogin', []);
$result['publicError'] = "MFA method not available. Please contact the site administrators.";
return;
}
$pending = MFABase::getPendingLogin();
if (!$pending) {
// Invalid, send user back to login.
$result['url'] = '/civicrm/login?sessionLost';
return;
}
$mfa = new $mfaClass($pending['userID']);
$okToLogin = $mfa->processMFAAttempt($pending, $this->mfaData);
if ($okToLogin) {
// OK!
\CRM_Core_Session::singleton()->set('pendingLogin', []);
$this->loginUser($pending['userID']);
$result['url'] = $pending['successUrl'];
}
else {
$result['publicError'] = "MFA failed verification";
}
}
}
/**
* Handle the initial API call which checks username and password.
*
* It sets $result['url'] to either the success URL,
* or the URL of the enabled MFA.
*/
protected function passwordCheck(Result $result) {
$successUrl = '/civicrm';
if (!empty($this->originalUrl) && parse_url($this->originalUrl, PHP_URL_PATH) !== '/civicrm/login') {
// We will return to this URL on success.
$successUrl = $this->originalUrl;
}
// Check user+password
if (empty($this->username) || empty($this->password)) {
$result['publicError'] = "Missing password/username";
return;
}
$security = Security::singleton();
$user = $security->loadUserByName($this->username);
if (!$security->checkPassword($this->password, $user['hashed_password'] ?? '')) {
$result['publicError'] = "Invalid credentials";
return;
}
// Password is ok. Do we have mfa configured?
// Collect configured and present MFA classes.
// This check means if an MFA extension is removed without changing config,
// users can login without it.
$mfaClasses = MFABase::getAvailableClasses();
// @todo remove this line if/when we implement a user choice of MFAs.
$mfaClasses = array_slice($mfaClasses, 0, 1);
switch (count($mfaClasses)) {
case 0:
// MFA not enabled.
$this->loginUser($user['id']);
$result['url'] = $successUrl;
return;
case 1:
// MFA enabled. Store data in a pendingLogin key on session.
// @todo expose the 120s timeout to config?
\CRM_Core_Session::singleton()->set('pendingLogin', [
'userID' => $user['id'],
'username' => $this->username,
'expiry' => time() + 120,
'successUrl' => $successUrl,
]);
$mfaClass = $mfaClasses[0];
$mfa = new $mfaClass($user['id']);
// Return the URL for the MFA form.
$result['url'] = $mfa->getFormUrl();
break;
default:
// We have multiple MFAs enabled.
// Currently unsupported and this codepath is never reached.
}
}
protected function loginUser(int $userID) {
$authx = new \Civi\Authx\Standalone();
$authx->loginSession($userID);
}
}
<?php
namespace Civi\Api4;
/**
* Totp entity.
*
* Provided by the Standalone Users extension.
*
* @package Civi\Api4
*/
class Totp extends Generic\DAOEntity {
}
......@@ -2,9 +2,10 @@
namespace Civi\Api4;
use Civi\Api4\Action\User\Create;
use Civi\Api4\Action\User\Login;
use Civi\Api4\Action\User\Save;
use Civi\Api4\Action\User\Update;
use Civi\Api4\Action\User\SendPasswordReset;
use Civi\Api4\Action\User\Update;
/**
* User entity.
......@@ -15,6 +16,15 @@ use Civi\Api4\Action\User\SendPasswordReset;
*/
class User extends Generic\DAOEntity {
/**
* @param bool $checkPermissions
* @return \Civi\Api4\Action\User\Login
*/
public static function login($checkPermissions = TRUE): Login {
return (new Login(static::getEntityName(), __FUNCTION__))
->setCheckPermissions($checkPermissions);
}
/**
* @param bool $checkPermissions
* @return \Civi\Api4\Action\User\Save
......@@ -78,6 +88,7 @@ class User extends Generic\DAOEntity {
'delete' => ['cms:administer users'],
'passwordReset' => ['access password resets'],
'sendPasswordReset' => ['access password resets'],
'login' => ['access password resets'],
];
}
......
<?php
namespace Civi\Standalone\MFA;
use CRM_Core_Session;
class Base {
public ?int $userID;
public function __construct(int $userID) {
$this->userID = $userID;
}
public function updatePendingLogin(array $changes): array {
$p = CRM_Core_Session::singleton()->get('pendingLogin') ?? [];
$p = array_merge($p, $changes);
CRM_Core_Session::singleton()->set('pendingLogin', $p);
return $p;
}
public function clearPendingLogin() {
CRM_Core_Session::singleton()->set('pendingLogin', []);
}
/**
* Checks if a given token is an enabled MFA class, and returns
* the fully qualified class name (or NULL)
*/
public static function classIsAvailable(string $shortClassName): ?string {
if (!in_array($shortClassName, explode(',', \Civi::settings()->get('standalone_mfa_enabled')))) {
// Class is not configured for use.
return NULL;
}
$mfaClass = "Civi\\Standalone\\MFA\\$shortClassName";
if (is_subclass_of($mfaClass, 'Civi\\Standalone\\MFA\\MFAInterface') && class_exists($mfaClass)) {
// The code is available, all good.
return $mfaClass;
}
return NULL;
}
/**
* Does the class exist on the system.
*/
public static function classIsMFA(string $shortClassName): ?string {
$mfaClass = "Civi\\Standalone\\MFA\\$shortClassName";
return (is_subclass_of($mfaClass, 'Civi\\Standalone\\MFA\\MFAInterface') && class_exists($mfaClass));
}
/**
* Returns an array of fully qualified or short class names that are available.
*
* Available here means:
* - is configured in settings as available to users
* - is actually an MFA class.
*/
public static function getAvailableClasses(bool $short = FALSE): array {
$list = [];
foreach (explode(',', \Civi::settings()->get('standalone_mfa_enabled')) as $shortClassName) {
$fqcn = Base::classIsAvailable($shortClassName);
if ($fqcn) {
$list[] = $short ? $shortClassName : $fqcn;
}
}
return $list;
}
/**
* Fetch the array of pending login data (userID, expiry)
* if it exists and has not expired.
*
* If it's expired, drop it from the session.
*/
public static function getPendingLogin(): ?array {
$pending = \CRM_Core_Session::singleton()->get('pendingLogin');
if (!$pending || !is_array($pending)
|| (($pending['expiry'] ?? 0) < time())
) {
\CRM_Core_Session::singleton()->set('pendingLogin', []);
return NULL;
}
return $pending;
}
}
<?php
namespace Civi\Standalone\MFA;
interface MFAInterface {
public function getFormUrl(): string;
public function checkMFAData($data):bool;
/**
* Handle the User.login request with MFA class + data.
*
* @return bool
* Should login continue?
*/
public function processMFAAttempt(array $pending, $code): bool;
}
<?php
namespace Civi\Standalone\MFA;
use Civi\Api4\Totp as TotpEntity;
use CRM_Core_DAO;
/**
* Time based One-Time Password.
*
*/
class TOTP extends Base implements MFAInterface {
public function getFormUrl(): string {
// Is TOTP set up for this user?
$totp = CRM_Core_DAO::executeQuery("SELECT * FROM civicrm_totp WHERE {$this->userID}")->fetch();
return $totp ? "/civicrm/mfa/totp" : '/civicrm/mfa/totp-setup';
}
/**
* Generate a new seed.
*
* This will be presented to the user so they can try it in their authenticator app.
* If they are successfully able to enter a correct TOTP code from the app, then
* we will store this against their record.
*
*/
public function generateNew(): string {
$ga = $this->getAuthenticator();
$secret = $ga->createSecret();
return $secret;
// echo "Secret is: " . $secret . "\n\n";
// $qrCodeUrl = $ga->getQRCodeGoogleUrl('Blog', $secret);
// echo "Google Charts URL for the QR-Code: " . $qrCodeUrl . "\n\n";
// $oneCode = $ga->getCode($secret);
// echo "Checking Code '$oneCode' and Secret '$secret':\n";
//
// // 2 = 2*30sec clock tolerance
// $checkResult = $ga->verifyCode($secret, $oneCode, 2);
// if ($checkResult) {
// echo 'OK';
// }
// else {
// echo 'FAILED';
// }
//
}
/**
* Store encrypted seed against the User ID.
*/
public function storeSeed(int $userID, string $seed) {
$encrypted = \Civi::service('crypto.token')->encrypt($seed, 'CRED');
// Ensure only one per user is stored.
TotpEntity::delete(FALSE)
->addWhere('user_id', '=', $userID)
->execute();
TotpEntity::create(FALSE)
->addValue('user_id', $userID)
->addValue('seed', $encrypted)
->execute();
}
/**
* Does a given code currently match the given seed?
*/
public function verifyCode(string $seed, string $code): bool {
// 2 = 2*30sec clock tolerance
$ga = $this->getAuthenticator();
return (bool) ($ga->verifyCode($seed, $code, 2));
}
/**
* Generate the currently valid code.
*/
public function getCode(string $seed): string {
$ga = $this->getAuthenticator();
return (string) ($ga->getCode($seed));
}
public function getAuthenticator(): \CiviGoogleAuthenticator {
require_once \Civi::paths()->getPath('[civicrm.packages]/PHPGangsta/CiviGoogleAuthenticator.php');
return new \CiviGoogleAuthenticator();
}
public function checkMFAData($data):bool {
// Load the seed from the user
$seed = TotpEntity::get(FALSE)
->addWhere('user_id', '=', $this->userID)
->execute()->first()['seed'] ?? '';
$seed = \Civi::service('crypto.token')->decrypt($seed, ['plain', 'CRED']);
return $this->verifyCode($seed, $data);
}
/**
*/
public function processMFAAttempt(array $pending, $code): bool {
// Either: we are checking an existing TOTP, OR verifying that
// the user has successfully imported the new seed to their authenticator
if (!empty($pending['seed'])) {
// We are trying to verify a new authenticator.
if ($this->verifyCode($pending['seed'], $code)) {
// Good! Store the seed against the user.
$this->storeSeed($pending['userID'], $pending['seed']);
return TRUE;
}
}
else {
// Normal login check.
return $this->checkMFAData($code);
}
return FALSE;
}
}
......@@ -59,3 +59,4 @@ to check if a given password is known to have been compromised. This is controll
// Disable haveibeenpwned checking.
define('CIVICRM_HIBP_URL', '');
ext/standaloneusers/docs/images/admin.jpg

21.9 KiB

ext/standaloneusers/docs/images/login.jpg

45.7 KiB

ext/standaloneusers/docs/images/setup.jpg

124 KiB

# Standalone Multi-Factor Authentication (MFA)
MFA improves security beyond a username and password combination, typically by requiring that users *have* something (an authenticator app), not just that they *know* something (their password). This means that if a password is stolen an attacker also needs to steal access to the configured authenticator app, too.
Standalone ships with support for time-based one-time password (TOTP), which is a common [internet standard](https://www.rfc-editor.org/rfc/rfc6238) method. It requires users initially to set up their authenticator app with a secret code provided by CiviCRM. Then at future logins the authenticator app will provide a 6 digit numeric code that must be entered. This code changes every 30 seconds.
## Administrators
To enable TOTP visit **Administration » Users and Permissions » Multi-Factor Authentication**
and select *TOTP* instead of *Disabled*
![screenshot showing this form. There is a drop-down selector and a save button.](./images/admin.jpg)
## Users: initial set-up
If you have not set up TOTP yet and the administrator has set it to be required, you will be required to set it up on your next login. After entering your password you will see a screen like this:
![screenshot of set-up screen. There is a code in a text input that you can copy and a QR code that you can scan. Underneath these is an input for you to provide the 6 digit numeric code from your authenticator app](./images/setup.jpg)
The process will be different depending on what authenticator app and device you choose to use. Typically, if you have an authenticator app on your phone you are able to scan the QR code and then your app will show you the 6 digit code.
Once you correctly enter a code, it saves it to your record and you will need to use that authenticator app each time you log in.
## Users: future logging in
Once you have set up TOTP you are required to enter the code from your app with each login.
![screenshot. There is an input for you to provide the 6 digit numeric code from your authenticator app](./images/login.jpg)
## What if you lost access to your authenticator app?
You will not be able to log in, and will need someone with admin permissions and access to the API to remove your TOTP record, which will then force you to re-set up TOTP on your next login.
## Finding an authenticator app
Support for TOTP can be found in dedicated apps and also bundled with various security apps. It is sometimes known by OTP, TFA, 2FA, authenticator, RFC-6238.
......@@ -32,7 +32,7 @@
</classloader>
<civix>
<namespace>CRM/Standaloneusers</namespace>
<format>23.02.1</format>
<format>24.09.1</format>
<angularModule>crmStandaloneusers</angularModule>
</civix>
<mixins>
......@@ -42,9 +42,9 @@
<mixin>setting-php@1.0.0</mixin>
<mixin>setting-admin@1.0.1</mixin>
<mixin>menu-xml@1.0.0</mixin>
<mixin>smarty@1.0.0</mixin>
<mixin>entity-types-php@2.0.0</mixin>
<mixin>smarty@1.0.3</mixin>
<mixin>afform-entity-php@1.0.0</mixin>
<mixin>entity-types-php@2.0.0</mixin>
</mixins>
<upgrader>CiviMix\Schema\Standaloneusers\AutomaticUpgrader</upgrader>
<tags>
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment