Skip to content

Latest commit

 

History

History
318 lines (265 loc) · 13.3 KB

File metadata and controls

318 lines (265 loc) · 13.3 KB

Login System

A session-based PHP login that authenticates users securely — hashed passwords, session fixation defence, hardened cookies, brute-force lockout, CSRF protection, and user-enumeration-resistant error messages.

Overview

This project demonstrates a minimal but production-shaped login flow. A user submits a username and password; the server verifies the credential against a password_hash() digest, and on success rotates the session ID and stores an authenticated identity.

Threat model and the concepts it demonstrates:

  • Credential theft at rest — passwords stored as bcrypt/Argon2 digests via password_hash(), never plaintext or md5/sha1.
  • Session fixationsession_regenerate_id(true) on privilege change.
  • Session hijackingSecure, HttpOnly, SameSite cookie flags.
  • Brute force / credential stuffing — throttling and lockout keyed by both username and client IP.
  • User enumeration — one generic failure message and constant-ish work for both "no such user" and "wrong password".
  • CSRF — a per-session token bound to the login form and checked with hash_equals().

Learning Objectives

By the end of this project you should be able to:

  • Store and verify credentials with password_hash() / password_verify() instead of reversible or fast hashes.
  • Harden a PHP session with Secure, HttpOnly, and SameSite cookie flags set before session_start().
  • Prevent session fixation by rotating the session ID with session_regenerate_id(true) on authentication.
  • Implement a per-session CSRF token generated with random_bytes() and verified in constant time with hash_equals().
  • Throttle brute-force and credential-stuffing attempts keyed by both client IP and username.
  • Write login flows that resist user enumeration through generic errors and timing-equalised verification.

Architecture

Browser ──POST /login (username, password, csrf_token)──▶ public/login.php
                                                            │
                                    ┌───────────────────────┼───────────────────────┐
                                    ▼                       ▼                       ▼
                              CSRF verify            LoginThrottle             Database (PDO)
                            (hash_equals)          (attempts by ip+user)     SELECT user by name
                                    │                       │                       │
                                    └───────────────────────┴───────────────────────┘
                                                            ▼
                                         password_verify() ── ok ──▶ session_regenerate_id(true)
                                                            │                    store $_SESSION['uid']
                                                            └── fail ─▶ record attempt + generic error

Source Structure

login-system/
├── public/              # web root (only this directory is served)
│   ├── login.php        # form + POST handler
│   └── logout.php
├── src/
│   ├── Database.php      # PDO connection factory
│   ├── Csrf.php          # token issue + verify
│   └── LoginThrottle.php # brute-force lockout
├── config/
│   └── config.php        # DSN + credentials (outside public/)
└── sql/
    └── schema.sql

Database Schema

