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

[REF] Standalone System - graduate some functions from Civi\Standalone\Security to CRM_Utils_System_Standalone and Civi\Authx\Standalone #31127

Merged
merged 12 commits into from
Oct 10, 2024
Merged
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
299 changes: 158 additions & 141 deletions CRM/Utils/System/Standalone.php

Large diffs are not rendered by default.

6 changes: 2 additions & 4 deletions ext/standaloneusers/CRM/Standaloneusers/Page/Login.php
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
<?php
use CRM_Standaloneusers_ExtensionUtil as E;
use Civi\Standalone\Security;

class CRM_Standaloneusers_Page_Login extends CRM_Core_Page {

public function run() {
Security::singleton()->getLoggedInUfID();
if (CRM_Core_Session::singleton()->get('ufID')) {
if (CRM_Core_Config::singleton()->userSystem->isUserLoggedIn()) {
// Already logged in.
CRM_Utils_System::redirect('/civicrm');
}
Expand All @@ -26,7 +24,7 @@ public function run() {
* Log out.
*/
public static function logout() {
Security::singleton()->logoutUser();
CRM_Core_Config::singleton()->userSystem->logout();
// Dump them back on the log-IN page.
CRM_Utils_System::redirect('/civicrm/login?justLoggedOut');
}
Expand Down
3 changes: 1 addition & 2 deletions ext/standaloneusers/Civi/Api4/Action/User/Login.php
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,7 @@ protected function passwordCheck(Result $result) {
}

protected function loginUser(int $userID) {
$authx = new \Civi\Authx\Standalone();
$authx->loginSession($userID);
_authx_uf()->loginSession($userID);
}

}
3 changes: 1 addition & 2 deletions ext/standaloneusers/Civi/Api4/Action/User/PasswordReset.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,7 @@ public function _run(Result $result) {
// todo: some minimum password quality check?

// Only valid change here is password, for a known ID.
$security = Security::singleton();
$userID = $security->checkPasswordResetToken($this->token);
$userID = Security::singleton()->checkPasswordResetToken($this->token);
if (!$userID) {
throw new API_Exception("Invalid token.");
}
Expand Down
9 changes: 2 additions & 7 deletions ext/standaloneusers/Civi/Api4/Action/User/WriteTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,15 +75,10 @@ protected function validateValues() {
$loggedInUserID = \CRM_Utils_System::getLoggedInUfID() ?? FALSE;
$hasAdminPermission = \CRM_Core_Permission::check(['cms:administer users']);
$authenticatedAsLoggedInUser = FALSE;
$security = Security::singleton();
// Check that we have the logged-in-user's password.
if ($this->actorPassword && $loggedInUserID) {
$storedHashedPassword = \Civi\Api4\User::get(FALSE)
->addWhere('id', '=', $loggedInUserID)
->addSelect('hashed_password')
->execute()
->single()['hashed_password'];
if (!$security->checkPassword($this->actorPassword, $storedHashedPassword)) {
$user = \CRM_Core_Config::singleton()->userSystem->getUserById($loggedInUserID);
if (!_authx_uf()->checkPassword($user['username'], $this->actorPassword)) {
throw new UnauthorizedException("Incorrect password");
}
$authenticatedAsLoggedInUser = TRUE;
Expand Down
31 changes: 23 additions & 8 deletions ext/standaloneusers/Civi/Authx/Standalone.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,33 +19,48 @@ class Standalone implements AuthxInterface {
* @inheritDoc
*/
public function checkPassword(string $username, string $password) {
$security = Security::singleton();
$user = $security->loadUserByName($username);
return $security->checkPassword($password, $user['hashed_password'] ?? '') ? $user['id'] : NULL;
return Security::singleton()->checkPassword($username, $password);
}

/**
* @inheritDoc
*/
public function loginSession($userId) {
$user = Security::singleton()->loadUserByID($userId);
Security::singleton()->loginAuthenticatedUserRecord($user, TRUE);
$this->loginStateless($userId);

$session = \CRM_Core_Session::singleton();
$session->set('ufID', $userId);

// Identify the contact
$user = \Civi\Api4\User::get(FALSE)
->addWhere('id', '=', $userId)
->execute()
->single();

// Confusingly, Civi stores it's *Contact* ID as *userID* on the session.
$session->set('userID', $user['contact_id'] ?? NULL);

if (!empty($user['language'])) {
$session->set('lcMessages', $user['language']);
}
}

/**
* @inheritDoc
*/
public function logoutSession() {
global $loggedInUserId;
$loggedInUserId = NULL;
\CRM_Core_Session::singleton()->reset();
session_destroy();
// session_destroy();
}

/**
* @inheritDoc
*/
public function loginStateless($userId) {
$user = Security::singleton()->loadUserByID($userId);
Security::singleton()->loginAuthenticatedUserRecord($user, FALSE);
global $loggedInUserId;
$loggedInUserId = $userId;
}

/**
Expand Down
206 changes: 33 additions & 173 deletions ext/standaloneusers/Civi/Standalone/Security.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,25 @@
namespace Civi\Standalone;

use Civi\Crypto\Exception\CryptoException;
use CRM_Core_Session;
use Civi;
use Civi\Api4\User;
use Civi\Api4\MessageTemplate;
use CRM_Standaloneusers_WorkflowMessage_PasswordReset;

/**
* This is a single home for security related functions for Civi Standalone.
* Security related functions for Standaloneusers.
*
* Things may yet move around in the codebase; at the time of writing this helps
* keep core PRs to a minimum.
* This is closely coupled with CRM_Utils_System_Standalone
* Many functions there started life here when Standalone
* was being resurrected.
*
* Some of the generic user functions have been moved back to the
* System class so that they are more permanently available.
*
* Things may yet move around in the codebase - particularly if
* alternative user extensions to Standaloneusers are developed as
* these would then need to share an interface with the System
* class
*/
class Security {

Expand Down Expand Up @@ -53,7 +60,10 @@ public function checkPermission(string $permissionName, ?int $userID = NULL) {
}

// NULL means the current logged-in user
$userID ??= $this->getLoggedInUfID() ?? 0;
$userID = $userID ?? \CRM_Utils_System::getLoggedInUfID();

// now any falsey userid is equivalent to userID = 0 = anonymous user
$userID = $userID ?: 0;

if (!isset(\Civi::$statics[__METHOD__][$userID])) {

Expand Down Expand Up @@ -88,176 +98,39 @@ public function checkPermission(string $permissionName, ?int $userID = NULL) {
}

/**
* High level function to encrypt password using the site-default mechanism.
*/
public function getUserIDFromUsername(string $username): ?int {
return \Civi\Api4\User::get(FALSE)
->addWhere('username', '=', $username)
->execute()
->first()['id'] ?? NULL;
public function hashPassword(string $plaintext): string {
// For now, we just implement D7's but this should be configurable.
// Sites should be able to move from one password hashing algo to another
// e.g. if a vulnerability is discovered.
$algo = new \Civi\Standalone\PasswordAlgorithms\Drupal7();
return $algo->hashPassword($plaintext);
}

/**
* Load an active user by username.
*
* @return array|bool FALSE if not found.
* Standaloneusers implementation of AuthxInterface::checkPassword
* @see \Civi\Authx\Standalone
*/
public function loadUserByName(string $username) {
public function checkPassword(string $username, string $plaintextPassword): ?int {
$user = \Civi\Api4\User::get(FALSE)
->addWhere('username', '=', $username)
->addWhere('is_active', '=', TRUE)
->execute()->first() ?? [];
if ($user) {
return $user;
}
return FALSE;
}

/**
* Load an active user by internal user ID.
*
* @return array|bool FALSE if not found.
*/
public function loadUserByID(int $userID) {
$user = \Civi\Api4\User::get(FALSE)
->addWhere('id', '=', $userID)
->addWhere('is_active', '=', TRUE)
->execute()->first() ?? [];
if ($user) {
return $user;
}
return FALSE;
}

/**
*
*/
public function logoutUser() {
// This is the same call as in CRM_Authx_Page_AJAX::logout()
_authx_uf()->logoutSession();
}

/**
* Create a user in the CMS.
*
* This is the (perhaps temporary location for) the implementation of CRM_Utils_System_Standalone method.
*
* @param array $params keys:
* - 'cms_name'
* - 'cms_pass' plaintext password
* - 'notify' boolean
* @param string $emailParam
* Name of the $param which contains the email address.
*
* @return int|bool
* uid if user was created, false otherwise
*/
public function createUser(&$params, $emailParam) {
try {
$email = $params[$emailParam];
$userID = User::create(FALSE)
->addValue('username', $params['cms_name'])
->addValue('uf_name', $email)
->addValue('password', $params['cms_pass'])
->addValue('contact_id', $params['contact_id'] ?? NULL)
// ->addValue('uf_id', 0) // does not work without this.
->execute()->single()['id'];
}
catch (\Exception $e) {
\Civi::log()->warning("Failed to create user '$email': " . $e->getMessage());
return FALSE;
}

// @todo This next line is what Drupal does, but it's unclear why.
// I think it assumes we want to be logged in as this contact, and as there's no uf match, it's not in civi.
// But I'm not sure if we are always becomming this user; I'm not sure waht calls this function.
// CRM_Core_Config::singleton()->inCiviCRM = FALSE;

return (int) $userID;
}

/**
* Update a user's email
*
* This is the (perhaps temporary location for) the implementation of CRM_Utils_System_Standalone method.
*/
public function updateCMSName($ufID, $email) {
\Civi\Api4\User::update(FALSE)
->addWhere('id', '=', $ufID)
->addValue('uf_name', $email)
->execute();
}
->addSelect('hashed_password', 'id')
->execute()
->first();

/**
* Register the given user as the currently logged in user.
*/
public function loginAuthenticatedUserRecord(array $user, bool $withSession) {
global $loggedInUserId, $loggedInUser;
$loggedInUserId = $user['id'];
$loggedInUser = $user;

if ($withSession) {
$session = \CRM_Core_Session::singleton();
$session->set('ufID', $user['id']);

// Identify the contact
$contactID = civicrm_api3('UFMatch', 'get', [
'sequential' => 1,
'return' => ['contact_id'],
'uf_id' => $user['id'],
])['values'][0]['contact_id'] ?? NULL;
// Confusingly, Civi stores it's *Contact* ID as *userID* on the session.
$session->set('userID', $contactID);
$this->applyLocaleFromUser($user);
if ($user && $this->checkHashedPassword($plaintextPassword, $user['hashed_password'])) {
return $user['id'];
}
}

/**
* This is the (perhaps temporary location for) the implementation of CRM_Utils_System_Standalone method.
*/
public function isUserLoggedIn(): bool {
return !empty($this->getLoggedInUfID());
}

/**
* This is the (perhaps temporary location for) the implementation of CRM_Utils_System_Standalone method.
*/
public function getLoggedInUfID(): ?int {
$authX = new \Civi\Authx\Standalone();
return $authX->getCurrentUserId();
}

/**
* This is the (perhaps temporary location for) the implementation of CRM_Utils_System_Standalone method.
*/
public function languageNegotiationURL($url, $addLanguagePart = TRUE, $removeLanguagePart = FALSE) {
// @todo
return $url;
}

/**
* This is the (perhaps temporary location for) the implementation of CRM_Utils_System_Standalone method.
* Return the CMS-specific url for its permissions page
* @return array
*/
public function getCMSPermissionsUrlParams() {
return ['ufAccessURL' => '/civicrm/admin/roles'];
}

/**
* High level function to encrypt password using the site-default mechanism.
*/
public function hashPassword(string $plaintext): string {
// For now, we just implement D7's but this should be configurable.
// Sites should be able to move from one password hashing algo to another
// e.g. if a vulnerability is discovered.
$algo = new \Civi\Standalone\PasswordAlgorithms\Drupal7();
return $algo->hashPassword($plaintext);
return NULL;
}

/**
* Check whether a password matches a hashed version.
* @return bool
*/
public function checkPassword(string $plaintextPassword, string $storedHashedPassword): bool {
protected function checkHashedPassword(string $plaintextPassword, string $storedHashedPassword): bool {

if (preg_match('@^\$S\$[A-Za-z./0-9]{52}$@', $storedHashedPassword)) {
// Looks like a default D7 password.
Expand Down Expand Up @@ -410,17 +283,4 @@ public function preparePasswordResetWorkflow(array $user, string $token): ?CRM_S
return $workflowMessage;
}

/**
* Applies the locale from the user record.
*
* @param array $user
* @return void
*/
public function applyLocaleFromUser(array $user) {
$session = CRM_Core_Session::singleton();
if (!empty($user['language'])) {
$session->set('lcMessages', $user['language']);
}
}

}
Loading