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.
<?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.
<?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.
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.
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.
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#
- 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