[TikiWiki-commits] [Git][tikiwiki/tiki][master] [EHN] Tiki reset password: Use random token for secure password reset

Benoit Grégoire (@benoitg) via TikiWiki-cvs <[email protected]>
Newsgroups gmane.comp.cms.tiki.cvs
Message-ID <68b85fee2ca6c_2cdd054570f0@gitlab-sidekiq-low-urgency-cpu-bound-v2-84cbfb8f78-gbwjq.mail>

Benoit Grégoire pushed to branch master at Tiki Wiki CMS Groupware / Tiki


Commits:
a88d01eb by Espoir Baraka at 2025-09-03T15:25:13+00:00
[EHN] Tiki reset password: Use random token for secure password reset
---
* [EHN] Tiki reset password: Use random token for secure password reset

See merge request tikiwiki/tiki!8426

- - - - -


8 changed files:

- db/tiki.sql
- db/tiki_convert_myisam_to_innodb.sql
- + installer/schema/20250107_add_password_reset_tokens_tiki.sql
- + lib/Auth/PasswordResetLib.php
- templates/mail/password_reminder.tpl
- templates/tiki-change_password.tpl
- tiki-change_password.php
- tiki-remind_password.php


Changes:

=====================================
db/tiki.sql
=====================================
@@ -4147,4 +4147,18 @@ CREATE TABLE `tiki_user_passwords_history` (
   `hash` varchar(60) default NULL,
   `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
   PRIMARY KEY(`passId`)
+) ENGINE=MyISAM;
+
+DROP TABLE IF EXISTS `tiki_password_reset_tokens`;
+CREATE TABLE `tiki_password_reset_tokens` (
+  `tokenId` int(11) NOT NULL AUTO_INCREMENT,
+  `user` varchar(200) NOT NULL,
+  `token` varchar(64) NOT NULL,
+  `created` int NOT NULL,
+  `expires` int NOT NULL,
+  `used` tinyint(1) NOT NULL DEFAULT 0,
+  PRIMARY KEY (`tokenId`),
+  UNIQUE KEY `token` (`token`),
+  KEY `user` (`user`),
+  KEY `expires` (`expires`)
 ) ENGINE=MyISAM;
\ No newline at end of file


=====================================
db/tiki_convert_myisam_to_innodb.sql
=====================================
@@ -277,4 +277,5 @@ ALTER TABLE `tiki_2fa_email_tokens` ENGINE = InnoDB;
 ALTER TABLE `tiki_sql_query_logs` ENGINE = InnoDB;
 ALTER TABLE `tiki_iot_apps` ENGINE = InnoDB;
 ALTER TABLE `tiki_iot_apps_actions_logs` ENGINE=InnoDB;
-ALTER TABLE `tiki_user_passwords_history` ENGINE = InnoDB;
\ No newline at end of file
+ALTER TABLE `tiki_user_passwords_history` ENGINE = InnoDB;
+ALTER TABLE `tiki_password_reset_tokens` ENGINE = InnoDB;
\ No newline at end of file


=====================================
installer/schema/20250107_add_password_reset_tokens_tiki.sql
=====================================
@@ -0,0 +1,12 @@
+CREATE TABLE IF NOT EXISTS `tiki_password_reset_tokens` (
+  `tokenId` int(11) NOT NULL AUTO_INCREMENT,
+  `user` varchar(200) NOT NULL,
+  `token` varchar(64) NOT NULL,
+  `created` int NOT NULL,
+  `expires` int NOT NULL,
+  `used` tinyint(1) NOT NULL DEFAULT 0,
+  PRIMARY KEY (`tokenId`),
+  UNIQUE KEY `token` (`token`),
+  KEY `user` (`user`),
+  KEY `expires` (`expires`)
+) ENGINE=MyISAM; 
\ No newline at end of file


=====================================
lib/Auth/PasswordResetLib.php
=====================================
@@ -0,0 +1,96 @@
+<?php
+
+/**
+ * @package tikiwiki
+ */
+
+// (c) Copyright by authors of the Tiki Wiki CMS Groupware Project
+//
+// All Rights Reserved. See copyright.txt for details and a complete list of authors.
+// Licensed under the GNU LESSER GENERAL PUBLIC LICENSE. See license.txt for details.
+
+namespace Tiki\Lib\Auth;
+
+/**
+ * Library for secure password reset functionality
+ */
+class PasswordResetLib extends \TikiLib
+{
+    /**
+     * Generate a secure password reset token and store it in the database
+     * @param string $user Username
+     * @return array Array containing token and expiration time
+     */
+    public function generateSecurePasswordResetToken($user)
+    {
+        global $prefs;
+
+        // Clean up expired tokens first
+        $this->cleanupExpiredPasswordResetTokens();
+
+        // Generate a cryptographically secure random token
+        $token = bin2hex(random_bytes(32)); // 32 bytes = 64 hex characters
+
+        // Set expiration time (default to 1 hour if not configured)
+        $expiry_time = ($prefs['resetpasswordlink_expiry'] ?? 60) * 60;
+        $expires = time() + $expiry_time;
+
+        // Store the token in the database
+        $query = 'INSERT INTO `tiki_password_reset_tokens` (`user`, `token`, `created`, `expires`) VALUES (?, ?, ?, ?)';
+        $result = $this->query($query, [$user, $token, time(), $expires]);
+
+        if ($result) {
+            return [
+                'token' => $token,
+                'expires' => $expires,
+                'expiry_time' => $expiry_time
+            ];
+        }
+
+        return false;
+    }
+
+    /**
+     * Validate a password reset token
+     * @param string $user Username
+     * @param string $token Reset token
+     * @return array|false Array with token info if valid, false otherwise
+     */
+    public function validatePasswordResetToken($user, $token)
+    {
+        // Clean up expired tokens first
+        $this->cleanupExpiredPasswordResetTokens();
+
+        $query = 'SELECT * FROM `tiki_password_reset_tokens` WHERE `user` = ? AND `token` = ? AND `expires` > ? AND `used` = 0';
+        $result = $this->query($query, [$user, $token, time()]);
+
+        if ($result && $result->numRows() > 0) {
+            return $result->fetchRow();
+        }
+
+        return false;
+    }
+
+    /**
+     * Mark a password reset token as used
+     * @param string $user Username
+     * @param string $token Reset token
+     * @return bool Success status
+     */
+    public function markPasswordResetTokenUsed($user, $token)
+    {
+        $query = 'UPDATE `tiki_password_reset_tokens` SET `used` = 1 WHERE `user` = ? AND `token` = ?';
+        $result = $this->query($query, [$user, $token]);
+
+        return $result !== false;
+    }
+
+    /**
+     * Clean up expired password reset tokens
+     */
+    private function cleanupExpiredPasswordResetTokens()
+    {
+        $query = 'DELETE FROM `tiki_password_reset_tokens` WHERE `expires` < ?';
+        $this->query($query, [time()]);
+    }
+}


=====================================
templates/mail/password_reminder.tpl
=====================================
@@ -3,9 +3,11 @@
 {tr _0=$prefs.mail_template_custom_text}Someone requested a password reset for your %0account{/tr} ({$mail_site}).
 
 {tr}Please click on the following link to confirm you wish to reset your password and go to the screen where you must enter a new "permanent" password. Please pick a password only you will know, and don't share it with anyone else.{/tr}
-{mailurl}tiki-change_password.php?user={$mail_user|escape:'url'}&actpass={$mail_apass|escape:'url'}&ts={$mail_timestamp|escape:'url'}&hash={$mail_timestamp_hash|escape:'url'}{/mailurl}
+{mailurl}tiki-change_password.php?user={$mail_user|escape:'url'}&token={$mail_token|escape:'url'}&actpass={$mail_apass|escape:'url'}{/mailurl}
 
 {tr}Important: Username & password are CaSe SenSitiVe{/tr}
 
 {tr}Important: The old password remains active if you don't click the link above.{/tr}
 
+{tr}This reset link will expire in {$mail_expiry_time_formatted}.{/tr}
+


=====================================
templates/tiki-change_password.tpl
=====================================
@@ -21,8 +21,8 @@
                     <input type="hidden" name="apass" value="{$password|escape}">
                     <input type="hidden" name="timestamp" value="{$timestamp|escape}">
                 {/if}
-                {if !empty($hash)}
-                    <input type="hidden" name="hash" value="{$hash|escape}">
+                {if !empty($secure_token)}
+                    <input type="hidden" name="token" value="{$secure_token|escape}">
                 {/if}
                 <div class="card-header text-center">
                     {if $new_user_validation neq 'y'}


=====================================
tiki-change_password.php
=====================================
@@ -9,15 +9,17 @@
 // All Rights Reserved. See copyright.txt for details and a complete list of authors.
 // Licensed under the GNU LESSER GENERAL PUBLIC LICENSE. See license.txt for details.
 $inputConfiguration = [
-    [ 'staticKeyFilters' => [
-        'user' => 'text',
-        'username' => 'text',
-        'pass' => 'none',
-        'passAgain' => 'none',
-        'oldpass' => 'none',
-        'change' => 'text',
+    [
+        'staticKeyFilters' => [
+            'user' => 'text',
+            'username' => 'text',
+            'pass' => 'none',
+            'passAgain' => 'none',
+            'oldpass' => 'none',
+            'change' => 'text',
+            'token' => 'text',
+        ],
     ],
-    ]
 ];
 require_once('tiki-setup.php');
 
@@ -31,11 +33,8 @@ if (! isset($_REQUEST["oldpass"])) {
     $_REQUEST["oldpass"] = '';
 }
 
-// Expected hash
 $user = $_REQUEST["user"];
-$timestamp = $_REQUEST["timestamp"];
-$actpass = $_REQUEST["apass"];
-$expected_hash = md5($user . '|' . $actpass . '|' . $timestamp);
+$secure_token = $_REQUEST["token"] ?? '';
 
 if (isset($_REQUEST["newuser"]) && $_REQUEST["newuser"] == 'y') {
     $smarty->assign('new_user_validation', 'y');
@@ -43,26 +42,27 @@ if (isset($_REQUEST["newuser"]) && $_REQUEST["newuser"] == 'y') {
 
 $smarty->assign('userlogin', $_REQUEST["user"]);
 $smarty->assign('oldpass', $_REQUEST["oldpass"]);
-$smarty->assign('password', $_REQUEST["actpass"]);
-$smarty->assign('timestamp', $_REQUEST["ts"]);
-$smarty->assign('hash', $_REQUEST["hash"]);
+$smarty->assign('secure_token', $secure_token);
 
 if (isset($_REQUEST["change"])) {
     $access->checkCsrf();
 
-    // If this is a new user validation, we do not check the hash or timestamp
+    // If this is a new user validation, we do not check the token
     if (! isset($_REQUEST["new_user_validation"]) && $_REQUEST["new_user_validation"] !== 'y') {
-        // Check if the hash is valid
-        $provided_hash = $_REQUEST["hash"];
-        if ($expected_hash !== $provided_hash) {
-            Feedback::errorAndDie(tra("Invalid hash."), \Laminas\Http\Response::STATUS_CODE_403);
+        // Check if the secure token is valid
+        if (empty($secure_token)) {
+            Feedback::errorAndDie(tra("Missing reset token."), \Laminas\Http\Response::STATUS_CODE_400);
         }
 
-        // Check if the timestamp is valid
-        $resetTime = $prefs['resetpasswordlink_expiry'];
-        if (time() - (int)$timestamp > $resetTime) {
-            Feedback::errorAndDie(tra("The link has expired."), \Laminas\Http\Response::STATUS_CODE_410);
+        $passwordResetLib = new \Tiki\Lib\Auth\PasswordResetLib();
+        $token_info = $passwordResetLib->validatePasswordResetToken($user, $secure_token);
+
+        if (! $token_info) {
+            Feedback::errorAndDie(tra("Invalid or expired reset token."), \Laminas\Http\Response::STATUS_CODE_403);
         }
+
+        // Mark the token as used to prevent reuse
+        $passwordResetLib->markPasswordResetTokenUsed($user, $secure_token);
     }
 
     // Check that pass and passAgain match, otherwise display error and exit


=====================================
tiki-remind_password.php
=====================================
@@ -81,17 +81,36 @@ if (isset($_REQUEST["remind"])) {
         include_once('lib/webmail/tikimaillib.php');
         $name = $_REQUEST['name'];
 
-        $pass = md5($userlib->renew_user_password($name));
-        $timestamp = time();
-        $hash = md5($name . '|' . $pass . '|' . $timestamp);
+        // Generate a secure password reset token instead of the insecure hash
+        $passwordResetLib = new \Tiki\Lib\Auth\PasswordResetLib();
+        $token_info = $passwordResetLib->generateSecurePasswordResetToken($name);
+
+        if (! $token_info) {
+            Feedback::errorAndDie(tra("Failed to generate password reset token. Please try again."), \Laminas\Http\Response::STATUS_CODE_500);
+        }
+
+        // Generate actpass for backward compatibility
+        $actpass = md5($userlib->renew_user_password($name));
+
+        // Format expiry time for display
+        $expiry_time_formatted = $token_info['expiry_time'];
+        // Convert seconds to minutes for display
+        $expiry_minutes = round($expiry_time_formatted / 60, 1);
+        if ($expiry_minutes < 1) {
+            $expiry_time_formatted = $expiry_time_formatted . ' ' . tr($expiry_time_formatted == 1 ? 'second' : 'seconds');
+        } else {
+            $expiry_time_formatted = $expiry_minutes . ' ' . tr($expiry_minutes == 1 ? 'minute' : 'minutes');
+        }
 
         $languageEmail = $tikilib->get_user_preference($name, "language", $prefs['site_language']);
         // Now check if the user should be notified by email
         $smarty->assign('mail_site', $_SERVER["SERVER_NAME"]);
         $smarty->assign('mail_user', $name);
-        $smarty->assign('mail_timestamp', $timestamp);
-        $smarty->assign('mail_timestamp_hash', $hash);
-        $smarty->assign('mail_apass', $pass);
+        $smarty->assign('mail_token', $token_info['token']);
+        $smarty->assign('mail_expires', $token_info['expires']);
+        $smarty->assign('mail_expiry_time', $token_info['expiry_time']);
+        $smarty->assign('mail_expiry_time_formatted', $expiry_time_formatted);
+        $smarty->assign('mail_apass', $actpass);
         $smarty->assign('mail_ip', $tikilib->get_ip_address());
         $mail_data = sprintf($smarty->fetchLang($languageEmail, 'mail/password_reminder_subject.tpl'), $_SERVER["SERVER_NAME"]);
         $mail = new TikiMail($name);



View it on GitLab: https://gitlab.com/tikiwiki/tiki/-/commit/a88d01ebee32a2eeccb766615b0a4fb49379e319

-- 
View it on GitLab: https://gitlab.com/tikiwiki/tiki/-/commit/a88d01ebee32a2eeccb766615b0a4fb49379e319
You're receiving this email because of your account on gitlab.com.

_______________________________________________
TikiWiki-cvs mailing list
[email protected]
https://lists.sourceforge.net/lists/listinfo/tikiwiki-cvs
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.