Pop PHP
Database

Auth Records

Pop\Db\Record\Auth extends Record\Encoded and turns a credentials table into a complete login flow — password verification, active and verified account gating, failed-attempt lockout that expires on its own, and optional multi-factor codes that can be switched on or off per account. It's new in v7; v6 had no equivalent, and it replaces the now defunct Pop\Auth\Table adapter in pop-auth.

Setting up the Table Class#

Because it sits on Record\Encoded, the Users class moves up one more step and keeps what it declared — $jsonFields and $hashOptions still apply. $hashFields drops out: the constructor adds $passwordField to it whether the subclass declares it or not, so a credentials class cannot forget to hash its password column.

PHPapp/src/Table/Users.php
<?php

namespace App\Table;

use Pop\Db\Record\Auth;

class Users extends Auth
{

    protected array $jsonFields  = ['preferences'];
    protected array $hashOptions = ['cost' => 12];

}

An empty class Users extends Auth {} is a working auth class too — every column the class reads has a default name, and the table carries all of them.

Column Type Read when
username varchar every authenticate() call
password varchar, wide enough for the hash — 255 is the usual choice every authenticate() call
attempts int, nullable, default 0 every failed attempt
active int or boolean, default truthy every authenticate(), authenticateMfa() and generateMfaCode() call
verified int or boolean, default truthy same as active
last_attempt int, nullable — a Unix timestamp stamped on a failed guess, read to expire a lockout
mfa int or boolean, nullable every authenticate() call, to decide whether MFA applies
mfa_code varchar, nullable every success on the default $mfa = true path
mfa_timestamp int, nullable every success on the default $mfa = true path

Every other column is yours. authenticate() loads the whole row, so email and anything else the table holds are on the record it hands back.

Give the table every column the auth flow reads — attempts and last_attempt, and the MFA columns if you enable it — since the failure path is the one that reads them. Give active and verified a truthy default so a newly inserted row can log in, or switch either check off as described under Active and Verified Accounts.

Overriding Column Names and Limits#

Every one of those names, the attempt limit and the lockout window is a property override.

Property Default Names
$usernameField 'username' the column the attempted username is matched against
$passwordField 'password' the hashed password column
$attemptsField 'attempts' an integer column counting consecutive failures
$activeField 'active' a truthy column marking the account usable — null to skip the check
$verifiedField 'verified' a truthy column marking the account confirmed — null to skip the check
$lastAttemptField 'last_attempt' a Unix-timestamp column anchoring the lockout clock — null to skip the tracking
$mfaField 'mfa' a column deciding MFA per account — null to skip the override
$attemptsLimit 3 the failure count at which the account locks — 0 to skip attempt enforcement
$lockoutExpiration 900 seconds a lockout holds before it clears itself — 0 to hold it until reset
$mfaConfig length 6, expires 300, alphanumeric false, mfa_code_field 'mfa_code', mfa_timestamp_field 'mfa_timestamp' the MFA code's shape, lifetime and columns

A table with different column names needs no more than those overrides.

PHPapp/src/Table/Accounts.php
<?php

namespace App\Table;

use Pop\Db\Record\Auth;

class Accounts extends Auth
{

    protected string $usernameField = 'email';
    protected int    $attemptsLimit = 5;
    protected array  $mfaConfig     = [
        'length'              => 8,
        'expires'             => 600,
        'alphanumeric'        => true,
        'mfa_code_field'      => 'otp',
        'mfa_timestamp_field' => 'otp_expires'
    ];

}

The three settings a running application is most likely to vary — the attempt limit, the lockout window and the MFA config — are also readable and settable on a loaded instance. Each setter is fluent.

PHP
use App\Table\Users;

$user = Users::findOne(['username' => $username]);

$user->setAttemptsLimit(5)
     ->setLockoutExpiration(1800)
     ->setMfaConfig(['length' => 8, 'alphanumeric' => true]);

setMfaConfig() merges, so the call above changes length and alphanumeric and leaves the other three keys at their defaults. It keeps the five keys the class reads, so a key outside that set is dropped rather than stored. Read any of them back with getAttemptsLimit(), getLockoutExpiration() and getMfaConfig(), and ask whether a limit is in force with hasAttemptsLimit() and hasLockoutExpiration().

authenticate() takes the attempt limit as its fifth argument for a path that wants a stricter one — an admin login, say. It calls setAttemptsLimit() internally, so the value stays on the instance for later calls as well.

PHP
$user->authenticate($username, $attemptedPassword, false, attemptsLimit: 1);

Single-Factor Login#

