Skip to content

Latest commit

 

History

History
387 lines (326 loc) · 15.8 KB

File metadata and controls

387 lines (326 loc) · 15.8 KB

Authentication System

A complete PHP account system — registration, email verification, login/logout, password reset via single-use expiring tokens, and a "remember me" cookie built on the split selector/validator pattern — with every database call through PDO prepared statements.

Overview

This project extends the Login-System into a full identity lifecycle. It demonstrates how the hard parts of authentication are made safe:

  • Registration stores a password_hash() digest and issues an email-verification token.
  • Email verification activates the account only after the user proves control of the address.
  • Login/logout reuses the hardened session flow (fixation defence + secure cookie flags).
  • Password reset uses a single-use token that is hashed at rest and expires quickly.
  • "Remember me" uses the selector/validator split: the selector looks the row up, the validator is compared with hash_equals() against a stored hash — so a database leak cannot be replayed and lookups stay constant-time.

Threat model: credential theft at rest, token theft from the database, token reuse/replay, session fixation, user enumeration, and CSRF on every state-changing form.

Architecture

Register ─▶ store user (unverified) + verify_token(hash) ─▶ email link
                                                              │
Verify   ◀── GET /verify?selector&token ──────────────────────┘  set is_verified = 1

Login ─▶ password_verify ─▶ session_regenerate_id(true) ─▶ [optional] issue remember cookie
                                                                     │
Remember cookie = "selector:validator"  ──▶  DB row: selector, validator_hash, expires
        (validator hashed at rest; compared with hash_equals)

Forgot ─▶ store reset_token(hash)+expiry ─▶ email link ─▶ Reset ─▶ password_hash + invalidate token

Source Structure

auth-system/
├── public/
│   ├── register.php
│   ├── verify.php
│   ├── login.php
│   ├── logout.php
│   ├── forgot.php
│   └── reset.php
├── src/
│   ├── Database.php     # PDO factory (prepared statements, no emulation)
│   ├── Csrf.php         # random_bytes token + hash_equals
│   ├── Mailer.php       # verification / reset email
│   ├── Auth.php         # register / login / verify
│   ├── PasswordReset.php
│   └── RememberMe.php   # selector/validator cookie
├── config/
│   └── config.php
└── sql/
    └── schema.sql

Database Schema

CREATE TABLE users (
    id             INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    email          VARCHAR(190) NOT NULL UNIQUE,
    password_hash  VARCHAR(255) NOT NULL,
    is_verified    TINYINT(1)   NOT NULL DEFAULT 0,
    created_at     DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- Email verification and password reset both use single-use, hashed, expiring tokens.
CREATE TABLE auth_tokens (
    id          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    user_id     INT UNSIGNED NOT NULL,
    purpose     ENUM('verify','reset') NOT NULL,
    token_hash  CHAR(64)  NOT NULL,        -- SHA-256 of the raw token
    expires_at  DATETIME  NOT NULL,
    used_at     DATETIME  NULL,
    INDEX idx_user_purpose (user_id, purpose),
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- "Remember me": selector locates the row, validator_hash is verified.
CREATE TABLE remember_tokens (
    id             BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    user_id        INT UNSIGNED NOT NULL,
    selector       CHAR(24)  NOT NULL UNIQUE,   -- hex, looked up directly
    validator_hash CHAR(64)  NOT NULL,          -- SHA-256 of the validator
    expires_at     DATETIME  NOT NULL,
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Code Examples

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,
            ]);
        }
        return self::$pdo;
    }
}

src/Auth.php — registration + verification

<?php
final class Auth
{
    public function __construct(private PDO $db, private Mailer $mailer) {}

    public function register(string $email, string $password): void
    {
        $email = filter_var($email, FILTER_VALIDATE_EMAIL)
            ?: throw new InvalidArgumentException('Invalid email.');
        if (strlen($password) < 12) {
            throw new InvalidArgumentException('Password too short.');
        }

        $hash = password_hash($password, PASSWORD_DEFAULT);

        $stmt = $this->db->prepare(
            'INSERT INTO users (email, password_hash) VALUES (:e, :h)'
        );
        // A duplicate email throws a unique-constraint error; catch it in the
        // caller and show a GENERIC "check your inbox" message either way to
        // avoid revealing which addresses are registered.
        $stmt->execute([':e' => $email, ':h' => $hash]);
        $userId = (int) $this->db->lastInsertId();

        $raw = $this->issueToken($userId, 'verify', 60 * 24);   // 24h
        $this->mailer->sendVerification($email, $userId, $raw);
    }

