fixes GHSA-9986-w7jf-33f6 and fixes GHSA-9986-w7jf-33f6

* Introduces a verification code step before generating password reset links.
* New configuration "password_reset_code_duration".
* Adds Base32, TOTP and PHPQRCode classes .
* New section is required in password.tpl: code verification won't work on themes not updated yet.
* 5 new language strings were added.
This commit is contained in:
Linty
2025-10-17 15:38:21 +02:00
parent ce3ccfe563
commit 9ac99be1de
12 changed files with 3771 additions and 40 deletions

View File

@@ -24,6 +24,8 @@ if (!is_a_guest())
trigger_notify('loc_begin_identification');
unset($_SESSION['reset_password_code']);
//-------------------------------------------------------------- identification
// security (level 1): the redirect must occur within Piwigo, so the

85
include/base32.class.php Normal file
View File

@@ -0,0 +1,85 @@
<?php
defined('PHPWG_ROOT_PATH') or die('Hacking attempt!');
/**
* Encode in Base32 based on RFC 4648.
* Requires 20% more space than base64
* Great for case-insensitive filesystems like Windows and URL's (except for = char which can be excluded using the pad option for urls)
*
* @author Bryan Ruiz
* @url https://www.php.net/manual/en/function.base-convert.php#102232
**/
class PwgBase32 {
private static $map = array(
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', // 7
'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', // 15
'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', // 23
'Y', 'Z', '2', '3', '4', '5', '6', '7', // 31
'=' // padding char
);
private static $flippedMap = array(
'A'=>'0', 'B'=>'1', 'C'=>'2', 'D'=>'3', 'E'=>'4', 'F'=>'5', 'G'=>'6', 'H'=>'7',
'I'=>'8', 'J'=>'9', 'K'=>'10', 'L'=>'11', 'M'=>'12', 'N'=>'13', 'O'=>'14', 'P'=>'15',
'Q'=>'16', 'R'=>'17', 'S'=>'18', 'T'=>'19', 'U'=>'20', 'V'=>'21', 'W'=>'22', 'X'=>'23',
'Y'=>'24', 'Z'=>'25', '2'=>'26', '3'=>'27', '4'=>'28', '5'=>'29', '6'=>'30', '7'=>'31'
);
/**
* Use padding false when encoding for urls
*
* @return base32 encoded string
**/
public static function encode($input, $padding = true)
{
if (empty($input)) return "";
$input = str_split($input);
$binaryString = "";
for ($i = 0; $i < count($input); $i++) {
$binaryString .= str_pad(base_convert(ord($input[$i]), 10, 2), 8, '0', STR_PAD_LEFT);
}
$fiveBitBinaryArray = str_split($binaryString, 5);
$base32 = "";
$i = 0;
while ($i < count($fiveBitBinaryArray)) {
$base32 .= self::$map[base_convert(str_pad($fiveBitBinaryArray[$i], 5, '0'), 2, 10)];
$i++;
}
if ($padding && ($x = strlen($binaryString) % 40) != 0) {
if ($x == 8) $base32 .= str_repeat(self::$map[32], 6);
else if ($x == 16) $base32 .= str_repeat(self::$map[32], 4);
else if ($x == 24) $base32 .= str_repeat(self::$map[32], 3);
else if ($x == 32) $base32 .= self::$map[32];
}
return $base32;
}
public static function decode($input)
{
if (empty($input)) return;
$paddingCharCount = substr_count($input, self::$map[32]);
$allowedValues = array(6, 4, 3, 1, 0);
if (!in_array($paddingCharCount, $allowedValues)) return false;
for ($i = 0; $i < 4; $i++) {
if (
$paddingCharCount == $allowedValues[$i] &&
substr($input, - ($allowedValues[$i])) != str_repeat(self::$map[32], $allowedValues[$i])
) return false;
}
$input = str_replace('=', '', $input);
$input = str_split($input);
$binaryString = "";
for ($i = 0; $i < count($input); $i = $i + 8) {
$x = "";
if (!in_array($input[$i], self::$map)) return false;
for ($j = 0; $j < 8; $j++) {
$x .= str_pad(base_convert(@self::$flippedMap[@$input[$i + $j]], 10, 2), 5, '0', STR_PAD_LEFT);
}
$eightBits = str_split($x, 8);
for ($z = 0; $z < count($eightBits); $z++) {
$binaryString .= (($y = chr(base_convert($eightBits[$z], 2, 10))) || ord($y) == 48) ? $y : "";
}
}
return $binaryString;
}
}

View File