authenticate() takes the attempted username and password and returns false on failure. Its third argument, $mfa, defaults to true — pass false for a single-factor login, where success is true and the instance is left loaded with the matched row.

PHP
use App\Table\Users;

$user = new Users();

if ($user->authenticate($username, $attemptedPassword, false)) {
    echo 'Welcome back, ' . $user->username;
} else {
    // log $user->getAuthFailure(); show the visitor something generic
}

Multi-Factor Login#

Leave $mfa at its default and a correct password does not finish the login. A fresh code and expiration are generated, saved to the record, and the loaded record is returned — not true — so the application can deliver the code however it likes.

PHP
use App\Table\Users;

$user   = new Users();
$result = $user->authenticate($username, $attemptedPassword);

if ($result !== false) {
    $mailer->send($result->email, $result->mfa_code);
}

The success value is an object rather than true, so compare against false explicitly. The second step is authenticateMfa(), which needs the record loaded — either the same instance, or one fetched back by username between requests.

PHP
use App\Table\Users;

$user = Users::findOne(['username' => $username]);

if ($user->authenticateMfa($attemptedCode)) {
    echo 'Welcome back, ' . $user->username;
}

Codes are compared with hash_equals(), and a successful check clears the stored code and timestamp, so a code cannot be replayed. A wrong code and an expired code both increment the same attempts column a bad password does — a locked-out account is locked out of MFA guessing too.

Per-User MFA#

The $mfa argument sets the policy for a call site. $mfaField names a column that overrides it per account, in either direction, so one login controller serves users who carry a second factor and users who do not.

PHP
use App\Table\Users;

$user   = new Users();
$result = $user->authenticate($username, $attemptedPassword);

// mfa column 0 -> $result is true, the login is finished
// mfa column 1 -> $result is the record, carrying a fresh code

The column counts only when it holds a value. A null means the account has expressed no preference and the $mfa argument stands, which is what makes the default safe on an existing table: a table with no mfa column keeps the behavior it had, and a row added before the column existed keeps whatever the call site asks for. Setting $mfaField to null turns the override off everywhere.

Because the column can turn MFA on as readily as off, an account that requires a second factor gets one even where the call site passes false:

PHP
$result = $user->authenticate($username, $attemptedPassword, false);

// mfa column 1 -> $result is still the record, and a code was still issued

Callers That Cannot Do MFA#

$mfa and the mfa column both express policy — whether an account should clear a second factor. The fourth argument, $mfaCapable, expresses something else: whether the caller can run one at all. A console command, a queue worker or a machine-to-machine endpoint has no way to prompt for a code or deliver one, so it passes false and gets a one-step answer.

PHP
use App\Table\Users;

$user   = new Users();
$result = $user->authenticate($username, $attemptedPassword, mfaCapable: false);

// $result is true for any valid account, whatever its mfa column holds
// Nothing is written to mfa_code or mfa_timestamp

Capability is settled first and wins outright. It is not a third vote alongside $mfa and the column: false skips MFA even for an account whose mfa column is 1, and no per-user setting overrules it, since an account cannot be handed a second factor by a context that has no channel for one. Leave the argument at its default of true on web paths, where the per-user override keeps deciding.

generateMfaCode() takes no capability argument, which follows from the same reasoning — a caller that vetoed MFA has no resend step to reach.

Resending an MFA Code#

generateMfaCode() is the method authenticate() uses internally to issue a code, and it's public, so a "resend code" link reissues one on an already-loaded record without asking for the password again. It's fluent, and wasMfaCodeGenerated() reports whether the most recent call — direct, or the one inside authenticate() — issued a code.

PHP
use App\Table\Users;

$user = Users::findOne(['username' => $username]);
$user->generateMfaCode();

if ($user->wasMfaCodeGenerated()) {
    $mailer->send($user->email, $user->mfa_code);
} else {
    // log $user->getAuthFailure()
}

A code is issued for a loaded record whose account is active, verified and within its attempt limit. Anything else leaves the stored code and timestamp as they were and sets the matching failure constant, so the previously delivered code stays valid while the resend reports why it declined. Holding the locked-out case back is what keeps resend from becoming an unlimited-guessing loophole: MFA verification checks the attempt limit before it compares the code, so a fresh code on a locked-out account would be unusable anyway. That account comes back through resetAttempts() or by waiting out the lockout window.

generateMfaCode() issues a code whenever those four conditions hold, whatever the mfa column says — the per-user override governs authenticate(). A resend path that also serves MFA-exempt accounts reads the column itself before offering the link.

Active and Verified Accounts#

Two columns gate a login on account state rather than credentials. $activeField and $verifiedField are checked at the top of authenticate(), authenticateMfa() and generateMfaCode(), ahead of the attempts and password checks.

PHP
use App\Table\Users;

// A row whose `active` column is 0, with the correct password
$user = new Users();
$user->authenticate($username, $attemptedPassword, false);   // false

$user->getAuthFailure();          // 'USER_NOT_ACTIVE'
$user->getAuthFailureMessage();   // 'The user is not active'

Both are hard blocks rather than guess failures, so neither touches the attempts column — repeated logins against a deactivated account leave its attempt count where it was, and a lockout stays a signal about guessing. userActive() and userVerified() read the same state directly, which is what an account-status screen wants.

Each check is independent and opt-out. Set its property to null and it always passes, whatever the row holds — for an application that has no email-verification step, that's the whole change.

PHPapp/src/Table/Members.php
<?php

namespace App\Table;

use Pop\Db\Record\Auth;

class Members extends Auth
{

    protected ?string $verifiedField = null;

}

Authentication Failures#

Every failure path sets a constant, readable through getAuthFailure(), getAuthFailureMessage() and hasAuthFailure(). A success clears it back to null. They are listed here in the order the checks run.

Constant Message Set when
Auth::USER_DOES_NOT_EXIST The user does not exist no row matches the attempted username
Auth::USER_NOT_ACTIVE The user is not active the row's $activeField column is falsy
Auth::USER_NOT_VERIFIED The user is not verified the row's $verifiedField column is falsy
Auth::ATTEMPTS_EXCEEDED The authentication attempts have been exceeded attempts stand at or above $attemptsLimit
Auth::INVALID_CREDENTIALS Invalid credentials the row exists but the password does not verify
Auth::INVALID_MFA_CODE Invalid MFA code the attempted code does not match the stored one
Auth::MFA_CODE_EXPIRED MFA code has expired the code matched, but its timestamp has passed

The order is what makes the first three cheap: they resolve before any password or code comparison happens. Log the result constant and show the person logging in a single generic message. USER_DOES_NOT_EXIST and INVALID_CREDENTIALS are for your logs.

Attempt Lockout#

Each failure against an existing row increments the attempts column. Once the count reaches $attemptsLimit, authenticate() returns ATTEMPTS_EXCEEDED even for the correct password, and keeps counting — so attempts records total failures rather than remaining tries.

PHP
use App\Table\Users;

$user = Users::findOne(['username' => $username]);

if ($user->attemptsExceeded()) {
    echo 'This account is locked.';
}

$user->resetAttempts();

Both read the currently loaded record, so a fresh unloaded instance reports false from attemptsExceeded() regardless of what the table holds. Load the row first. resetAttempts() is fluent, so it chains into whatever the unlock path does next.

A lockout clears itself. Once $lockoutExpiration seconds — 15 minutes by default — have passed since the last failed guess, attemptsExceeded() resets the attempts column and reports false, and the next correct password logs in. That covers the ordinary case of someone mistyping their password three times, with no unlock path to build.

PHP
use App\Table\Users;

$user = Users::findOne(['username' => $username]);

$user->lockoutExpired();      // true once the window has passed
$user->attemptsExceeded();    // false — and the attempts column is now back to 0

The window is measured from $lastAttemptField, which is stamped when a guess fails. A request that was already turned away as locked out leaves the stamp alone, so the clock is anchored to the last real guess and a lockout always runs out on schedule. Call lockoutExpired() when you want to read the clock on its own — attemptsExceeded() is the one that clears the lockout as it reports.

For a lockout that holds until someone clears it, set $lockoutExpiration to 0, and build the unlock path you want — an admin screen, an emailed link, or a scheduled task.

PHPapp/src/Table/Admins.php
<?php

namespace App\Table;

use Pop\Db\Record\Auth;

class Admins extends Auth
{

    protected int $lockoutExpiration = 0;

}

Setting $attemptsLimit to 0 goes the other way and turns attempt enforcement off, for a table where lockout is handled elsewhere: attemptsExceeded() stays false however high the column climbs.

A successful authenticate() also resets the attempts counter to zero. It checks the password with verify(), so the transparent rehash happens inside that check — a login against an outdated hash comes back true with the stored hash already upgraded, and authenticate() does nothing further about it.

Record\Auth handles the table side of a login. Sessions, the file- and JWT-backed adapters in Pop\Auth, and the ACL layer that decides what an authenticated user may do are covered in Authentication.

See Also#