    /** Returns the raw token to email; only its SHA-256 hash is stored. */
    private function issueToken(int $userId, string $purpose, int $minutes): string
    {
        $raw = bin2hex(random_bytes(32));
        $stmt = $this->db->prepare(
            'INSERT INTO auth_tokens (user_id, purpose, token_hash, expires_at)
             VALUES (:u, :p, :h, (NOW() + INTERVAL :m MINUTE))'
        );
        $stmt->bindValue(':u', $userId, PDO::PARAM_INT);
        $stmt->bindValue(':p', $purpose);
        $stmt->bindValue(':h', hash('sha256', $raw));
        $stmt->bindValue(':m', $minutes, PDO::PARAM_INT);
        $stmt->execute();
        return $raw;
    }

    public function verify(int $userId, string $rawToken): bool
    {
        $stmt = $this->db->prepare(
            "SELECT id FROM auth_tokens
             WHERE user_id = :u AND purpose = 'verify' AND used_at IS NULL
               AND expires_at > NOW() AND token_hash = :h
             LIMIT 1"
        );
        $stmt->execute([':u' => $userId, ':h' => hash('sha256', $rawToken)]);
        $tokenId = $stmt->fetchColumn();
        if ($tokenId === false) {
            return false;
        }

        $this->db->beginTransaction();
        $this->db->prepare('UPDATE users SET is_verified = 1 WHERE id = :u')
                 ->execute([':u' => $userId]);
        $this->db->prepare('UPDATE auth_tokens SET used_at = NOW() WHERE id = :t')
                 ->execute([':t' => $tokenId]);   // single-use
        $this->db->commit();
        return true;
    }
}

[!warning] Storing reset/verify tokens in plaintext A naive design saves the raw token in the database and never expires it, so anyone who reads the table (SQLi, backup leak) can hijack every pending account. Store only hash('sha256', $raw), set a short expires_at, mark used_at on first use, and email the raw value once.

src/PasswordReset.php

<?php
final class PasswordReset
{
    public function __construct(private PDO $db, private Mailer $mailer) {}

    public function request(string $email): void
    {
        $stmt = $this->db->prepare('SELECT id FROM users WHERE email = :e LIMIT 1');
        $stmt->execute([':e' => $email]);
        $userId = $stmt->fetchColumn();

        // Silently succeed for unknown addresses: no user enumeration.
        if ($userId === false) {
            return;
        }

        $raw  = bin2hex(random_bytes(32));
        $stmt = $this->db->prepare(
            "INSERT INTO auth_tokens (user_id, purpose, token_hash, expires_at)
             VALUES (:u, 'reset', :h, (NOW() + INTERVAL 30 MINUTE))"
        );
        $stmt->execute([':u' => $userId, ':h' => hash('sha256', $raw)]);
        $this->mailer->sendReset($email, (int) $userId, $raw);
    }

    public function reset(int $userId, string $rawToken, string $newPassword): bool
    {
        if (strlen($newPassword) < 12) {
            return false;
        }
        $stmt = $this->db->prepare(
            "SELECT id FROM auth_tokens
             WHERE user_id = :u AND purpose = 'reset' AND used_at IS NULL
               AND expires_at > NOW() AND token_hash = :h
             LIMIT 1"
        );
        $stmt->execute([':u' => $userId, ':h' => hash('sha256', $rawToken)]);
        $tokenId = $stmt->fetchColumn();
        if ($tokenId === false) {
            return false;
        }

        $hash = password_hash($newPassword, PASSWORD_DEFAULT);
        $this->db->beginTransaction();
        $this->db->prepare('UPDATE users SET password_hash = :h WHERE id = :u')
                 ->execute([':h' => $hash, ':u' => $userId]);
        $this->db->prepare('UPDATE auth_tokens SET used_at = NOW() WHERE id = :t')
                 ->execute([':t' => $tokenId]);
        // Invalidate all remember-me sessions after a password change.
        $this->db->prepare('DELETE FROM remember_tokens WHERE user_id = :u')
                 ->execute([':u' => $userId]);
        $this->db->commit();
        return true;
    }
}

src/RememberMe.php — selector/validator pattern

<?php
final class RememberMe
{
    private const COOKIE = 'remember';
    private const DAYS   = 30;

    public function __construct(private PDO $db) {}

