Compare commits
3 Commits
71b0448004
...
f27f3fe62f
Author | SHA1 | Date |
---|---|---|
|
f27f3fe62f | |
|
0d4251b321 | |
|
11fa58bd6e |
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -142,6 +142,8 @@ class User {
|
|||
// Login successful
|
||||
$_SESSION['user_id'] = $user['id'];
|
||||
$_SESSION['username'] = $user['username'];
|
||||
$_SESSION['CREATED'] = time();
|
||||
$_SESSION['LAST_ACTIVITY'] = time();
|
||||
return [
|
||||
'status' => 'success',
|
||||
'user_id' => $user['id'],
|
||||
|
@ -151,10 +153,7 @@ class User {
|
|||
|
||||
// Get remaining attempts AFTER this failed attempt
|
||||
$remainingAttempts = $this->rateLimiter->getRemainingAttempts($username, $ipAddress);
|
||||
return [
|
||||
'status' => 'failed',
|
||||
'message' => "Invalid credentials. {$remainingAttempts} attempts remaining."
|
||||
];
|
||||
throw new Exception("Invalid credentials. {$remainingAttempts} attempts remaining.");
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -10,6 +10,8 @@ return [
|
|||
'domain' => 'localhost',
|
||||
// subfolder for the web app, if any
|
||||
'folder' => '/jilo-web/',
|
||||
// site name used in emails and in the inteerface
|
||||
'site_name' => 'Jilo Web',
|
||||
// set to false to disable new registrations
|
||||
'registration_enabled' => '1',
|
||||
// will be displayed on login screen
|
||||
|
|
|
@ -19,11 +19,11 @@ unset($error);
|
|||
|
||||
try {
|
||||
// connect to database
|
||||
$dbWeb = connectDB($config)['db'];
|
||||
$db = connectDB($config)['db'];
|
||||
|
||||
// Initialize RateLimiter
|
||||
require_once '../app/classes/ratelimiter.php';
|
||||
$rateLimiter = new RateLimiter($dbWeb);
|
||||
$rateLimiter = new RateLimiter($db);
|
||||
|
||||
// Get user IP
|
||||
$user_IP = getUserIP();
|
||||
|
@ -38,7 +38,7 @@ try {
|
|||
$rememberMe = isset($_SESSION['2fa_pending_remember']);
|
||||
|
||||
require_once '../app/classes/twoFactorAuth.php';
|
||||
$twoFactorAuth = new TwoFactorAuthentication($dbWeb);
|
||||
$twoFactorAuth = new TwoFactorAuthentication($db);
|
||||
|
||||
if ($twoFactorAuth->verify($userId, $code)) {
|
||||
// Complete login
|
||||
|
@ -63,6 +63,122 @@ try {
|
|||
// Load the 2FA verification template
|
||||
include '../app/templates/credentials-2fa-verify.php';
|
||||
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' ) {
|
||||
|
|
|
@ -2,17 +2,17 @@
|
|||
<div class="card text-center w-50 mx-auto">
|
||||
<h2 class="card-header">Login</h2>
|
||||
<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">
|
||||
<?php include 'csrf_token.php'; ?>
|
||||
<div class="form-group mb-3">
|
||||
<input type="text" class="form-control w-50 mx-auto" name="username" placeholder="Username"
|
||||
<input type="text" class="form-control w-50 mx-auto" name="username" placeholder="Username"
|
||||
pattern="[A-Za-z0-9_\-]{3,20}" title="3-20 characters, letters, numbers, - and _"
|
||||
required autofocus />
|
||||
</div>
|
||||
<div class="form-group mb-3">
|
||||
<input type="password" class="form-control w-50 mx-auto" name="password" placeholder="Password"
|
||||
pattern=".{2,}" title="Eight or more characters"
|
||||
<input type="password" class="form-control w-50 mx-auto" name="password" placeholder="Password"
|
||||
pattern=".{5,}" title="Eight or more characters"
|
||||
required />
|
||||
</div>
|
||||
<div class="form-group mb-3">
|
||||
|
@ -23,6 +23,9 @@
|
|||
</div>
|
||||
<input type="submit" class="btn btn-primary" value="Login" />
|
||||
</form>
|
||||
<div class="mt-3">
|
||||
<a href="?page=login&action=forgot">forgot password?</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- /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),
|
||||
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 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
|
|
|
@ -24,6 +24,14 @@ class SessionMiddlewareTest extends TestCase
|
|||
'domain' => 'localhost'
|
||||
];
|
||||
$this->app_root = 'https://localhost/app';
|
||||
|
||||
// Initialize session variables
|
||||
$_SESSION = [
|
||||
'user_id' => 1,
|
||||
'username' => 'testuser',
|
||||
'CREATED' => time(),
|
||||
'LAST_ACTIVITY' => time()
|
||||
];
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
|
@ -33,78 +41,65 @@ class SessionMiddlewareTest extends TestCase
|
|||
|
||||
public function testSessionStart()
|
||||
{
|
||||
$_SESSION = ['USER_ID' => 1];
|
||||
$result = applySessionMiddleware($this->config, $this->app_root);
|
||||
|
||||
$this->assertTrue($result);
|
||||
$this->assertArrayHasKey('LAST_ACTIVITY', $_SESSION);
|
||||
$this->assertArrayHasKey('CREATED', $_SESSION);
|
||||
$this->assertArrayHasKey('USER_ID', $_SESSION);
|
||||
$this->assertEquals(1, $_SESSION['USER_ID']);
|
||||
$this->assertArrayHasKey('user_id', $_SESSION);
|
||||
$this->assertEquals(1, $_SESSION['user_id']);
|
||||
}
|
||||
|
||||
public function testSessionTimeout()
|
||||
{
|
||||
$_SESSION = [
|
||||
'USER_ID' => 1,
|
||||
'LAST_ACTIVITY' => time() - 1500 // 25 minutes ago
|
||||
];
|
||||
$_SESSION['LAST_ACTIVITY'] = time() - 1500; // 25 minutes ago
|
||||
|
||||
$result = applySessionMiddleware($this->config, $this->app_root);
|
||||
|
||||
$this->assertFalse($result);
|
||||
$this->assertArrayNotHasKey('USER_ID', $_SESSION, 'Session should be cleared after timeout');
|
||||
$this->assertArrayNotHasKey('user_id', $_SESSION, 'Session should be cleared after timeout');
|
||||
}
|
||||
|
||||
public function testSessionRegeneration()
|
||||
{
|
||||
$now = time();
|
||||
$_SESSION = [
|
||||
'USER_ID' => 1,
|
||||
'CREATED' => $now - 1900 // 31+ minutes ago
|
||||
];
|
||||
$_SESSION['CREATED'] = $now - 1900; // 31+ minutes ago
|
||||
|
||||
$result = applySessionMiddleware($this->config, $this->app_root);
|
||||
|
||||
$this->assertTrue($result);
|
||||
$this->assertEquals(1, $_SESSION['USER_ID']);
|
||||
$this->assertEquals(1, $_SESSION['user_id']);
|
||||
$this->assertGreaterThanOrEqual($now - 1900, $_SESSION['CREATED']);
|
||||
$this->assertLessThanOrEqual($now + 10, $_SESSION['CREATED']);
|
||||
}
|
||||
|
||||
public function testRememberMe()
|
||||
{
|
||||
$_SESSION = [
|
||||
'USER_ID' => 1,
|
||||
'REMEMBER_ME' => true,
|
||||
'LAST_ACTIVITY' => time() - 86500 // More than 24 hours ago
|
||||
];
|
||||
$_SESSION['REMEMBER_ME'] = true;
|
||||
$_SESSION['LAST_ACTIVITY'] = time() - 86500; // More than 24 hours ago
|
||||
|
||||
$result = applySessionMiddleware($this->config, $this->app_root);
|
||||
|
||||
$this->assertTrue($result);
|
||||
$this->assertArrayHasKey('USER_ID', $_SESSION);
|
||||
$this->assertArrayHasKey('user_id', $_SESSION);
|
||||
}
|
||||
|
||||
public function testNoUserSession()
|
||||
{
|
||||
$_SESSION = [];
|
||||
unset($_SESSION['user_id']);
|
||||
$result = applySessionMiddleware($this->config, $this->app_root);
|
||||
|
||||
$this->assertFalse($result);
|
||||
$this->assertArrayNotHasKey('USER_ID', $_SESSION);
|
||||
$this->assertArrayNotHasKey('user_id', $_SESSION);
|
||||
}
|
||||
|
||||
public function testSessionHeaders()
|
||||
{
|
||||
$_SESSION = [
|
||||
'USER_ID' => 1,
|
||||
'LAST_ACTIVITY' => time() - 1500 // 25 minutes ago
|
||||
];
|
||||
$_SESSION['LAST_ACTIVITY'] = time() - 1500; // 25 minutes ago
|
||||
|
||||
$result = applySessionMiddleware($this->config, $this->app_root);
|
||||
|
||||
$this->assertFalse($result);
|
||||
$this->assertArrayNotHasKey('USER_ID', $_SESSION, 'Session should be cleared after timeout');
|
||||
$this->assertArrayNotHasKey('user_id', $_SESSION, 'Session should be cleared after timeout');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -44,6 +44,19 @@ class UserTest extends TestCase
|
|||
)
|
||||
");
|
||||
|
||||
// Create user_2fa table for two-factor authentication
|
||||
$this->db->getConnection()->exec("
|
||||
CREATE TABLE user_2fa (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
secret_key TEXT NOT NULL,
|
||||
backup_codes TEXT,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
");
|
||||
|
||||
// Create tables for rate limiter
|
||||
$this->db->getConnection()->exec("
|
||||
CREATE TABLE login_attempts (
|
||||
|
@ -116,7 +129,13 @@ class UserTest extends TestCase
|
|||
// Test successful login
|
||||
try {
|
||||
$result = $this->user->login('testuser', $password);
|
||||
$this->assertTrue($result);
|
||||
$this->assertIsArray($result);
|
||||
$this->assertEquals('success', $result['status']);
|
||||
$this->assertArrayHasKey('user_id', $result);
|
||||
$this->assertArrayHasKey('username', $result);
|
||||
$this->assertArrayHasKey('user_id', $_SESSION);
|
||||
$this->assertArrayHasKey('CREATED', $_SESSION);
|
||||
$this->assertArrayHasKey('LAST_ACTIVITY', $_SESSION);
|
||||
} catch (Exception $e) {
|
||||
$this->fail('Login should not throw an exception for valid credentials: ' . $e->getMessage());
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue