Pop PHP
Database

Auth Records

Pop\Db\Record\Auth extends Record\Encoded and turns a credentials table into a complete login flow — password verification, failed-attempt lockout, and optional multi-factor codes. 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 has to carry all five.

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
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 the MFA columns if you enable it — since the failure path is the one that reads them.

Overriding Column Names and the Attempt Limit#

Every one of those names, and the attempt limit, 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
$attemptsLimit 3 the failure count at which the account locks
$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'
    ];

}

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.

Authentication Failures#

Every failure path sets one of five constants, readable through getAuthFailure(), getAuthFailureMessage() and hasAuthFailure(). A success clears it back to null.

Constant Message Set when
Auth::USER_DOES_NOT_EXIST The user does not exist no row matches the attempted username
Auth::INVALID_CREDENTIALS Invalid credentials the row exists but the password does not verify
Auth::ATTEMPTS_EXCEEDED The authentication attempts have been exceeded attempts stand at or above $attemptsLimit
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

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.

Lockout holds until something calls resetAttempts(), so build the unlock path you want — an admin screen, an emailed link, or a scheduled task that clears attempts after a window.

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#