@@ -635,6 +635,11 @@ $conf['password_reset_duration'] = 60*60;
// of an password activation link. Default value is 72 hours (259200 seconds).
$conf['password_activation_duration'] = 3*24*60*60;
// password_reset_code_duration: defines the validity duration (in seconds)
// for the verification code sent before genrating the reset link.
// Default value is 5 minutes (max = 15 minutes)
$conf['password_reset_code_duration'] = 5 * 60;
// +-----------------------------------------------------------------------+
// | history |
// +-----------------------------------------------------------------------+

View File

@@ -1077,6 +1077,33 @@ function pwg_generate_set_password_mail($username, $set_password_link, $gallery_
);
}
/**
* Generate content mail for user code verification
*
* Return the content mail to send
* @since 16
* @param string $code
* @return array mail content
*/
function pwg_generate_code_verification_mail($code)
{
global $conf;
set_make_full_url();
$message = '<p style="margin: 20px 0">';
$message.= l10n('Here is your verification code:').' <br />';
$message.= '<span style="font-size: 16px">'. $code .'</span></p>';
$message.= '<p style="margin: 20px 0;">';
$message.= l10n('If this was a mistake, just ignore this email and nothing will happen.') . '</p>';
unset_make_full_url();
$subject = '['.$conf['gallery_title'].'] '.l10n('Your verification code');
return array(
'subject' => $subject,
'content' => $message,
'content_format' => 'text/html',
);
}
trigger_notify('functions_mail_included');
?>

View File

@@ -2708,4 +2708,40 @@ function notification_api_key_expiration($username, $email, $days_left)
return $result;
}
/**
* Generate an user code for verification
*
* @since 16
* @return array [$secret, $code]
*/
function generate_user_code()
{
global $conf;
require_once(PHPWG_ROOT_PATH . 'include/totp.class.php');
$secret = PwgTOTP::generateSecret();
$code = PwgTOTP::generateCode($secret, min($conf['password_reset_code_duration'], 900)); // max 15 minutes
return array(
'secret' => $secret,
'code' => $code
);
}
/**
* Verify user code
*
* @since 16
* @param string $secret
* @param string $code
* @return bool
*/
function verify_user_code($secret, $code)
{
global $conf;
require_once(PHPWG_ROOT_PATH . 'include/totp.class.php');
return PwgTOTP::verifyCode($code, $secret, min($conf['password_reset_code_duration'], 900), 1);
}
?>

3311
include/phpqrcode.php Normal file

File diff suppressed because it is too large Load Diff

115
include/totp.class.php Normal file
View File

@@ -0,0 +1,115 @@
<?php
defined('PHPWG_ROOT_PATH') or die('Hacking attempt!');
require_once(PHPWG_ROOT_PATH . 'include/base32.class.php');
class PwgTOTP
{
/**
* Generate a Base32 secret for TOTP
*
* @param string $secret Base32-encoded secret
* @param int $timestamp 30s intervasl since 1970
* @return string TOTP Code
*/
private static function generateCodeFromTimestamp($secret, $timestamp)
{
$key = PwgBase32::decode($secret);
$msg = pack('N*', 0) . pack('N*', $timestamp); // hash_hmac need this form
$hash = hash_hmac('sha1', $msg, $key, true);
// RFC 4226, section 5.3
$offset = ord(substr($hash, -1)) & 0x0F;
$part = substr($hash, $offset, 4);
$number = unpack('N', $part)[1] & 0x7FFFFFFF;
$code = $number % 1000000; // code 6 digits $number % 10^6
return str_pad((string)$code, 6, '0', STR_PAD_LEFT); // 123 become 000123
}
/**
* Generate a Base32 secret for TOTP
*
* @param int $length Length in bytes (default: 20)
* @return string Base32-encoded secret
*/
public static function generateSecret($length = 20)
{
$random = random_bytes($length);
return PwgBase32::encode($random, false);
}
/**
* Get Otp auth url
*
* @param string $secret Encoded base32 secret
* @return string otpauth://totp/ url
*/
public static function getOtpAuthUrl($secret)
{
global $user;
$url = substr(get_absolute_root_url(), 0, -1);
return 'otpauth://totp/'.$user['username'].':'.$url.'?secret='.$secret.'&issuer=Piwigo&algorithm=sha1&digits=6&period=30';
}
/**
* Get Qr Code
*
* @param string $secret Encoded base32 secret
* @return string data:image/png;base64..
*/
public static function getQrCode($secret)
{
// require_once(TF_REALPATH . 'include/phpqrcode.php');
// $otp_url = self::getOtpAuthUrl($secret);
// ob_start();
// QRcode::png($otp_url);
// $qrcode_image = ob_get_clean();
// $base64_qrcode = base64_encode($qrcode_image);
// return 'data:image/png;base64,' . $base64_qrcode;
}
/**
* Generate a TOTP Code
*
* @param string $secret Encoded base32 secret
* @param int $timestamp timestamp used in second (default: 30)
* @return string 6 digits TOTP code
*/
public static function generateCode($secret, $timestamp = 30)
{
$timestamp = floor(time() / $timestamp); // e.g 58338889 > 30-second intervals since 1970 at the moment T
return self::generateCodeFromTimestamp($secret, $timestamp);
}
/**
* Verify TOTP Code
*
* @param string $code Digits 6 TOTP Code
* @param string $secret Encoded base32 secret
* @param int $timestamp timestamp used in second (default: 30)
* @param int $check_interval Number of 30s steps to check before/after current (default: 1)
* @return bool
*/
public static function verifyCode($code, $secret, $timestamp = 30, $check_interval = 1)
{
$timestamp = floor(time() / $timestamp);
// generate a totp code for 30s intervals
// following or preceding the current one and check it
for ($i=-$check_interval; $i <= $check_interval; $i++)
{
$interval_timestamp = $timestamp + $i;
$generated_code = self::generateCodeFromTimestamp($secret, $interval_timestamp);
if (hash_equals($generated_code, $code))
{
return true;
}
}
return false;
}
}

