Skip to content

Commit

Permalink
fix(password) refactor recover password + add button to recover any user
Browse files Browse the repository at this point in the history
  • Loading branch information
seballot committed Jan 22, 2025
1 parent f355313 commit c2d16be
Show file tree
Hide file tree
Showing 6 changed files with 80 additions and 147 deletions.
12 changes: 5 additions & 7 deletions includes/controllers/ApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use YesWiki\Core\Exception\GroupNameDoesNotExistException;
use YesWiki\Core\Exception\InvalidGroupNameException;
use YesWiki\Core\Exception\UserEmailAlreadyUsedException;
use YesWiki\Core\Exception\UserNameAlreadyUsedException;
use YesWiki\Core\Exception\UserNameDoesNotExistException;
use YesWiki\Core\Service\AclService;
use YesWiki\Core\Service\ArchiveService;
Expand Down Expand Up @@ -188,6 +189,7 @@ public function createUser()
{
$this->denyAccessUnlessAdmin();
$userController = $this->getService(UserController::class);
$userManager = $this->getService(UserManager::class);

if (empty($_POST['name'])) {
$code = Response::HTTP_BAD_REQUEST;
Expand All @@ -206,11 +208,7 @@ public function createUser()
'email' => strval($_POST['email']),
'password' => $this->wiki->generateRandomString(30),
]);
if (!boolval($this->wiki->config['contact_disable_email_for_password']) && !empty($user)) {
$link = $userController->sendPasswordRecoveryEmail($user);
} else {
$link = '';
}
$link = $userManager->sendPasswordRecoveryEmail($user);
$code = Response::HTTP_OK;
$result = [
'created' => [$user['name']],
Expand Down Expand Up @@ -341,7 +339,7 @@ public function createGroup()
'name' => $group_name,
'error' => $th->getMessage(),
];
} catch (UserNameDoesNotExistException|GroupNameDoesNotExistException $th) {
} catch (UserNameDoesNotExistException | GroupNameDoesNotExistException $th) {
$code = Response::HTTP_UNPROCESSABLE_ENTITY;
$result = [
'name' => $group_name,
Expand Down Expand Up @@ -386,7 +384,7 @@ public function updateGroup(string $group_name)
'name' => $group_name,
'error' => $th->getMessage(),
];
} catch (UserNameDoesNotExistException|GroupNameDoesNotExistException $th) {
} catch (UserNameDoesNotExistException | GroupNameDoesNotExistException $th) {
$code = Response::HTTP_UNPROCESSABLE_ENTITY;
$result = [
'name' => $group_name,
Expand Down
9 changes: 0 additions & 9 deletions includes/controllers/UserController.php
Original file line number Diff line number Diff line change
Expand Up @@ -103,15 +103,6 @@ public function create(array $newValues): ?User
return null;
}

public function sendPasswordRecoveryEmail(User $user): string
{
if ($this->userManager->sendPasswordRecoveryEmail($user, _t('LOGIN_PASSWORD_FOR'))) {
return $this->userManager->getUserLink();
} else {
return '';
}
}

/**
* update user params
* for e-mail check is existing e-mail.
Expand Down
148 changes: 52 additions & 96 deletions includes/services/UserManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ class UserManager implements UserProviderInterface, PasswordUpgraderInterface
protected $securityController;
protected $params;
protected $tripleStore;
protected $userlink;

private $getOneByNameCacheResults;

Expand All @@ -56,24 +55,6 @@ public function __construct(
$this->params = $params;
$this->tripleStore = $tripleStore;
$this->getOneByNameCacheResults = [];
$this->userlink = '';
}

private function arrayToUser(?array $userAsArray = null, bool $fillEmpty = false): ?User
{
if (empty($userAsArray)) {
return null;
}
if ($fillEmpty) {
foreach (User::PROPS_LIST as $key) {
if (!array_key_exists($key, $userAsArray)) {
$userAsArray[$key] = null;
}
}
}

// be carefull the User::__construct is really strict about list of properties that should set
return new User($userAsArray);
}

public function userExist($name): bool
Expand Down Expand Up @@ -128,9 +109,8 @@ function ($userAsArray) {
*/
public function create($wikiNameOrUser, string $email = '', string $plainPassword = '')
{
$this->userlink = '';
if ($this->securityController->isWikiHibernated()) {
throw new \Exception(_t('WIKI_IN_HIBERNATION'));
throw new Exception(_t('WIKI_IN_HIBERNATION'));
}

if (is_array($wikiNameOrUser)) {
Expand Down Expand Up @@ -161,23 +141,23 @@ public function create($wikiNameOrUser, string $email = '', string $plainPasswor
'signuptime' => '',
];
} else {
throw new \Exception('First parameter of UserManager->create should be string or array!');
throw new Exception('First parameter of UserManager->create should be string or array!');
}

if (empty($wikiName)) {
throw new \Exception("'Name' parameter of UserManager->create should not be empty!");
throw new Exception("'Name' parameter of UserManager->create should not be empty!");
}
if (!empty($this->getOneByName($wikiName))) {
throw new UserNameAlreadyUsedException();
}
if (empty($email)) {
throw new \Exception("'email' parameter of UserManager->create should not be empty!");
throw new Exception("'email' parameter of UserManager->create should not be empty!");
}
if (!empty($this->getOneByEmail($email))) {
throw new UserEmailAlreadyUsedException();
}
if (empty($plainPassword)) {
throw new \Exception("'password' parameter of UserManager->create should not be empty!");
throw new Exception("'password' parameter of UserManager->create should not be empty!");
}

unset($this->getOneByNameCacheResults[$wikiName]);
Expand All @@ -199,93 +179,52 @@ public function create($wikiNameOrUser, string $email = '', string $plainPasswor
);
}

/*
* Password recovery process (AKA reset password)
* 1. A key is generated using name, email alongside with other stuff.
* 2. The triple (user's name, specific key "vocabulary",key) is stored in triples table.
* 3. In order to update h·er·is password, the user must provided that key.
* 4. The new password is accepted only if the key matches with the value in triples table.
* 5. The corresponding row is removed from triples table.
/** Part of the Password recovery process: Handles the password recovery email process
*
* Generates the password recovery key
* Stores the (name, vocabulary, key) triple in triples table
* Generates the recovery email
* Sends it
*
* @param User $user
* @return string The link sent to the user
*/

protected function generateUserLink($user)
public function sendPasswordRecoveryEmail(User $user)
{
// Generate the password recovery key
$passwordHasher = $this->passwordHasherFactory->getPasswordHasher($user);
$plainKey = $user['name'] . '_' . $user['email'] . random_bytes(16) . date('Y-m-d H:i:s');
$hashedKey = $passwordHasher->hash($plainKey);
$tripleStore = $this->wiki->services->get(TripleStore::class);
// Erase the previous triples in the trible table
$tripleStore->delete($user['name'], self::KEY_VOCABULARY, null, '', '');
$this->tripleStore->delete($user['name'], self::KEY_VOCABULARY, null, '', '');
// Store the (name, vocabulary, key) triple in triples table
$tripleStore->create($user['name'], self::KEY_VOCABULARY, $hashedKey, '', '');
$this->tripleStore->create($user['name'], self::KEY_VOCABULARY, $hashedKey, '', '');

// Generate the recovery email
$this->userlink = $this->wiki->Href('', 'MotDePassePerdu', [
// Generate the recovery link
$link = $this->wiki->Href('', 'MotDePassePerdu', [
'a' => 'recover',
'email' => $hashedKey,
'u' => base64_encode($user['name']),
], false);
}

/**
* Part of the Password recovery process: Handles the password recovery email process.
*
* Generates the password recovery key
* Stores the (name, vocabulary, key) triple in triples table
* Generates the recovery email
* Sends it
*
* @return bool True if OK or false if any problems
*/
public function sendPasswordRecoveryEmail(User $user, string $title): bool
{
$this->generateUserLink($user);
$pieces = parse_url($this->params->get('base_url'));
$domain = isset($pieces['host']) ? $pieces['host'] : '';

$message = _t('LOGIN_DEAR') . ' ' . $user['name'] . ",\n";
$message .= _t('LOGIN_CLICK_FOLLOWING_LINK') . ' :' . "\n";
$message .= '-----------------------' . "\n";
$message .= $this->userlink . "\n";
$message .= '-----------------------' . "\n";
$message .= _t('LOGIN_THE_TEAM') . ' ' . $domain . "\n";

$subject = $title . ' ' . $domain;

// Send the email
return send_mail($this->params->get('BAZ_ADRESSE_MAIL_ADMIN'), $this->params->get('BAZ_ADRESSE_MAIL_ADMIN'), $user['email'], $subject, $message);
}
if (!boolval($this->wiki->config['contact_disable_email_for_password'])) {
$pieces = parse_url($this->params->get('base_url'));
$domain = isset($pieces['host']) ? $pieces['host'] : '';

/**
* Assessor for userlink field.
*/
public function getUserLink(): string
{
return $this->userlink;
}
$message = _t('LOGIN_DEAR') . ' ' . $user['name'] . ",\n";
$message .= _t('LOGIN_CLICK_FOLLOWING_LINK') . ' :' . "\n";
$message .= '-----------------------' . "\n";
$message .= $link . "\n";
$message .= '-----------------------' . "\n";
$message .= _t('LOGIN_THE_TEAM') . ' ' . $domain . "\n";

/**
* Assessor for userlink field.
*/
public function getLastUserLink(User $user): string
{
$passwordHasher = $this->passwordHasherFactory->getPasswordHasher($user);
$plainKey = $user['name'] . '_' . $user['email'] . random_bytes(16) . date('Y-m-d H:i:s');
$hashedKey = $passwordHasher->hash($plainKey);
$tripleStore = $this->wiki->services->get(TripleStore::class);
$key = $tripleStore->getOne($user['name'], self::KEY_VOCABULARY, '', '');
if ($key != null) {
$this->userlink = $this->wiki->Href('', 'MotDePassePerdu', [
'a' => 'recover',
'email' => $key,
'u' => base64_encode($user['name']),
], false);
} else {
$this->generateUserLink($user);
$subject = _t('LOGIN_PASSWORD_LOST_FOR') . ' ' . $domain;

send_mail($this->params->get('BAZ_ADRESSE_MAIL_ADMIN'), $this->params->get('BAZ_ADRESSE_MAIL_ADMIN'), $user['email'], $subject, $message);
}

return $this->userlink;
return $link;
}

/**
Expand All @@ -300,7 +239,7 @@ public function getLastUserLink(User $user): string
public function update(User $user, array $newValues): bool
{
if ($this->securityController->isWikiHibernated()) {
throw new \Exception(_t('WIKI_IN_HIBERNATION'));
throw new Exception(_t('WIKI_IN_HIBERNATION'));
}
$newKeys = array_keys($newValues);
$authorizedKeys = array_filter($newKeys, function ($key) {
Expand Down Expand Up @@ -378,10 +317,10 @@ public function delete(User $user)
*/
public function groupsWhereIsMember(User $user, bool $adminCheck = true)
{
$group_list = $this->tripleStore->getMatching(GROUP_PREFIX . '%', null, '%'.$user['name'].'%', 'LIKE', '=', 'LIKE');
$group_list = $this->tripleStore->getMatching(GROUP_PREFIX . '%', null, '%' . $user['name'] . '%', 'LIKE', '=', 'LIKE');
$prefix_len = strlen(GROUP_PREFIX);
$list = array();
foreach($group_list as $group) {
foreach ($group_list as $group) {
$list[] = substr($group['resource'], $prefix_len);
}
return $list;
Expand Down Expand Up @@ -542,4 +481,21 @@ public function logout()
{
$this->wiki->services->get(AuthController::class)->logout();
}

private function arrayToUser(?array $userAsArray = null, bool $fillEmpty = false): ?User
{
if (empty($userAsArray)) {
return null;
}
if ($fillEmpty) {
foreach (User::PROPS_LIST as $key) {
if (!array_key_exists($key, $userAsArray)) {
$userAsArray[$key] = null;
}
}
}

// be carefull the User::__construct is really strict about list of properties that should set
return new User($userAsArray);
}
}
15 changes: 3 additions & 12 deletions tools/login/actions/LostPasswordAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,19 +68,16 @@ public function run()
'type' => 'danger',
'message' => _t('LOGIN_UNKNOWN_USER'),
]);
break;
case 'successPage':
return $renderedTitle . $this->render('@templates/alert-message.twig', [
'type' => 'success',
'message' => _t('LOGIN_MESSAGE_SENT'),
]);
break;
case 'recoverSuccess':
return $renderedTitle . $this->render('@templates/alert-message.twig', [
'type' => 'success',
'message' => _t('LOGIN_PASSWORD_WAS_RESET'),
]);
break;
case 'recoverForm':
if (isset($hash)) {
$key = $hash;
Expand All @@ -95,13 +92,11 @@ public function run()
'key' => $hash ?? $key,
'inIframe' => (testUrlInIframe() == 'iframe'),
]);
break;
case 'directDangerMessage':
return $renderedTitle . $this->render('@templates/alert-message.twig', [
'type' => 'danger',
'message' => $message,
]);
break;
case 'emailForm':
default:
return $this->render('@login/lost-password-email-form.twig', [
Expand Down Expand Up @@ -130,7 +125,7 @@ private function manageSubStep(int $subStep): ?User
$user = $this->userManager->getOneByEmail($email);
if (!empty($user)) {
$this->typeOfRendering = 'successPage';
$this->userManager->sendPasswordRecoveryEmail($user, _t('LOGIN_PASSWORD_LOST_FOR'));
$this->userManager->sendPasswordRecoveryEmail($user);
} else {
$this->errorType = 'userNotFound';
$this->typeOfRendering = 'userNotFound';
Expand Down Expand Up @@ -177,12 +172,10 @@ private function manageSubStep(int $subStep): ?User
return $user ?? null;
}

/** Part of the Password recovery process: sets the password to a new value if given the the proper recovery key (sent in a recovery email).
*
/**
* In order to update h·er·is password, the user provides a key (sent using sendPasswordRecoveryEmail())
* The new password is accepted only if the key matches with the value in triples table.
* The corresponding row is the removed from triples table.
* See Password recovery process above
*
* @param string $userName The user login
* @param string $key The password recovery key (sent by email)
Expand All @@ -201,10 +194,9 @@ private function resetPassword(string $userName, string $key, string $password)

$user = $this->userManager->getOneByName($userName);
if (empty($user)) {
$this->error = false;
$this->typeOfRendering = 'userNotFound';

return null;
return false;
}
$this->authController->setPassword($user, $password);
// Was able to update password => Remove the key from triples table
Expand All @@ -230,5 +222,4 @@ private function checkEmailKey(string $hash, string $user): bool
// Pas de detournement possible car utilisation de _vocabulary/key ....
return !is_null($this->tripleStore->exist($user, UserManager::KEY_VOCABULARY, $hash, '', ''));
}
/* End of Password recovery process (AKA reset password) */
}
Loading

0 comments on commit c2d16be

Please sign in to comment.