    public function issue(int $userId): void
    {
        $selector  = bin2hex(random_bytes(12));   // 24 hex chars, stored plainly
        $validator = bin2hex(random_bytes(32));   // secret; only its hash is stored

        $stmt = $this->db->prepare(
            'INSERT INTO remember_tokens (user_id, selector, validator_hash, expires_at)
             VALUES (:u, :s, :v, (NOW() + INTERVAL :d DAY))'
        );
        $stmt->bindValue(':u', $userId, PDO::PARAM_INT);
        $stmt->bindValue(':s', $selector);
        $stmt->bindValue(':v', hash('sha256', $validator));
        $stmt->bindValue(':d', self::DAYS, PDO::PARAM_INT);
        $stmt->execute();

        setcookie(self::COOKIE, $selector . ':' . $validator, [
            'expires'  => time() + self::DAYS * 86400,
            'path'     => '/',
            'secure'   => true,
            'httponly' => true,
            'samesite' => 'Strict',
        ]);
    }

    /** Returns a user id if the cookie authenticates, else null. */
    public function login(): ?int
    {
        $raw = $_COOKIE[self::COOKIE] ?? '';
        if (!str_contains($raw, ':')) {
            return null;
        }
        [$selector, $validator] = explode(':', $raw, 2);

        $stmt = $this->db->prepare(
            'SELECT user_id, validator_hash FROM remember_tokens
             WHERE selector = :s AND expires_at > NOW() LIMIT 1'
        );
        $stmt->execute([':s' => $selector]);
        $row = $stmt->fetch();
        if (!$row) {
            return null;
        }

        // Constant-time compare of the hashed validator: a DB leak of
        // validator_hash cannot be reversed into a usable cookie.
        if (!hash_equals($row['validator_hash'], hash('sha256', $validator))) {
            return null;
        }
        return (int) $row['user_id'];
    }

    public function forget(): void
    {
        $raw = $_COOKIE[self::COOKIE] ?? '';
        if (str_contains($raw, ':')) {
            [$selector] = explode(':', $raw, 2);
            $this->db->prepare('DELETE FROM remember_tokens WHERE selector = :s')
                     ->execute([':s' => $selector]);
        }
        setcookie(self::COOKIE, '', time() - 3600, '/');
    }
}

[!warning] "Remember me" as a raw password/id cookie Storing the user id or password hash directly in a persistent cookie means any theft is a permanent login, and a plain-token lookup with === leaks timing. The selector/validator split lets the lookup use the (public) selector while the (secret) validator is verified with hash_equals() against a stored hash — theft is revocable per device and a DB dump is not replayable.

public/verify.php — wiring the token link

<?php
require __DIR__ . '/../src/Database.php';
require __DIR__ . '/../src/Mailer.php';
require __DIR__ . '/../src/Auth.php';

$userId = (int) filter_input(INPUT_GET, 'uid', FILTER_VALIDATE_INT);
$token  = (string) filter_input(INPUT_GET, 'token');

$auth = new Auth(Database::get(), new Mailer());
$ok   = $userId > 0 && $token !== '' && $auth->verify($userId, $token);

$msg = $ok ? 'Your email is verified. You can now sign in.'
           : 'This verification link is invalid or has expired.';
echo htmlspecialchars($msg, ENT_QUOTES, 'UTF-8');

Security Considerations

Risk Mitigation in this project
SQL injection Every statement is a PDO prepared statement with bound params; emulation disabled.
Password disclosure password_hash() at rest, password_verify() to check; reset re-hashes.
Token theft from DB Verify/reset tokens stored as SHA-256 hashes; remember-me validator hashed too.
Token replay / reuse Single-use (used_at) verify/reset tokens with short expires_at.
Session fixation session_regenerate_id(true) on login (see Login-System).
Persistent-cookie theft Selector/validator split, hash_equals(), revocable per device, wiped on password change.
User enumeration Registration and password-reset return the same message for known/unknown emails.
CSRF random_bytes() token + hash_equals() on every POST form; SameSite cookies.
XSS All output escaped with htmlspecialchars(..., ENT_QUOTES, 'UTF-8').

See OWASP Forgot Password and Authentication cheat sheets.

Improvements

  • Add TOTP/WebAuthn multi-factor and step-up auth for sensitive actions.
  • Rate-limit registration, verification resends, and reset requests.
  • Rehash passwords on login with password_needs_rehash().
  • Send email asynchronously via a queue; sign links so tampering is detectable.
  • Notify the user by email whenever the password or email is changed.

References

Related