Adds forgotten password reset functionality

main
Yasen Pramatarov 2025-04-08 12:12:14 +03:00
parent 11fa58bd6e
commit 0d4251b321
6 changed files with 377 additions and 7 deletions

View File

@ -0,0 +1,172 @@
<?php
/**
* Handles password reset functionality including token generation and validation
*/
class PasswordReset {
private $db;
private const TOKEN_LENGTH = 32;
private const TOKEN_EXPIRY = 3600; // 1 hour
public function __construct($database) {
if ($database instanceof PDO) {
$this->db = $database;
} else {
$this->db = $database->getConnection();
}
}
/**
* Creates a password reset request and sends email to user
*
* @param string $email User's email address
* @return array Status of the reset request
*/
public function requestReset($email) {
// Check if email exists
$query = $this->db->prepare("
SELECT u.id, um.email
FROM users u
JOIN users_meta um ON u.id = um.user_id
WHERE um.email = :email"
);
$query->bindParam(':email', $email);
$query->execute();
$user = $query->fetch(PDO::FETCH_ASSOC);
if (!$user) {
return ['success' => false, 'message' => 'If this email exists in our system, you will receive reset instructions.'];
}
// Generate unique token
$token = bin2hex(random_bytes(self::TOKEN_LENGTH / 2));
$expires = time() + self::TOKEN_EXPIRY;
// Store token in database
$query = $this->db->prepare("
INSERT INTO user_password_reset (user_id, token, expires)
VALUES (:user_id, :token, :expires)"
);
$query->bindParam(':user_id', $user['id']);
$query->bindParam(':token', $token);
$query->bindParam(':expires', $expires);
if (!$query->execute()) {
return ['success' => false, 'message' => 'Failed to process reset request'];
}
// We need the config for the email details
global $config;
// Prepare the reset link
$scheme = $_SERVER['REQUEST_SCHEME'];
$domain = trim($config['domain'], '/');
$folder = trim($config['folder'], '/');
$folderPath = $folder !== '' ? "/$folder" : '';
$resetLink = "{$scheme}://{$domain}{$folderPath}/index.php?page=login&action=reset&token=" . urlencode($token);
// Send email with reset link
$to = $user['email'];
$subject = "{$config['site_name']} - Password reset request";
$message = "Dear user,\n\n";
$message .= "We received a request to reset your password for your {$config['site_name']} account.\n\n";
$message .= "To set a new password, please click the link below:\n\n";
$message .= $resetLink . "\n\n";
$message .= "This link will expire in 1 hour for security reasons.\n\n";
$message .= "If you did not request this password reset, please ignore this email. Your account remains secure.\n\n";
if (!empty($config['site_name'])) {
$message .= "Best regards,\n";
$message .= "The {$config['site_name']} team\n";
if (!empty($config['site_slogan'])) {
$message .= ":: {$config['site_slogan']} ::";
}
}
$headers = [
'From' => "noreply@{$config['domain']}",
'Reply-To' => "noreply@{$config['domain']}",
'X-Mailer' => 'PHP/' . phpversion()
];
if (!mail($to, $subject, $message, $headers)) {
return ['success' => false, 'message' => 'Failed to send reset email'];
}
return ['success' => true, 'message' => 'If this email exists in our system, you will receive reset instructions.'];
}
/**
* Validates a reset token and returns associated user ID if valid
*
* @param string $token Reset token
* @return array Validation result with user ID if successful
*/
public function validateToken($token) {
$now = time();
$query = $this->db->prepare("
SELECT user_id
FROM user_password_reset
WHERE token = :token
AND expires > :now
AND used = 0
");
$query->bindParam(':token', $token);
$query->bindParam(':now', $now);
$query->execute();
$result = $query->fetch(PDO::FETCH_ASSOC);
if (!$result) {
return ['valid' => false];
}
return ['valid' => true, 'user_id' => $result['user_id']];
}
/**
* Completes the password reset process
*
* @param string $token Reset token
* @param string $newPassword New password
* @return bool Whether reset was successful
*/
public function resetPassword($token, $newPassword) {
$validation = $this->validateToken($token);
if (!$validation['valid']) {
return false;
}
// Start transaction
$this->db->beginTransaction();
try {
// Update password
$hashedPassword = password_hash($newPassword, PASSWORD_DEFAULT);
$query = $this->db->prepare(
"UPDATE user
SET password = :password
WHERE id = :user_id"
);
$query->bindParam(':password', $hashedPassword);
$query->bindParam(':user_id', $validation['user_id']);
$query->execute();
// Mark token as used
$query = $this->db->prepare(
"UPDATE user_password_reset
SET used = 1
WHERE token = :token"
);
$query->bindParam(':token', $token);
$query->execute();
$this->db->commit();
return true;
} catch (Exception $e) {
$this->db->rollBack();
return false;
}
}
}

View File

@ -19,11 +19,11 @@ unset($error);
try { try {
// connect to database // connect to database
$dbWeb = connectDB($config)['db']; $db = connectDB($config)['db'];
// Initialize RateLimiter // Initialize RateLimiter
require_once '../app/classes/ratelimiter.php'; require_once '../app/classes/ratelimiter.php';
$rateLimiter = new RateLimiter($dbWeb); $rateLimiter = new RateLimiter($db);
// Get user IP // Get user IP
$user_IP = getUserIP(); $user_IP = getUserIP();
@ -38,7 +38,7 @@ try {
$rememberMe = isset($_SESSION['2fa_pending_remember']); $rememberMe = isset($_SESSION['2fa_pending_remember']);
require_once '../app/classes/twoFactorAuth.php'; require_once '../app/classes/twoFactorAuth.php';
$twoFactorAuth = new TwoFactorAuthentication($dbWeb); $twoFactorAuth = new TwoFactorAuthentication($db);
if ($twoFactorAuth->verify($userId, $code)) { if ($twoFactorAuth->verify($userId, $code)) {
// Complete login // Complete login
@ -63,6 +63,122 @@ try {
// Load the 2FA verification template // Load the 2FA verification template
include '../app/templates/credentials-2fa-verify.php'; include '../app/templates/credentials-2fa-verify.php';
exit(); exit();
} elseif ($action === 'forgot') {
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Handle password reset request
try {
// Validate CSRF token
$security = SecurityHelper::getInstance();
if (!$security->verifyCsrfToken($_POST['csrf_token'] ?? '')) {
throw new Exception('Invalid security token. Please try again.');
}
// Apply rate limiting
if (!$rateLimiter->isIpWhitelisted($user_IP)) {
if ($rateLimiter->isIpBlacklisted($user_IP)) {
throw new Exception(Feedback::get('LOGIN', 'IP_BLACKLISTED')['message']);
}
if ($rateLimiter->tooManyAttempts('password_reset', $user_IP)) {
throw new Exception(Feedback::get('LOGIN', 'TOO_MANY_ATTEMPTS')['message']);
}
$rateLimiter->attempt('password_reset', $user_IP);
}
// Validate email
$email = filter_var($_POST['email'] ?? '', FILTER_VALIDATE_EMAIL);
if (!$email) {
throw new Exception('Please enter a valid email address.');
}
// Process reset request
require_once '../app/classes/passwordReset.php';
$resetHandler = new PasswordReset($db);
$result = $resetHandler->requestReset($email);
// Always show same message whether email exists or not for security
Feedback::flash('NOTICE', 'DEFAULT', $result['message']);
header("Location: $app_root?page=login");
exit();
} catch (Exception $e) {
Feedback::flash('ERROR', 'DEFAULT', $e->getMessage());
}
}
// Generate CSRF token
$security = SecurityHelper::getInstance();
$security->generateCsrfToken();
// Load the forgot password form
include '../app/helpers/feedback.php';
include '../app/templates/form-password-forgot.php';
exit();
} elseif ($action === 'reset' && isset($_GET['token'])) {
// Handle password reset
try {
require_once '../app/classes/passwordReset.php';
$resetHandler = new PasswordReset($db);
$token = $_GET['token'];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Validate CSRF token
$security = SecurityHelper::getInstance();
if (!$security->verifyCsrfToken($_POST['csrf_token'] ?? '')) {
throw new Exception('Invalid security token. Please try again.');
}
// Apply rate limiting
if (!$rateLimiter->isIpWhitelisted($user_IP)) {
if ($rateLimiter->tooManyAttempts('password_reset', $user_IP)) {
throw new Exception(Feedback::get('LOGIN', 'TOO_MANY_ATTEMPTS')['message']);
}
$rateLimiter->attempt('password_reset', $user_IP);
}
// Validate password
require_once '../app/classes/validator.php';
$validator = new Validator($_POST);
$rules = [
'new_password' => [
'required' => true,
'min' => 8
],
'confirm_password' => [
'required' => true,
'matches' => 'new_password'
]
];
if (!$validator->validate($rules)) {
throw new Exception($validator->getFirstError());
}
// Reset password
if ($resetHandler->resetPassword($token, $_POST['new_password'])) {
Feedback::flash('NOTICE', 'DEFAULT', 'Your password has been reset successfully. You can now log in.');
header("Location: $app_root?page=login");
exit();
}
throw new Exception('Invalid or expired reset link. Please request a new one.');
}
// Verify token is valid
$validation = $resetHandler->validateToken($token);
if (!$validation['valid']) {
throw new Exception('Invalid or expired reset link. Please request a new one.');
}
// Show reset password form
include '../app/helpers/feedback.php';
include '../app/templates/form-password-reset.php';
exit();
} catch (Exception $e) {
Feedback::flash('ERROR', 'DEFAULT', $e->getMessage());
header("Location: $app_root?page=login&action=forgot");
exit();
}
} }
if ( $_SERVER['REQUEST_METHOD'] == 'POST' && $action !== 'verify' ) { if ( $_SERVER['REQUEST_METHOD'] == 'POST' && $action !== 'verify' ) {

View File

@ -2,7 +2,7 @@
<div class="card text-center w-50 mx-auto"> <div class="card text-center w-50 mx-auto">
<h2 class="card-header">Login</h2> <h2 class="card-header">Login</h2>
<div class="card-body"> <div class="card-body">
<p class="card-text"><strong>Welcome to JILO!</strong><br />Please enter login credentials:</p> <p class="card-text"><strong>Welcome to <?= htmlspecialchars($config['site_name']); ?>!</strong><br />Please enter login credentials:</p>
<form method="POST" action="<?= htmlspecialchars($app_root) ?>?page=login"> <form method="POST" action="<?= htmlspecialchars($app_root) ?>?page=login">
<?php include 'csrf_token.php'; ?> <?php include 'csrf_token.php'; ?>
<div class="form-group mb-3"> <div class="form-group mb-3">
@ -12,7 +12,7 @@
</div> </div>
<div class="form-group mb-3"> <div class="form-group mb-3">
<input type="password" class="form-control w-50 mx-auto" name="password" placeholder="Password" <input type="password" class="form-control w-50 mx-auto" name="password" placeholder="Password"
pattern=".{2,}" title="Eight or more characters" pattern=".{5,}" title="Eight or more characters"
required /> required />
</div> </div>
<div class="form-group mb-3"> <div class="form-group mb-3">
@ -23,6 +23,9 @@
</div> </div>
<input type="submit" class="btn btn-primary" value="Login" /> <input type="submit" class="btn btn-primary" value="Login" />
</form> </form>
<div class="mt-3">
<a href="?page=login&action=forgot">forgot password?</a>
</div>
</div> </div>
</div> </div>
<!-- /login form --> <!-- /login form -->

View File

@ -0,0 +1,32 @@
<div class="container">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card mt-5">
<div class="card-body">
<h3 class="card-title mb-4">Reset password</h3>
<p>Enter your email address and we will send you<br />
instructions to reset your password.</p>
<form method="post" action="?page=login&action=forgot">
<?php include 'csrf_token.php'; ?>
<div class="form-group">
<label for="email">email address:</label>
<input type="email"
class="form-control"
id="email"
name="email"
required
autocomplete="email">
</div>
<button type="submit" class="btn btn-primary btn-block mt-4">
Send reset instructions
</button>
</form>
<div class="mt-3 text-center">
<a href="?page=login">back to login</a>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,38 @@
<div class="container">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card mt-5">
<div class="card-body">
<h3 class="card-title mb-4">Set new password</h3>
<form method="post" action="?page=login&action=reset&token=<?= htmlspecialchars(urlencode($token)) ?>">
<?php include 'csrf_token.php'; ?>
<div class="form-group">
<label for="new_password">new password:</label>
<input type="password"
class="form-control"
id="new_password"
name="new_password"
required
minlength="8"
autocomplete="new-password">
</div>
<div class="form-group mt-3">
<label for="confirm_password">confirm password:</label>
<input type="password"
class="form-control"
id="confirm_password"
name="confirm_password"
required
minlength="8"
autocomplete="new-password">
</div>
<button type="submit" class="btn btn-primary btn-block mt-4">
Set new password
</button>
</form>
</div>
</div>
</div>
</div>
</div>

View File

@ -63,6 +63,15 @@ CREATE TABLE user_2fa_temp (
PRIMARY KEY (user_id, code), PRIMARY KEY (user_id, code),
FOREIGN KEY (user_id) REFERENCES users(id) FOREIGN KEY (user_id) REFERENCES users(id)
); );
CREATE TABLE user_password_reset (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token TEXT NOT NULL UNIQUE,
expires INTEGER NOT NULL,
used INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE
);
CREATE TABLE logs ( CREATE TABLE logs (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,