Adds forgotten password reset functionality
parent
11fa58bd6e
commit
0d4251b321
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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' ) {
|
||||||
|
|
|
@ -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 -->
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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,
|
||||||
|
|
Loading…
Reference in New Issue