CREATE TABLE users (
    id            INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    username      VARCHAR(64)  NOT NULL UNIQUE,
    password_hash VARCHAR(255) NOT NULL,          -- bcrypt/Argon2 output from password_hash()
    created_at    DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE login_attempts (
    id          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    ip_address  VARBINARY(16) NOT NULL,           -- inet_pton() form (v4 or v6)
    username    VARCHAR(64)   NOT NULL,
    attempted_at DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_ip_time   (ip_address, attempted_at),
    INDEX idx_user_time (username, attempted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Code Examples

config/config.php

<?php
// Kept OUTSIDE the web root so it is never served as text.
return [
    'dsn'  => 'mysql:host=127.0.0.1;dbname=app;charset=utf8mb4',
    'user' => 'app_user',
    'pass' => getenv('DB_PASS') ?: '',   // prefer env vars over literals
];

src/Database.php

<?php
final class Database
{
    private static ?PDO $pdo = null;

    public static function get(): PDO
    {
        if (self::$pdo === null) {
            $cfg = require __DIR__ . '/../config/config.php';
            self::$pdo = new PDO($cfg['dsn'], $cfg['user'], $cfg['pass'], [
                PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
                PDO::ATTR_EMULATE_PREPARES   => false,   // real prepared statements
            ]);
        }
        return self::$pdo;
    }
}

src/Csrf.php

<?php
final class Csrf
{
    public static function token(): string
    {
        if (empty($_SESSION['csrf'])) {
            $_SESSION['csrf'] = bin2hex(random_bytes(32));
        }
        return $_SESSION['csrf'];
    }

    public static function check(?string $sent): bool
    {
        // hash_equals is constant-time: defeats timing side-channels.
        return is_string($sent)
            && !empty($_SESSION['csrf'])
            && hash_equals($_SESSION['csrf'], $sent);
    }
}

[!warning] Insecure vs secure token check The naive if ($_POST['csrf'] === $_SESSION['csrf']) leaks timing information and often skips the "token exists" check, so an empty/absent token can pass. Always generate the token with random_bytes() (not rand()/uniqid()) and compare with hash_equals().

src/LoginThrottle.php

<?php
final class LoginThrottle
{
    private const MAX_ATTEMPTS = 5;      // per window
    private const WINDOW_MIN   = 15;     // minutes

    public static function lockedOut(PDO $db, string $ip, string $user): bool
    {
        $stmt = $db->prepare(
            'SELECT COUNT(*) FROM login_attempts
             WHERE (ip_address = :ip OR username = :user)
               AND attempted_at > (NOW() - INTERVAL :mins MINUTE)'
        );
        $stmt->bindValue(':ip',   inet_pton($ip), PDO::PARAM_STR);
        $stmt->bindValue(':user', $user,          PDO::PARAM_STR);
        $stmt->bindValue(':mins', self::WINDOW_MIN, PDO::PARAM_INT);
        $stmt->execute();
        return (int) $stmt->fetchColumn() >= self::MAX_ATTEMPTS;
    }

    public static function record(PDO $db, string $ip, string $user): void
    {
        $stmt = $db->prepare(
            'INSERT INTO login_attempts (ip_address, username) VALUES (:ip, :user)'
        );
        $stmt->execute([':ip' => inet_pton($ip), ':user' => $user]);
    }

    public static function clear(PDO $db, string $ip, string $user): void
    {
        $stmt = $db->prepare(
            'DELETE FROM login_attempts WHERE ip_address = :ip OR username = :user'
        );
        $stmt->execute([':ip' => inet_pton($ip), ':user' => $user]);
    }
}

public/login.php — secure session bootstrap + handler

<?php
// Harden the session cookie BEFORE session_start().
session_set_cookie_params([
    'lifetime' => 0,
    'path'     => '/',
    'secure'   => true,      // only sent over HTTPS
    'httponly' => true,      // unreadable to JavaScript (mitigates XSS cookie theft)
    'samesite' => 'Strict',  // not sent on cross-site requests (CSRF defence-in-depth)
]);
session_start();

require __DIR__ . '/../src/Database.php';
require __DIR__ . '/../src/Csrf.php';
require __DIR__ . '/../src/LoginThrottle.php';

$db    = Database::get();
$ip    = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
$error = '';

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $username = trim((string) filter_input(INPUT_POST, 'username'));
    $password = (string) ($_POST['password'] ?? '');

    if (!Csrf::check($_POST['csrf'] ?? null)) {
        $error = 'Your session expired. Please try again.';
    } elseif (LoginThrottle::lockedOut($db, $ip, $username)) {
        $error = 'Too many attempts. Try again later.';
    } else {
        $stmt = $db->prepare('SELECT id, password_hash FROM users WHERE username = :u LIMIT 1');
        $stmt->execute([':u' => $username]);
        $user = $stmt->fetch();

        // Verify even when the user does not exist, against a dummy hash, so the
        // response time does not reveal whether the username is valid.
        $hash = $user['password_hash']
            ?? '$2y$12$usesomesillystringforsalt0000000000000000000000000000000';

        if ($user && password_verify($password, $hash)) {
            LoginThrottle::clear($db, $ip, $username);
            session_regenerate_id(true);          // defeat session fixation
            $_SESSION['uid'] = (int) $user['id'];
            unset($_SESSION['csrf']);             // rotate CSRF secret post-auth
            header('Location: /dashboard.php');
            exit;
        }

        LoginThrottle::record($db, $ip, $username);
        $error = 'Invalid username or password.';  // generic: no user enumeration
    }
}
?>

public/login.php — the form

<!doctype html>
<meta charset="utf-8">
<title>Sign in</title>
<?php if ($error !== ''): ?>
  <p class="error"><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></p>
<?php endif; ?>
<form method="post" action="/login.php" autocomplete="on">
  <input type="hidden" name="csrf"
         value="<?= htmlspecialchars(Csrf::token(), ENT_QUOTES, 'UTF-8') ?>">
  <label>Username <input name="username" required></label>
  <label>Password <input type="password" name="password" required></label>
  <button type="submit">Sign in</button>
</form>

public/logout.php

<?php
session_start();
$_SESSION = [];
if (ini_get('session.use_cookies')) {
    $p = session_get_cookie_params();
    setcookie(session_name(), '', time() - 42000,
        $p['path'], $p['domain'], $p['secure'], $p['httponly']);
}
session_destroy();
header('Location: /login.php');
exit;

[!warning] Enumeration through error messages Distinct messages — "user not found" vs "wrong password" — let an attacker enumerate valid usernames, and short-circuiting password_verify() when the user is missing creates a timing oracle. The secure handler returns a single message and runs password_verify() against a dummy hash for unknown users so both paths cost roughly the same.

Security Considerations

Risk Mitigation in this project
SQL injection Every query uses PDO prepared statements with bound parameters; PDO::ATTR_EMULATE_PREPARES => false.
Password disclosure password_hash($pw, PASSWORD_DEFAULT) at rest; password_verify() to check. Never md5/sha1.
Session fixation session_regenerate_id(true) immediately after successful auth.
Session hijacking / XSS cookie theft Secure + HttpOnly + SameSite=Strict cookie flags.
Brute force / credential stuffing login_attempts throttle keyed by IP and username, 5 tries / 15 min.
User enumeration Single generic error; dummy-hash verify equalises timing.
CSRF random_bytes() token per session, hash_equals() verify, SameSite cookie.
Reflected XSS All dynamic output escaped with htmlspecialchars(..., ENT_QUOTES, 'UTF-8').

See OWASP Authentication and Session Management cheat sheets.

Improvements

  • Add multi-factor authentication (TOTP) as a second step after password verify.
  • Rehash on login when password_needs_rehash() reports an outdated cost/algorithm.
  • Move throttling to a fast store (Redis) and add an exponential back-off delay.
  • Enforce HTTPS with HSTS and add a CAPTCHA after repeated failures.
  • Log authentication events for detection/alerting without recording secrets.

References

Related