Skip to content

Commit

Permalink
Fire civi.standalone.login events
Browse files Browse the repository at this point in the history
This gives other extensions opportunity to implement various extra
login guards, e.g. excessive wrong passwords/mfa attempts.

standalone: Login/TOTP improve notifications using status messages

standalone: minor css improvement for login

standalone: shrink QR code a bit, it was mahusive

standalone: remove API_Exception → CRM_Core_Exception

fix style

Update tests for changed wording in standalone

standalone: remove huge - we want it *bigger*!

style fix
  • Loading branch information
artfulrobot committed Sep 21, 2024
1 parent edd3144 commit a70decb
Show file tree
Hide file tree
Showing 14 changed files with 177 additions and 28 deletions.
2 changes: 1 addition & 1 deletion CRM/Utils/System/Standalone.php
Original file line number Diff line number Diff line change
Expand Up @@ -562,7 +562,7 @@ public function permissionDenied() {
// render a login page
if (class_exists('CRM_Standaloneusers_Page_Login')) {
$loginPage = new CRM_Standaloneusers_Page_Login();
$loginPage->assign('anonAccessDenied', TRUE);
CRM_Core_Session::setStatus(ts('You need to be logged in to access this page.'), ts('Please sign in'));
return $loginPage->run();
}

Expand Down
17 changes: 15 additions & 2 deletions ext/standaloneusers/CRM/Standaloneusers/Page/Login.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,27 @@ public function run() {
// Already logged in.
CRM_Utils_System::redirect('/civicrm');
}
if (isset($_GET['justLoggedOut'])) {
// When the user has just logged out their session is destroyed
// so we are unable to use setStatus in that request. Here we
// add the session message and redirect so the user doesn't keep getting
// the message when they press Back.
CRM_Core_Session::setStatus(
ts('You have been logged out.'),
ts('Successfully signed out'),
'success');
CRM_Utils_System::redirect('/civicrm/login');
}

$this->assign('logoUrl', E::url('images/civicrm-logo.png'));
$this->assign('pageTitle', '');
$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']));

// statusMessages are usually at top of page but in login forms they look much better
// inside the main box.
$this->assign('statusMessages', CRM_Core_Smarty::singleton()->fetch("CRM/common/status.tpl"));

parent::run();
}
Expand Down
6 changes: 6 additions & 0 deletions ext/standaloneusers/CRM/Standaloneusers/Page/TOTP.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,15 @@ public function run() {
) {
// Invalid, send user back to login.
$pending = CRM_Core_Session::singleton()->set('pendingLogin', []);
CRM_Core_Session::setStatus('Please try again.', 'Session expired', 'warning');
CRM_Utils_System::redirect('/civicrm/login');
}

// CRM_Core_Session::setStatus('hello', 'oi!', 'success');
// statusMessages are usually at top of page but in login forms they look much better
// inside the main box.
$this->assign('statusMessages', CRM_Core_Smarty::singleton()->fetch("CRM/common/status.tpl"));

$this->assign('pageTitle', '');
$this->assign('logoUrl', E::url('images/civicrm-logo.png'));
$this->assign('breadcrumb', NULL);
Expand Down
7 changes: 6 additions & 1 deletion ext/standaloneusers/CRM/Standaloneusers/Page/TOTPSetup.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ public function run() {
CRM_Utils_System::redirect('/civicrm/mfa/totp');
}

// CRM_Core_Session::setStatus('hello', 'oi!', 'success');
// statusMessages are usually at top of page but in login forms they look much better
// inside the main box.
$this->assign('statusMessages', CRM_Core_Smarty::singleton()->fetch("CRM/common/status.tpl"));

$totp = new \Civi\Standalone\MFA\TOTP($pending['userID']);

$seed = $totp->generateNew();
Expand All @@ -54,7 +59,7 @@ public function run() {
'issuer' => $domain,
]);
$barcodeobj = new TCPDF2DBarcode($url, 'QRCODE,H');
$this->assign('totpqr', $barcodeobj->getBarcodeHTML(6, 6, 'black'));
$this->assign('totpqr', $barcodeobj->getBarcodeHTML(4, 4, 'black'));

$this->assign('logoUrl', E::url('images/civicrm-logo.png'));
$this->assign('pageTitle', '');
Expand Down
20 changes: 18 additions & 2 deletions ext/standaloneusers/Civi/Api4/Action/User/Login.php
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
<?php
namespace Civi\Api4\Action\User;

use Civi;
use Civi\Api4\Generic\AbstractAction;
use Civi\Api4\Generic\Result;
use Civi\Standalone\Event\LoginEvent;
use Civi\Standalone\MFA\Base as MFABase;
use Civi\Standalone\Security;