View File

@@ -524,3 +524,8 @@ $lang['Your API key will expire in %d days.'] = 'Your API key will expire in %d
$lang['To continue using the API, please renew your key before it expires.'] = 'To continue using the API, please renew your key before it expires.';
$lang['You can manage your API keys in your <a href="%s">account settings.</a>'] = 'You can manage your API keys in your <a href="%s">account settings.</a>';
$lang['Expert mode'] = 'Expert mode';
$lang['An email has been sent with a verification code'] = 'An email has been sent with a verification code';
$lang['If you do not receive the email, please contact your webmaster.'] = 'If you do not receive the email, please contact your webmaster.';
$lang['Verification code'] = 'Verification code';
$lang['Verify'] = 'Verify';
$lang['Invalid verification code'] = 'Invalid verification code';

View File

@@ -523,3 +523,8 @@ $lang['Your API key will expire in %d days.'] = 'Votre clé API expirera dans %d
$lang['To continue using the API, please renew your key before it expires.'] = 'Pour continuer à utiliser l\'API, veuillez renouveler votre clé avant son expiration.';
$lang['You can manage your API keys in your <a href="%s">account settings.</a>'] = 'Vous pouvez gérer vos clés API dans les <a href="%s">paramètres de votre compte.</a>';
$lang['Expert mode'] = 'Mode expert';
$lang['An email has been sent with a verification code'] = 'Un e-mail contenant un code de vérification vous a été envoyé';
$lang['If you do not receive the email, please contact your webmaster.'] = 'Si vous ne recevez pas cet e-mail, veuillez contacter votre webmaster.';
$lang['Verification code'] = 'Code de vérification';
$lang['Verify'] = 'Vérifier';
$lang['Invalid verification code'] = 'Code de vérification invalide';

View File

