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.
<?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.
<?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.
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.
$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.
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.
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.
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.
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:
$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.
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.
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.
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.
<?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.
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.
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.
<?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#
- Authentication — sessions,
Pop\Authadapters and JWT around this table - Encoded Records —
Record\Encoded, the parent that hashes the password column - Records & the ORM — the finders and
save()an auth class inherits - Authorization — deciding what the authenticated user may do
- pop-db README —
userExists(),getUser()and the rest of the surface