Expand Down Expand Up @@ -58,7 +60,7 @@ public function _run(Result $result) {
return $this->passwordCheck($result);
}
else {
// This call is from an MFA class, needing to check the mfaData.
// This call is from Javascript from an MFA class, needing to check the mfaData.
$mfaClass = MFABase::classIsAvailable($this->mfaClass);
if (!$mfaClass) {
\CRM_Core_Session::singleton()->set('pendingLogin', []);
Expand All @@ -68,12 +70,15 @@ public function _run(Result $result) {
$pending = MFABase::getPendingLogin();
if (!$pending) {
// Invalid, send user back to login.
$result['url'] = '/civicrm/login?sessionLost';
\CRM_Core_Session::setStatus('Please try again.', 'Session expired', 'warning');
$result['url'] = '/civicrm/login';
return;
}

$mfa = new $mfaClass($pending['userID']);
$okToLogin = $mfa->processMFAAttempt($pending, $this->mfaData);
$event = new LoginEvent($pending['userID'], 'post_mfa', $okToLogin ? NULL : 'wrongMFA');
Civi::dispatcher()->dispatch('civi.standalone.login', $event);
if ($okToLogin) {
// OK!
\CRM_Core_Session::singleton()->set('pendingLogin', []);
Expand Down Expand Up @@ -108,8 +113,17 @@ protected function passwordCheck(Result $result) {
}
$security = Security::singleton();
$user = $security->loadUserByName($this->username);

$event = new LoginEvent($user['id'] ?? NULL, 'pre_credentials_check');
Civi::dispatcher()->dispatch('civi.standalone.login', $event);
if ($event->stopReason) {
$result['url'] = '/civicrm/login?' . $event->stopReason;
}

if (!$security->checkPassword($this->password, $user['hashed_password'] ?? '')) {
$result['publicError'] = "Invalid credentials";
$event = new LoginEvent($user['id'] ?? NULL, 'post_credentials_check', 'wrongUserPassword');
Civi::dispatcher()->dispatch('civi.standalone.login', $event);
return;
}
// Password is ok. Do we have mfa configured?
Expand Down Expand Up @@ -155,6 +169,8 @@ protected function passwordCheck(Result $result) {
protected function loginUser(int $userID) {
$authx = new \Civi\Authx\Standalone();
$authx->loginSession($userID);
$event = new LoginEvent($user['id'], 'post_login');
Civi::dispatcher()->dispatch('civi.standalone.login', $event);
}

}
6 changes: 3 additions & 3 deletions ext/standaloneusers/Civi/Api4/Action/User/PasswordReset.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

use Civi\Api4\Generic\Result;
use Civi\Standalone\Security;
use API_Exception;
use CRM_Core_Exception;
use Civi\Api4\User;
use Civi\Api4\Generic\AbstractAction;

Expand Down Expand Up @@ -33,7 +33,7 @@ class PasswordReset extends AbstractAction {
public function _run(Result $result) {

if (empty($this->password)) {
throw new API_Exception("Invalid password");
throw new CRM_Core_Exception("Invalid password");
}

// todo: some minimum password quality check?
Expand All @@ -42,7 +42,7 @@ public function _run(Result $result) {
$security = Security::singleton();
$userID = $security->checkPasswordResetToken($this->token);
if (!$userID) {
throw new API_Exception("Invalid token.");
throw new CRM_Core_Exception("Invalid token.");
}

User::update(FALSE)
Expand Down
10 changes: 4 additions & 6 deletions ext/standaloneusers/Civi/Api4/Action/User/SendPasswordReset.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,14 @@

use Civi;
use Civi\Api4\Generic\Result;
use API_Exception;
use CRM_Core_Exception;
use Civi\Api4\User;
use Civi\Standalone\Security;
use Civi\Api4\Generic\AbstractAction;

/**
* @class API_Exception
*/

/**
* @class SendPasswordReset
*
* This is designed to be a public API
*
* @method static setIdentifier(string $identifier)
Expand All @@ -37,7 +35,7 @@ public function _run(Result $result) {

$identifier = trim($this->identifier);
if (!$identifier) {
throw new API_Exception("Missing identifier");
throw new CRM_Core_Exception("Missing identifier");
}

$user = User::get(FALSE)
Expand Down
4 changes: 2 additions & 2 deletions ext/standaloneusers/Civi/Api4/Action/User/WriteTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,10 @@ protected function formatWriteValues(&$record) {
}
if (array_key_exists('password', $record)) {
if (!empty($record['hashed_password'])) {
throw new \API_Exception("Ambiguous password parameters: Cannot pass password AND hashed_password.");
throw new \CRM_Core_Exception("Ambiguous password parameters: Cannot pass password AND hashed_password.");
}
if (empty($record['password'])) {
throw new \API_Exception("Disallowing empty password.");
throw new \CRM_Core_Exception("Disallowing empty password.");
}
}
parent::formatWriteValues($record);
Expand Down
103 changes: 103 additions & 0 deletions ext/standaloneusers/Civi/Standalone/Event/LoginEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?php
/*
+--------------------------------------------------------------------+
| Copyright CiviCRM LLC. All rights reserved. |
| |
| This work is published under the GNU AGPLv3 license with some |
| permitted exceptions and without any warranty. For full license |
| and copyright information, see https://civicrm.org/licensing |
+--------------------------------------------------------------------+
*/

namespace Civi\Standalone\Event;

use Civi\Core\Event\GenericHookEvent;

/**
* Class LoginEvent
*
* This event (civi.standalone.login) is fired various times during the
* standalone login process.
*
* Generally, listeners may set stopReason to a valid string (see below)
* to prevent login continuing.
*/
class LoginEvent extends GenericHookEvent {

/**
* What stage are we at?
*
* Valid values:
*
* - 'pre_credentials_check'
*
* userID should be set if the user exists but the password
* has not been checked yet. Example use: per IP/per user flood checks.
*
* - 'post_credentials_check'
*
* userID must be set; password has been checked and stopReason
* should be 'wrongUserPassword' or NULL.
* Example use: limit incorrect password attempts per user.
*
* - 'post_mfa'
*
* userID must be set; password was OK. stopReason should be
* 'wrongMFA' (about to reject login)' or NULL (login about to happen).
* Example use: identify suspicious activity?
*
* - 'post_login'
*
* userID is set; password and possibly MFA were correct. User is
* successfully logged in. Setting stopReason would have no effect.
* Example use: monitor logins.
*
* @var string
*/
public $stage;

/**
* The user ID of the user attempting to login.
*
* NULL if the username provided was invalid.
*
* @var int
*/
public $userID;

/**
* If set, authentication will not proceed.
*
* It may be set when the event is created or altered by listeners,
* e.g. loginPrevented
*
* Valid values:
* - 'wrongUserPassword'
* - 'wrongMFA'
* - 'loginPrevented'
*
* @var null|string
*/
public $stopReason = NULL;

/**
* Class constructor.
*
* @param string $stage
* @param int|null $userID
* @param string|null $stopReason
*/
public function __construct($stage, $userID, $stopReason = NULL) {
$this->stage = $stage;
$this->userID = $userID;
$this->stopReason = $stopReason;
}

/**
* @inheritDoc
*/
public function getHookValues() {
return [$this->stage, $this->userID, $this->stopReason];
}

}
10 changes: 7 additions & 3 deletions ext/standaloneusers/css/standalone.css
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ html.crm-standalone nav.breadcrumb>ol {

.standalone-auth-form {
display: grid;
place-content: center;
grid-template-rows: 1fr auto 3fr;
justify-content: center;
width: 100%;
height: 100vh;
}
.standalone-auth-box {
grid-row: 2;
box-sizing: border-box;
width: clamp(280px, 68vw, 45rem);
margin: 0;
Expand All @@ -30,11 +32,13 @@ html.crm-standalone nav.breadcrumb>ol {
.standalone-auth-form .input-wrapper {
margin-bottom: 1rem;
}
.standalone-auth-form label {
/* the div.crm-container is to beat the specificity of civicrm.css under Greenwich */
div.crm-container .standalone-auth-form label {
display: block;
margin-bottom: 0.25rem;
}
.standalone-auth-form input {
/* the div.crm-container is to beat the specificity of civicrm.css under Greenwich */
div.crm-container .standalone-auth-form input {
box-sizing: border-box;
width: 100%;
height: 2rem;
Expand Down
11 changes: 4 additions & 7 deletions ext/standaloneusers/templates/CRM/Standaloneusers/Page/Login.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,19 @@
<div class="standalone-auth-box">
<form id=login-form>
<img class="crm-logo" src="{$logoUrl}" alt="logo for CiviCRM, with an intersecting blue and green triangle">
{if $justLoggedOut}<div class="help message info">{ts}You have been logged out.{/ts}</div>{/if}
{if $anonAccessDenied}<div class="help message warning">{ts}You do not have permission to access that, you may
need to login.{/ts}</div>{/if}
{if $sessionLost}<div class="help message warning">{ts}Your session timed out.{/ts}</div>{/if}
{$statusMessages}
<div class="input-wrapper">
<label for="usernameInput" name=username class="form-label">Username</label>
<input type="text" class="form-control" id="usernameInput">
<input type="text" class="form-control crm-form-text" id="usernameInput" >
</div>
<div class="input-wrapper">
<label for="passwordInput" class="form-label">Password</label>
<input type="password" class="form-control" id="passwordInput">
<input type="password" class="form-control crm-form-text" id="passwordInput">
</div>
<div id="error" style="display:none;" class="form-alert">Your username and password do not match</div>
<div class="login-or-forgot">
<a href="{$forgottenPasswordURL}">Forgotten password?</a>
<button id="loginSubmit" type="submit" class="btn btn-secondary crm-button">Submit</button>
<button id="loginSubmit" type="submit" class="btn btn-primary crm-button">Submit</button>
</div>
</form>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
<div class="standalone-auth-box">
<form id=totp-form>
<img class="crm-logo" src="{$logoUrl}" alt="logo for CiviCRM, with an intersecting blue and green triangle">
{$statusMessages}

<div class="input-wrapper">
<label for="totpcode" name=totp class="form-label">{ts}Enter the code from your authenticator app{/ts}</label>
Expand Down Expand Up @@ -36,6 +37,7 @@
console.error('caught', e);
}
alert(errorMsg);
totpcodeInput.value = '';
}

form.addEventListener('submit', submit);
Expand All @@ -45,6 +47,9 @@
}
});

// Get ready for user to type code.
totpcodeInput.focus();

});
</script>
{/literal}
Loading

0 comments on commit a70decb

Please sign in to comment.