@@ -22,7 +22,7 @@ check_status(ACCESS_FREE);
trigger_notify('loc_begin_password');
check_input_parameter('action', $_GET, false, '/^(lost|reset|lost_end|reset_end|none)$/');
check_input_parameter('action', $_GET, false, '/^(lost|reset|lost_code|lost_end|reset_end|none)$/');
// +-----------------------------------------------------------------------+
// | Functions |
@@ -30,7 +30,80 @@ check_input_parameter('action', $_GET, false, '/^(lost|reset|lost_end|reset_end|
/**
* checks the validity of input parameters, fills $page['errors'] and
* $page['infos'] and send an email with confirmation link
* $page['infos'] and send an email with the verification code
*
* @return bool
*/
function process_verification_code()
{
global $page, $conf, $logger;
if (isset($_SESSION['reset_password_code']))
{
return true;
}
// empty param
$username_or_email = trim($_POST['username_or_email']);
if (empty($username_or_email))
{
$page['errors']['password_form_error'] = l10n('Invalid username or email');
return false;
}
// retrievies user by email is not try by username
$user_id = get_userid_by_email($username_or_email);
if (!is_numeric($user_id))
{
$user_id = get_userid($username_or_email);
}
// when no user is found, we assign guest_id instead of stopping.
// this lets the function behave identically for unknown users,
// preventing username/email enumeration through timing or responses.
$is_user_founded = is_numeric($user_id);
if (!$is_user_founded)
{
$user_id = $conf['guest_id'];
}
$userdata = getuserdata($user_id, false);
// check if we want to skip email sending
// if user is guest, generic or doesn't have email
$status = $userdata['status'];
$skip_mail = !$is_user_founded or is_a_guest($status) or is_generic($status) or empty($userdata['email']);
// send mail with verification code to user
switch_lang_to($userdata['language']);
$user_code = generate_user_code();
$template_mail = pwg_generate_code_verification_mail($user_code['code']);
if (!$skip_mail)
{
$mail_send = pwg_mail($userdata['email'], $template_mail);
// pwg_activity('user', $userdata['id'], 'reset_password_code', array(
// 'ip' => $_SERVER['REMOTE_ADDR'],
// 'agent' => $_SERVER['HTTP_USER_AGENT'],
// 'is_mail_sent' => $mail_send
// ));
}
switch_lang_back();
$_SESSION['reset_password_code'] = [
'secret' => $user_code['secret'],
'attempts' => 0,
'user_id' => $is_user_founded ? $user_id : null,
'created_at' => time(),
'ttl' => min($conf['password_reset_code_duration'], 900) // max 15 min
];
return true;
}
/**
* checks the validity of input parameters, fills $page['errors'] and
* $page['infos'] and send an email with reset link
*
* @return bool (true if email was sent, false otherwise)
*/
@@ -38,63 +111,79 @@ function process_password_request()
{
global $page, $conf;
if (empty($_POST['username_or_email']))
$state = $_SESSION['reset_password_code'] ?? null;
if (!$state)
{
$page['errors']['password_form_error'] = l10n('Invalid username or email');
return true; // fallback line 366
}
// check expired
if (time() > $state['created_at'] + $state['ttl'])
{
unset($_SESSION['reset_password_code']);
$page['errors']['password_form_error'] = l10n('Code expired');
return false;
}
$user_id = get_userid_by_email($_POST['username_or_email']);
$_SESSION['reset_password_code']['attempts']++;
if (!is_numeric($user_id))
$is_valid = true;
$user_code = trim($_POST['user_code'] ?? '');
if (
empty($user_code) // empty user code
|| !preg_match('/^\d{6}$/', $user_code) // check digit 6
|| !verify_user_code($state['secret'], $user_code)) // verify user code
{
$user_id = get_userid($_POST['username_or_email']);
$is_valid = false;
}
if (!is_numeric($user_id))
if (!$is_valid)
{
$page['errors']['password_form_error'] = l10n('Invalid username or email');
if ($_SESSION['reset_password_code']['attempts'] >= 3)
{
unset($_SESSION['reset_password_code']);
$page['errors']['login_page_error'] = l10n('Too many attempts');
return false;
}
$userdata = getuserdata($user_id, false);
$page['errors']['password_form_error'] = l10n('Invalid verification code');
return false;
}
// password request is not possible for guest/generic users
$status = $userdata['status'];
if (is_a_guest($status) or is_generic($status))
// verify code success
$user_id = $state['user_id'];
unset($_SESSION['reset_password_code']);
if (empty($user_id))
{
$page['errors']['password_form_error'] = l10n('Invalid verification code');
return false;
}
$userdata = getuserdata($user_id);
$status = $userdata['status'] ?? null;
// fallback check: don't send mail when user is guest, generic or doesn't have email
if (is_a_guest($status) || is_generic($status) || empty($userdata['email']))
{
$page['errors']['password_form_error'] = l10n('Password reset is not allowed for this user');
return false;
}
if (empty($userdata['email']))
{
$page['errors']['password_form_error'] = l10n(
'User "%s" has no email address, password reset is not possible',
$userdata['username']
);
return false;
}
$generate_link = generate_password_link($user_id);
// $userdata['activation_key'] = $generate_link['activation_key'];
switch_lang_to($userdata['language']);
$email_params = pwg_generate_reset_password_mail($userdata['username'], $generate_link['password_link'], $conf['gallery_title'], $generate_link['time_validation']);
$send_email = pwg_mail($userdata['email'], $email_params);
switch_lang_back();
if ($send_email)
{
$page['infos'][] = l10n('Check your email for the confirmation link');
// pwg_activity('user', $userdata['id'], 'reset_password_link', array(
// 'ip' => $_SERVER['REMOTE_ADDR'],
// 'agent' => $_SERVER['HTTP_USER_AGENT'],
// 'is_mail_sent' => $send_email
// ));
return true;
}
else
{
$page['errors']['password_page_error'] = l10n('Error sending email');
return false;
}
}
/**
@@ -199,9 +288,19 @@ if (isset($_POST['submit']))
check_pwg_token();
if ('lost' == $_GET['action'])
{
if (process_verification_code())
{
$page['infos'][] = l10n('An email has been sent with a verification code');
$page['action'] = 'lost_code';
}
}
if ('lost_code' == $_GET['action'])
{
if (process_password_request())
{
$page['infos'][] = l10n('An email has been sent with a link to reset your password');
$page['action'] = 'lost_end';
}
}
@@ -253,7 +352,7 @@ if (!isset($page['action']))
{
$page['action'] = 'lost';
}
elseif (in_array($_GET['action'], array('lost', 'reset', 'none')))
elseif (in_array($_GET['action'], array('lost', 'lost_code', 'reset', 'none')))
{
$page['action'] = $_GET['action'];
}
@@ -269,6 +368,16 @@ if ('lost' == $page['action'] and !is_a_guest())
redirect(get_gallery_home_url());
}
if ('lost_code' == $page['action'] and !isset($_SESSION['reset_password_code']))
{
redirect(get_gallery_home_url(). 'identification.php');
}
if ('lost' == $page['action'] and isset($_SESSION['reset_password_code']))
{
$page['action'] = 'lost_code';
}
// +-----------------------------------------------------------------------+
// | template initialization |
// +-----------------------------------------------------------------------+

View File

@@ -27,6 +27,17 @@
</p>
<p class="bottomButtons"><input type="submit" name="submit" value="{'Change my password'|@translate}"></p>
{elseif $action eq 'lost_code'}
<div>
<div class="message">{"If you do not receive the email, please contact your webmaster."|translate}</div>
<label>
{'Verification code'|@translate}
<br>
<input type="text" id="user_code" name="user_code" size="100" />
</label>
<p class="bottomButtons"><input type="submit" name="submit" value="{'Verify'|@translate}"></p>
</div>
{elseif $action eq 'reset'}
<div class="message">
@@ -60,6 +71,8 @@
{literal}try{document.getElementById('username_or_email').focus();}catch(e){}{/literal}
{elseif $action eq 'reset'}
{literal}try{document.getElementById('use_new_pwd').focus();}catch(e){}{/literal}
{elseif $action eq 'lost_code'}
{literal}try{document.getElementById('user_code').focus();}catch(e){}{/literal}
{/if}
</script>

View File

@@ -37,14 +37,14 @@
<section id="password-form">
<div class="">
{if $action eq 'lost' or $action eq 'reset'}
{if $action eq 'lost' or $action eq 'reset' or $action eq 'lost_code'}
<h1 class="">{if !isset($is_first_login)}{'Forgot your password?'|translate}{else}{'Welcome !'|translate}<br>{'It\'s your first login !'|translate}{/if}</h1>
<form id="lostPassword" class="properties" action="{$form_action}?action={$action}{if isset($key)}&amp;key={$key}{/if}" method="post">
<input type="hidden" name="pwg_token" value="{$PWG_TOKEN}">
{if $action eq 'lost'}
<p class="form-instructions">{'Please enter your username or email address.'|@translate}<br>{'You will receive a link to create a new password via email.'|@translate}</p>
<p class="form-instructions">{'Please enter your username or email address.'|@translate} {'You will receive a link to create a new password via email.'|@translate}</p>
<div class="column-flex">
<label for="username">{'Username or email'|@translate}</label>
@@ -105,6 +105,24 @@
<input tabindex="4" type="submit" name="submit" {if !isset($is_first_login)}value="{'Confirm my new password'|@translate}"{else}value="{'Set my password'|@translate}"{/if} class="btn btn-main ">
</div>
{elseif $action eq 'lost_code'}
<span class="success-message"><i class="gallery-icon-ok-circled"></i>{'An email has been sent with a verification code'|translate}</span>
<div class="column-flex">
<label for="user_code">{'Verification code'|@translate}</label>
<div class="row-flex input-container">
<i class="gallery-icon-user-2"></i>
<input type="text" id="user_code" name="user_code" size="100" maxlength="100" autofocus>
</div>
<p class="error-message"><i class="gallery-icon-attention-circled"></i> {'must not be empty'|translate}</p>
</div>
<div class="column-flex">
<input tabindex="4" type="submit" name="submit" value="{'Verify'|@translate}" class="btn btn-main">
{if isset($errors['password_form_error'])}
<p class="error-message" style="display:block;bottom:-20px;"><i class="gallery-icon-attention-circled"></i> {$errors['password_form_error']}</p>
{/if}
<p style="font-size: 12px;">{"If you do not receive the email, please contact your webmaster."|translate}</p>
</div>
{/if}
</form>