Pramnos Authentication & User Management Guide¶
Overview¶
The Pramnos Framework provides a comprehensive authentication system that supports multiple authentication methods, user management, permissions, JWT tokens, session management, and OAuth2 capabilities. The system is modular and extensible through addons.
v1.2 New Features: - Database Authentication Driver — Native auth without addons - Two-Factor Authentication (2FA/TOTP) — Time-based one-time passwords - Login Lockout — Brute-force protection with exponential backoff - Session Tracking — Bot detection and session monitoring - OAuth2 Server — Full
league/oauth2-serverintegration - Security Hardening — CSRF, session cookie, view escaping - Auto-Login Lifecycle — Built-in login/logout handlingSee related guides: Pramnos_Security_Guide.md, Pramnos_Authorization_Guide.md
Architecture¶
Core Components¶
- Auth System (
Pramnos\Auth\Auth) - Main authentication controller - User Management (
Pramnos\User\User) - User data and operations - JWT Support (
Pramnos\Auth\JWT) - JSON Web Token implementation - Permissions (
Pramnos\Auth\Permissions) - Access control system - Session Management (
Pramnos\Http\Session) - Session handling - Token Management (
Pramnos\User\Token) - User tokens and API access
Authentication Flow¶
// Basic authentication flow
$auth = \Pramnos\Auth\Auth::getInstance();
$success = $auth->auth($username, $password, $rememberMe);
if ($success) {
// User is authenticated, session is set
$user = \Pramnos\User\User::getCurrentUser();
} else {
// Authentication failed
$response = $auth->lastResponse;
echo $response['message'];
}
User Authentication¶
Basic Login¶
// Simple login
$auth = \Pramnos\Auth\Auth::getInstance();
$result = $auth->auth('user@example.com', 'password');
if ($result) {
echo "Login successful";
// User session is automatically created
} else {
echo "Login failed: " . $auth->lastResponse['message'];
}
Remember me¶
auth($username, $password = '', $remember = true, …) — the third parameter defaults to
true, so the basic login above already sets the persistent login cookie. Pass false for a login that
lasts only as long as the browser session:
// Session-only login: no persistent cookie
$auth = \Pramnos\Auth\Auth::getInstance();
$result = $auth->auth('user@example.com', 'password', false);
What the browser carries. Every sign-in gets its own token, Pramnos\Auth\RememberTokens,
in the form selector.validator. It goes in $_SESSION['auth'], in the auth cookie, and in the
login response's auth key, whichever path established the session, an application's own user
addon included. It is random and unrelated to the password. The remember_tokens table keeps
only the selector and a hash of the validator, so the table alone signs nobody in.
A token ends in each of these cases:
- it expires ('auth' => ['remember_days' => 30]);
- this browser signs out (logout());
- the person signs out everywhere ($auth->signOutEverywhere($userId)), which also flags each
live session to sign out on its next request;
- the password changes, from any path, because each token remembers the password hash it was
issued under.
Signing in again from the cookies, $auth->rememberLogin(), uses the token once and issues a new
one. A copied cookie therefore stops working as soon as the browser it came from returns.
authCheck() calls rememberLogin() when no auth addon is registered, so an application needs
no addon for remember-me, and init scaffolds none. The framework's UserDatabase addon, where an
application still registers it, does the same.
Never print $_SESSION['auth'] into a page, not as a JavaScript variable and not in a
header the page reads. It signs its holder in. An API call from the page authenticates with the
session cookie and the page's CSRF token (X-CSRF-Token, from <meta name="csrf">), or with an
access token.
Code that signs in again by handing auth back where the stored hash was expected keeps working
with the token: auth($username, $auth, true, true), as an application's own auth addon's
onAuthCheck() does, or a mobile client re-authenticating with the auth it was given. The
driver accepts this account's unexpired token there. auth() then spends it, because the sign-in
handed back its replacement: a remembered visit leaves no live token behind, and a client reads the
new auth. Signing in again in a session that had a sign-in, as an administrator's "log in as"
does, ends the token it replaces.
Until migrate has created remember_tokens there is nowhere to keep a token. A sign-in then
keeps the credential it had, and the log says to migrate.
An auth cookie from before the tokens, which held the password hash, signs in once the old way
and is replaced with a token, so the upgrade signs nobody out. After the cookies have had time
to turn over (they last 14 days and are refreshed while used), refuse the old ones:
A cookie that says somebody is signed in¶
A CDN rule, a service worker and the page cache's early path cannot open a session, so they cannot tell a signed-in visitor from an anonymous one. A login marker tells them:
- What it is. Its value is
1and it holds no secret, so it is not HttpOnly: it has to be readable. - When it is set. Every sign-in sets it, after the addon or the built-in path, so an application's own user addon gets it too. A remembered sign-in keeps it as long as the remember-me cookies.
- When it is cleared. Signing out clears it, and so does the session tracking when it finds the marker with nobody signed in, because the session that set it has ended.
- Why
logged_in. The name begins withlogged, which the page cache's defaultbypassCookiesalready bypasses on. That pattern is anchored at the start, so a name likepramnos_loggedwould not match.
It is off until it is named.
Signing in as somebody else, and back¶
Support often needs to see what a customer sees. Auth::impersonate($userId) (or
Impersonation::start()) signs the administrator in as another account, and
Auth::endImpersonation() brings them back without a password:
$why = \Pramnos\Auth\Auth::getInstance()->impersonate($customerId); // null, or why not
// the framework's own: POST Users/loginas/{id} from the administration
// on every page, in the theme: who this is, and the way back (a POST with the token)
echo \Pramnos\Auth\Impersonation::banner(sURL . 'Impersonation');
- Nothing that signs the administrator in stays in the browser. Their token ends when the
other sign-in begins. The way back is a one-time key held only in the impersonated session. Its
hash is kept in
impersonations, with who, as whom, and how it ended. - Coming back (
POST /Impersonation) ends the impersonated sign-in's token and signs the administrator in with a fresh one. The way back is then spent. - Ending it otherwise. The way back is open for eight hours. Signing out while impersonating closes it, so neither account stays signed in.
- The activity log records both events:
impersonate/impersonatedandimpersonation_end. - A shortcut of the application's own undoes it. Code that signs the administrator back in
on an admin request (from a cookie of its own, say) does so a moment after the impersonation
starts. Remove it, or skip it while
Impersonation::current()is not null. - Who may. From the
superrole up, and only as an account of a lower usertype: never one's own, an equal or a higher one. Configure it with:
Encrypted Password Authentication¶
// Login with the stored hash instead of the plaintext password
$auth = \Pramnos\Auth\Auth::getInstance();
$storedHash = $user->password; // exactly as stored in users.password
$result = $auth->auth('user@example.com', $storedHash, false, true); // Fourth parameter: password is the stored hash
The driver compares the value with users.password as a plain string. Pass the hash exactly as
it is stored; a newly computed password_hash() has its own random salt and never matches.
Logout¶
// Logout current user
$auth = \Pramnos\Auth\Auth::getInstance();
$auth->logout();
// This triggers the 'Logout' addon events and clears the session
Credential Verification (Without Session)¶
If you need to verify a user's credentials (username/password) without actually logging them in (e.g. for API tokens, OAuth2 Password Grants, or verifying a password before sensitive actions), use the central static method User::validateUserCredentials():
$credentials = \Pramnos\User\User::validateUserCredentials($username, $password);
if ($credentials !== false) {
// Credentials match!
$userId = $credentials['userid'];
$username = $credentials['username'];
$email = $credentials['email'];
} else {
// Invalid credentials
}
This method delegates to Auth::getInstance()->verifyCredentials($username, $password) which resolves the active authentication logic in the proper priority order (respecting any registered addons like UserDatabase or custom AuthDriverInterface drivers).
Two audiences for one refusal: revokeapplication¶
Account::revokeapplication() answers both an XHR and a plain form post, and the difference is
decided once by sendRevokeResponse():
| caller | answer |
|---|---|
X-Requested-With: XMLHttpRequest |
a JSON body, then the response ends |
| a browser form | a flash message, then a redirect to applicationsUrl(), by default <routeBase>/applications |
Worth stating because getting it wrong is invisible from one side. An XHR handed an HTML flash page
fails at response.json() and surfaces as «something went wrong» with no clue that the request was
simply missing a field. A form post handed a bare JSON body shows the visitor {"success":false,…}
as the entire page.
The redirect belongs in sendRevokeResponse(), not at the end of the action. The action has
early returns — «client_id is required», «Application not found» — and every one of them answers
through sendRevokeResponse(), so a browser form gets its redirect on every path rather than a flash
queued for a page that is never rendered. If you override sendRevokeResponse(), it owns ending the
response. An area that serves the screen elsewhere (/admin/…) overrides applicationsUrl(), not
the whole response. The messages go through t().
The applications a person has authorized: AuthorizedApplications¶
The listing and the revoking both live in Pramnos\Auth\AuthorizedApplications, so the person's
own screen and an administrator acting on their account do the same thing:
$apps = new \Pramnos\Auth\AuthorizedApplications();
$apps->forUser($userId);
// [['appid' => 4, 'name' => 'Assistant', 'apikey' => '…', 'active' => true, 'token_count' => 2,
// 'last_used' => 1760000000, 'consented_at' => '2026-01-02 03:04:05', 'scope' => 'mcp offline_access', …]]
$apps->revoke($userId, $appId, byUserId: $administratorId); // how many tokens were revoked
- Listed: a live token, or a consent. A consent whose tokens have all expired is still listed,
with
token_count0. It would approve the next authorization without asking, so it must be visible to be withdrawn.last_usedis the latest of a token's last use and its creation. - A deactivated application is listed and revocable. Its
activeisfalse, but its refresh tokens are as live as anybody's.byClientId()finds it by client id whatever its status. - Revoking sets every live token the application holds for the person, access and refresh, to
status 3, kept for the audit trail with its
removedate. It deletes the consent, so the next authorization asks again, and recordsapplication_revokedin the person's activity. WhenbyUserIdis somebody else, the record carriesrevoked_by. Status 3 is also whatEndSessionwrites when a client signs a person out.
exportdata is a confirmation on GET and the file on POST¶
GDPR Article 20, and the split is the security property rather than a UX preference. A GET that
produced the file would put every fact the site holds about somebody behind a URL — shareable, in
browser history, in a proxy log, and fetchable by any <img src> on any page they visit. So the GET
renders what is about to be exported, and only a POST with a valid session token builds it.
The order inside the POST matters too: the data is built before the download headers go out. The
other way round hands the browser a file called user_data_export_<date>.json containing an error
page.
User Management¶
What a usertype is, what each one may do, and how to change them¶
users.usertype is an integer read as a threshold, not an enum. Every framework
administration screen opens at 98 — AdminAccess::defaultUsertype(), the super role,
moved with roles or the admin_default_usertype setting — and the administration area's own floor is whatever
admin.min_usertype says. A comparison, not an equality, is what the framework's own guards
are written in.
Below 98, an account is given the screens it needs through a role: set
admin_access to mixed and allow the role admin.users, admin.logs… on its page. That is
the intended way to give somebody part of the area — see
Authorization — rather than a usertype in
between, which grants every screen at once.
The account init and user:create --admin create is 99, Root
(UserCreate::ADMIN_USERTYPE): on a fresh installation that account is the owner of
the deployment, and 99 is the only tier granted * rather than a list that has to be
revisited when a capability is added above it.
The framework's own types, and what each may do¶
| Value | Type | Tone | Gains, on top of everything below it |
|---|---|---|---|
99 |
Root | danger | * — every capability, including ones added later |
98 |
Super Administrator | danger | admin.area and every administration screen, admin.users, admin.settings, admin.permissions… |
90 |
Administrator | warning | no administration screen unless a role grants it |
1 |
System User (Client Credentials Grant) | neutral | api.client_credentials — and nothing else, see below |
0 |
Simple User | primary | account.self |
/admin/Users/types renders this table from the registry, so it is always the running
answer rather than a copy of it, and an application that declared its own sees its own. The
admin.* capabilities are read from the screens themselves — each menu item's floor, which
is the floor its controller applies — so UserTypes::can() cannot say a screen opens when it
does not. admin.area is the area's min_usertype, devpanel the developer panel's.
Three rules make the numbers behave:
- A value is a threshold.
95is an Administrator and has an administrator's label;label(95)says so. Everything between2and89is a Simple User, because a framework has no basis for inventing roles — those are an application's, and it declares them. - Capabilities accumulate downwards. An Administrator has
account.selftoo. 1is matched exactly and inherits nothing. It is the machine account a Client Credentials grant authenticates as, not a very senior person: giving itaccount.selfwould invent a human for a token.
\Pramnos\User\UserTypes::label(95); // 'Administrator'
\Pramnos\User\UserTypes::can(90, 'admin.settings'); // false — the screen opens at 98
\Pramnos\User\UserTypes::capabilities(98); // the resolved list
\Pramnos\User\UserTypes::tone(99); // 'danger' — a meaning, for a theme to colour
\Pramnos\User\UserTypes::options(); // value => label, for Html\Select::addOptions()
Moving the lines: roles¶
The framework's screens and tools never compare a usertype with a number of their own. They ask
Pramnos\User\Roles for a role, so an installation moves every line in one place:
// app/app.php — administrators at 97, one root account
'roles' => ['admin' => 97, 'super' => 98, 'root' => 99],
| Role | Default | What asks for it |
|---|---|---|
manager |
80 | a dashboard widget's default allowed(); one user's tokens, outside the area |
admin |
90 | phpinfo on the Health screen, the GDPR requests screen's administrator view, auth_trusted_devices_exclude_admins |
super |
98 | every administration screen's floor and the superuser (AdminAccess::defaultUsertype(), superuserUsertype()) |
root |
99 | the developer tools: the DevPanel in development and production, its link in the debug bar, Adminer, the debug bar's grants |
\Pramnos\User\Roles::level('admin'); // 97 with the config above
\Pramnos\User\Roles::atLeast($user, Roles::ROOT); // whether this person holds it
A role nobody defined throws rather than answering 0, because a typo must not open a screen to everybody.
A key a tool already has still wins over its role: devpanel.min_usertype,
devpanel.adminer_min_usertype, devpanel.production_min_usertype, debug.grant_min_usertype,
and the admin_default_usertype and admin_superuser_usertype settings. A screen's own
$requiredUserType, set in an application's subclass, wins too. 0 there means "the
administration floor", never "anybody".
A test, RolesTest::testNoBundledCodeWritesAUsertypeNumber, fails when bundled code compares a
usertype with a literal, so a new screen cannot quietly keep a line of its own.
Declaring your own¶
// app/app.php
'usertypes' => [
99 => 'Root',
98 => 'Super Administrator',
90 => 'Administrator',
50 => 'Management User', // scoped to an organization in this application
20 => 'Installer',
1 => 'System User (Client Credentials Grant)',
0 => 'Simple User',
],
'usertype_tones' => [50 => 'warning'], // this application treats 50 as privileged
'usertype_capabilities' => [ // replaces the map — see the note
99 => ['*'],
90 => ['admin.area', 'admin.users', 'admin.logs'],
50 => ['admin.area', 'admin.organizations'],
0 => ['account.self'],
],
Keyed by the type's floor and read highest-first; declare them in any order, because they are sorted before use — a config listing them lowest-first would otherwise label an administrator "Simple User".
usertype_capabilities replaces the framework's own list rather than merging with it —
a capability list is what the application's own can() checks mean, and quietly adding
defaults underneath would grant things it did not ask for. The administration screens are
added whatever it says: they are not grants but a description of what each screen does.
Every bundled screen — the badge on a user, the label and filter in the list, the select on the edit form, the reference screen — reads this registry. No view carries its own copy, so every screen gives the same answer to "what is 85?" and the same opinion about which number is alarming.
Capabilities are not permissions, and neither is the area's floor¶
- A capability answers may this kind of account reach this kind of screen.
- A permission answers may this account touch this record — per user, in
authserver.permissions, editable on the user's own screen. See Authorization, which puts the three side by side and gives the test for telling them apart. - The administration area's
min_usertypeis a third thing: what stops the area being browsable at all, applied before any screen's own check. The scaffolded value is98, the screens' floor. Underadmin_access = mixedorpermissions, somebody below it whom a role opens a screen to is let into the area — otherwise the floor would refuse the role before the screen could honour it.
What the administration screen shows about a user¶
The framework records a user's history in ten stores, all shown on the user's administration screen:
| Store | What it holds | Where it appears |
|---|---|---|
authserver.user_activity_log |
sign-ins, logouts, whatever the application records with ActivityLog::record($userId, $action, $details), which writes wherever the table exists, whichever features are on |
Activity panel, and users/activity/{id} for the whole of it |
authserver.gdpr_requests |
export and erasure requests | Data requests panel |
authserver.loginlockouts |
failed attempts and any active lockout | Login security panel, with Clear lockout |
authserver.user_twofactor |
whether a second factor is on | Second factor panel, with Disable |
authserver.passkey_credentials |
registered passkeys | same panel, with Revoke per key |
authserver.user_privacy_settings |
what the user chose about their data | Privacy choices panel |
usertokens |
issued tokens | Recent tokens, and the Tokens screen |
tokenactions |
what was done with them | Token actions panel |
authserver.user_organizations |
memberships | Organizations panel |
mails |
the mail this address was sent | Emails received panel, each subject linking to the mail |
The mail panel matches on the address, because mails has no userid. So it answers
"was this person actually sent the code" — which was otherwise a question an operator could
only take to a mail log indexed by address, with an address copied off this page by hand,
and therefore usually answered with "it must have been sent". The limit is the other half of
that: mail sent to an address the account used before it was changed does not appear, and
nothing pretends otherwise.
The panels are the tailwind theme's. scaffolding/themes/tailwind/views/users/view.html.php
draws all ten; the bootstrap and plain-css themes ship a simpler user screen with none of them.
The controller collects the records either way, so a project on those themes has the data and
has to render what it wants from it.
Every read is guarded on its own. These tables arrive with features — an application
without authserver has none of the authserver.* ones — so a panel with nothing behind
it renders empty rather than taking the page down, and an empty panel is still rendered:
"no GDPR requests" is an answer, and a section silently omitted is indistinguishable from
one that never existed.
Three operator actions come with them, and each is recorded in the activity log because each is exactly what an audit needs to show:
users/unlocklogin/{id} clear a login lockout — the answer to most "I cannot sign in"
users/disabletwofactor/{id} turn off 2FA for somebody who lost their phone
users/revokepasskey/{id}?credential={n} remove a credential bound to a device that is gone
disabletwofactor goes through TwoFactorAuthService::disableForOperator(), the named
unchecked path: the user's own disable() requires their password, and an operator cannot
be asked for somebody else's. revokepasskey matches on the user and the credential, so
a request naming another account's key deletes nothing.
The audit trail: what operators did¶
The activity log is a person's own history. authserver.audit_log is the operators' record of
actions, for an administrator or an auditor: who did what, to which account or record.
use Pramnos\Auth\AuditLog;
AuditLog::record('role.granted', $adminId, $userId, ['role' => 'editor']); // about an account
AuditLog::record('admin.page', $adminId, $userId, [], 'page', 'users/edit'); // …through a screen
AuditLog::change('client.updated', $adminId, 'application', $appId, $old, $new); // a record changed
AuditLog::change('user.updated', $adminId, 'user', $userId, $old, $new, [], $userId); // …of an account
AuditLog::record('report.sent', null); // by the system
$rows = AuditLog::query(['event' => 'role.*', 'actor' => $adminId, 'since' => strtotime('-7 days')], 50);
$rest = AuditLog::query(['not_event' => ['admin.page*']], 50); // all but page views
$next = AuditLog::query(['event' => 'role.*', 'before' => end($rows)['auditid']], 50);
- Secrets are redacted in the context, the old values and the new: a key whose words name
one (
password,client_secret,accessToken,api_key,otp,pin,cookie,Authorization…) keeps its place and loses its value, so the trail says a password changed without holding it. Only whole words count, sospinandkeywordare kept. A number, a boolean or null under such a key is kept too, so a count of tokens is still a count.AuditLog::redact($array)is the same rule for code of your own, andActivityLog::record()applies it to its details. - The reader takes
event(exact, or a prefix:role.*),not_event(a list of the same, left out),actor,target(a userid),object_type,object_id,since/until(Unix times) andbefore(anauditid, for the next page). It answers newest first, at most 500 at a time, with the JSON columns decoded. - The moment is written and compared as a Unix time the database converts
(
Instant), so it is right whatever zone the session is in. Each row read carriesat, the moment as a Unix time;event_timestampis as the database returns it, in the session's zone on MySQL and with an offset on PostgreSQL. Formatatfor display. - It never breaks the action it audits. With no table, or a database that fails, nothing is
written, the reader answers
[]and the reason goes to theauthlog. It writes wherever the table exists, on MySQL and PostgreSQL alike. On TimescaleDB the table is a hypertable, compressed after 90 days. - Nothing is deleted unless asked. How long an audit trail is kept is a decision of law or
policy.
audit:pruneruns daily on the framework schedule and deletes nothing until a retention is set:'audit' => ['retention_days' => 730]inapp/app.php, oraudit:prune --days=Nby hand. A retention per event is a map of events or prefixes, with*for every event no other entry names:['admin.page' => 180, 'role.*' => 3650, '*' => 730]. Without*, an event no entry names is kept.
Editing a user: what the screen can change¶
The edit form covers every column the framework's own schema gives a user — username, email, names, phone, mobile, language, timezone, usertype, active, validated, password — plus the two things that are about an account rather than on it:
Per-user settings. usersettings is a key/value store per user, and the value is JSON:
$user->setSetting('notifications.email', true, $operatorId);
$user->getSetting('notifications.email', false); // decoded — true, not "true"
$user->listSettings(); // what the admin screen lists
$user->deleteSetting('notifications.email'); // the default applies again
There were two places to keep something about a user and neither fits an operator-visible
switch: users columns are the schema every application shares, and $otherinfo is a blob
with no list, no per-key delete and nothing an administrator can read. Deleting is not the
same as setting null: no row means the application's default applies, a null value means
somebody deliberately set it to nothing.
Two things to know about how the four behave when the store is unreachable — a project that
has not run the migration, or a database that is down. The readers degrade and the writers
report failure. getSetting() answers the default and listSettings() answers [],
because a project with no settings table has no settings, and a framework upgrade should not
take down every page that consults a preference. setSetting() and deleteSetting() return
false instead, and both log: a caller told a write succeeded will tell somebody the switch
was changed, and «removed» about a row that is still there is the one answer an operator acts
on and is wrong about. So check the return value on a write and ignore it on a read.
Values keep their type through the round trip — a list stays a list and false stays false,
which matters because a store that flattened to text would hand back "0" for a switch
somebody turned off, and "0" is truthy. A row whose value is not valid JSON comes back as
the raw string, because rows do get written by hand in a database client and refusing to read
one is the store deciding an operator's edit did not happen.
Per-user permissions. The grants written directly to this account, with revoke, and a
form to add one (object_type, object_id, action, allow/deny). Only the direct
grants are listed: the resolver also answers from usertype and group membership, and a
screen mixing them would offer a revoke button for a permission that has no row.
Everything is three separate forms on one screen, because they are three separate decisions — saving a name should not resubmit a permission, and a rejected permission should not lose a typed name.
Second factors: which ones exist, and who decides¶
Four, and they are not equally strong. The order is the policy, and it is the order a step-up offers them in:
| Method | What it is | Strength |
|---|---|---|
passkey |
WebAuthn — a device holding a key | strongest; also a primary method |
totp |
an authenticator app | strong; needs enrolling in advance |
email |
a six-digit code, mailed | weakest; needs nothing set up in advance |
push |
"Is it you trying to sign in?" on a trusted phone | offered first when the account has one — see below |
Wrong codes are limited. Each wrong second-factor code (TOTP, a backup code, a mailed code,
any registered factor) counts against the pending login, and against the account's lockout like a
wrong password. After 'auth' => ['second_factor_attempts' => 5] of them, five by default, the
pending login is ended and the next try starts again from the password. If the failures set off
the lockout, the result is LOCKED, with its remaining seconds. Without the limit, a password was
enough to guess a six-digit code at leisure. A factor whose gateway is down refuses without
counting: that is an outage, not a guess.
The second factor everywhere, not only on the login form¶
The login form asks for the second factor. A site's own sign-in form, a social login and a remember-me cookie do not. Where one PHP session serves a site, an administration and an API, a password alone then reaches all three through whichever door does not ask. Turn on:
Every session is then checked once, in Session::staticIsLogged(), which every "is somebody
signed in" path goes through: pages, controllers' guards, /oauth/authorize, and the API's
session path.
- A session the login flow finished is marked (
SecondFactorGate::SESSION) and passes from then on. - A session signed in any other way, for an account with a second factor, is not counted as
signed in. It becomes a pending login, and the login page shows the second step. The
wrong-code limit applies to it as to any other, and its wrong codes count against the
account's lockout under its username, as the form's would. An account already locked out gets
no pending login: it waits until the lockout lifts, and the login page says it is locked and
for how long (
SecondFactorGate::lockedFor()). - A stopped session stays stopped. It reads as signed out, but it is not a guest: while its login waits for the step, or its account is locked out, every page it opens is sent to the step again, at most fifteen minutes. Finishing the login, abandoning it or letting it expire ends that. Asked before the application exists, as a front controller deciding on its page cache does, the session counts as owed and nothing is forgotten. The question asked once the application is up decides. An application that throws away a guest's session on every request loses the pending login with it, and the person signs in again.
- An account without a second factor, or a browser it trusts, is marked and passes. A remember-me cookie in a browser trusted with "Don't ask again on this device" is therefore not asked again, as the form would not ask.
- An administrator signing in as somebody else settled their own factor, and the account they become is not asked for its own.
Where the person is sent. A page opened in a browser (a GET asking for HTML, not an XHR or
a JSON call) is remembered, and the account screens return to it once the second step is done,
where the request names no return of its own. To send the browser straight to the step, name
it:
'auth' => ['security' => [
'second_factor_everywhere' => true,
'second_factor_url' => 'login', // or an area's own sign-in page, or a full URL
]],
Without it, the page simply sees nobody signed in and sends the person to sign in as it would anyone. A form post, an API call or a script is never redirected: it is refused, as signed out.
The application's own login flow. The gate runs the same LoginFlow as the account screens:
LoginFlow::make(), which is the class 'auth' => ['login_flow' => App\Auth\LoginFlow::class]
names, or the framework's own. A subclass with its own trusted devices, lockout or factors
therefore decides what a session owes as well as what the form asks.
Off by default. With it on, a remember-me cookie no longer skips the second factor: "Don't ask
again on this device" is what skips it. Nothing of this is inside Auth::auth(), which stays
the password check it is.
Trusted devices and sign-in approval¶
Google's two sign-in conveniences, for every application on the framework. Both are off until an administrator turns them on in System Settings → Security, because each changes what a password can do.
"Don't ask again on this device" (auth_trusted_devices). The two-step page shows the box,
ticked by default. Finishing a real second factor with it ticked trusts the browser for
auth_trusted_device_days (30, 1–365): a password sign-in from it skips the second step until
then. Use does not extend it. auth_trusted_devices_exclude_admins (off) makes usertype ≥ 90
always take the second step — including browsers trusted before it was switched on, which also
stop receiving and answering phone prompts.
The server trusts only a box it was sent. pf-auth.js copies the box into whichever form is
submitted, as trust_device=1 or 0 (and onto the passkey's address as ?trust=); the step-up
remembers it with LoginFlow::chooseTrust(). A page that never sends it trusts nothing — no
JavaScript, or an application's own copy of login/login_2fa from before the box. Shown ticked is
not chosen: an application that copied the two-step view must add the box (and load pf-auth.js)
before its users can trust a device at all.
A new password forgets every trusted device. Account::updatePassword() — where both the
change and the reset arrive — calls revokeAll(): people change a password because somebody else
may have it, and that person's browser must not keep skipping the second step.
Pramnos\Auth\TrustedDevices—trust(),isTrusted(),current(),forUser(),revoke(),revokeAll(). The cookie (pf_trusted_device, HTTP-only, Lax) holds a random token;authserver.trusted_devicesholds onlyToken::lookup()of it./account/securitylists them, marks "This device", and forgets one or all (account/revokedevice, POST with the session token).- A password alone, a trusted browser, or an emailed sign-in link never trusts anything.
Approval from a trusted phone (auth_push_approval). Needs push working — VAPID keys and
minishlink/web-push (see the Push guide); the settings screen says when it is not. An account
with a trusted device that is subscribed to notifications gets the push factor, offered
first. The step-up sends it as it begins — nothing to start, as with Google — and the page waits:
- The phone's service worker reports receipt (
POST /push/ack?token=…) and the page says "Delivered to your phone ✓". After 20 seconds without it the page opens "Try another way". - A number, unless the attempt is plainly the account's own. The waiting page shows it; the
page the notification opens (
account/approve) offers three and the phone must pick the right one, and the notification's only button is No, it's not me. The exception is a plain Yes / No — on the notification and on the page — for a browser the account trusted before (TrustedDevices::known(): its cookie outlives the trust,KNOWN_DAYS= 365) signing in with nothing unusual (SignInRisk). An attempt that merely looks familiar earns nothing: User-Agent and country are what whoever holds the password chooses, and a lock-screen Yes is what prompt bombing counts on.auth_push_number_matching:risk(default) as above,alwaysfor the number every time. - Approved, the waiting page submits by itself (
method=push, throughrequestSubmit()so the box goes with it) and signs in.
The rules: only trusted devices are asked, never every subscribed browser; answering needs a
browser signed in as the account and carrying its trust cookie, so a forwarded link approves
nothing; the wrong number is a refusal, and a Yes without the number an ask needs changes
nothing; the first answer stands (a No and a Yes
arriving together are settled by the row, so a refusal is never overwritten); only the waiting
browser's session can use an approval, once, within 60 s of the answer
(PushApprovals::CONSUME_WINDOW); each ask can be answered for 120 s; at most 3 asks in 10
minutes per account. "No, it's not me" records signin_denied, and mails
SecurityChangeNotifier::SIGNIN_DENIED — the password is known to somebody else.
Does it satisfy enrolment? The site decides. push scores 70 so that it is offered first.
Whether it also counts towards require_factor_enrolment_from_usertype is
auth_push_counts_for_enrolment (PushApprovalSecondFactor::countsForEnrolment()), because a
browser can be trusted after a mailed code:
| Value | Counts when |
|---|---|
strong (default) |
a phone that would be asked was trusted with a factor that counts itself — an authenticator, a passkey, an adaptor of strength ≥ 40 (authserver.trusted_devices.trusted_via, FactorEnrolment::methodIsStrong()) |
always |
there is any phone to ask, as Google counts its prompt — for a site that does not need the stricter bar |
never |
never: the account needs its own authenticator, passkey or adaptor |
A browser trusted through the phone prompt itself, or before trusted_via existed, counts as not
strong.
Never the only way. The page always has "Try another way", and an account whose only other
option would be nothing is also offered a mailed code: switching the prompt on lets email
through the factor list whatever twofactor_methods says.
| Endpoint | Who | What |
|---|---|---|
GET account/pushstatus |
the waiting page | {state: sent\|delivered\|approved\|denied\|expired\|none, number, devices, expires_in} |
GET/POST account/approve?token= |
the phone, signed in and trusted | the question, and the answer |
POST push/ack?token= |
the phone's worker | the receipt; the token is the credential |
POST push/respond?token=&decision= |
the notification's buttons | No always; Yes only on an ask that needs no number |
POST push/subscribe |
the page | now also links the subscription to its trusted device |
A passkey inside the username autofill¶
The passkey button is not where most people sign in with one. Conditional mediation puts the credential in the username field's own autofill list, so signing in is one tap on a suggestion instead of noticing a second button and choosing it. The scaffolded sign-in screen does this out of the box; two things make it work.
The field declares it. The webauthn token is how the specification marks the field a passkey
suggestion belongs in:
<input type="text" name="username" id="username"
autocomplete="username webauthn"
autocapitalize="none" autocorrect="off" spellcheck="false" required>
The ceremony is started on load and left waiting. pf-auth.js does it when the page has both
that field and a [data-pf-passkey-login] button to read the URLs from. The promise settles when
somebody picks a passkey — which may be never, and that is the normal case.
window.PramnosWebAuthn.conditional(optionsUrl, verifyUrl)
.then(function (result) { if (result) { window.location = result.redirect; } });
A browser allows one outstanding credentials.get()
A conditional request holds it for the life of the page. So switching this on naively breaks the passkey button: the second ceremony is refused, silently, and nothing on the page says why.
PramnosWebAuthn.authenticate() cancels any pending conditional request before starting its own,
which is what keeps both paths working. If you write your own button, call
PramnosWebAuthn.cancelConditional() first.
Three cases stand down rather than interfere, and each matters for a reason:
isConditionalMediationAvailable()is absent — older browsers. Calling it there is aTypeErroron the sign-in page, so it is feature-detected.- it answers false — no ceremony is started at all. Starting one that then failed would hold the
single outstanding
get()and break the button for exactly the browsers that need it most. - the person cancels, or uses the password form — an
AbortError, which is the normal ending of a conditional ceremony and is not reported. An error message about a ceremony nobody asked for, appearing while somebody types a password, is worse than no message.
The client is tested by being run: ConditionalMediationClientTest loads the shipped
pf-webauthn.js under Node against a stubbed navigator.credentials and asserts, among the rest,
that pressing the button cancels the waiting ceremony and still signs the person in.
Email is offered precisely because it needs nothing set up in advance. An authenticator app protects an account only if the person installed one before the day they needed it, and most have not. Mail is a channel every account already has, so it is the only second factor that can be turned on for everybody — and a weak second factor is a large improvement on a password alone.
It is never ranked above TOTP. An account with both is asked for the app and offered mail as the fallback; reversing that would quietly downgrade every account that had done the stronger thing.
Adding a factor of your own — SMS, a push, anything¶
The three the framework ships — totp, email and push — are adaptors like any other, held
in Pramnos\Auth\SecondFactorRegistry (passkeys sit outside the registry). An application adds
its own from a service
provider's boot():
Pramnos\Auth\SecondFactorInterface is seven methods:
public function name(): string; // 'sms' — travels in forms and the audit log
public function label(): string; // 'Text message' — what a person is shown
public function strength(): int; // ordering: app 60, SMS ~40, mailed code 20
public function isEnrolledFor(int $userId): bool;
public function needsSending(): bool; // true for SMS, false for an authenticator app
public function send(int $userId): bool;
public function verify(int $userId, string $code): bool;
Nothing else changes. The login offers it, the step-up screen renders it, the audit log
records which factor carried the sign-in, and auth_newsignin_action can demand it —
without the framework knowing your gateway exists.
Three obligations, because the flow cannot check them for you.
isEnrolledFor()must be a promise. True meansverify()can succeed. An SMS adaptor with no phone number on file must answer false, or the login offers a step-up nobody can complete — a lockout wearing the clothes of a security feature.isEnrolledFor()must not send. It is called while deciding what to offer, including on pages that are never submitted. Sending there texts somebody on every failed password attempt, at your expense.- What you send must expire, be single-use and be attempt-capped. Only the adaptor
knows what it issued, so the flow cannot enforce it.
EmailSecondFactoris the worked example: ten minutes, five attempts and then the code is destroyed, single use.
Registering is not enabling: auth.twofactor_methods still decides. A shared codebase can
register an SMS adaptor everywhere and a deployment that does not pay for the gateway
leaves it out of the list — no code change, and accounts stay enrolled for the day it comes
back.
Registering under an existing name replaces it, which is how an application routes the
mailed code through its own transactional provider: register your own email and it is
used instead.
Turning the email factor on¶
Two switches, answering different questions. The application decides the method exists:
The default is ['totp'], so an installation that does not ask for this gets exactly
what it had. totp is always in the list whether or not you write it — an application
cannot switch off the method its existing accounts are enrolled in by adding a config
key, which is what omitting it would otherwise mean.
Declaring it is not enough on its own: the installation has to be able to send mail.
EmailSecondFactor::isAvailable() is false while smtp_host is empty
(Email::isConfigured()). A code nobody can receive is not a factor, so with no mail the
method is not offered, and an account that enabled it is treated as not having it rather
than asked for digits that never arrive.
The account decides it wants it, from its own security screen, behind its own
password (user_twofactor.email_enabled). Attaching a second factor is a change to how
an account authenticates, so a borrowed session must not be able to make it — in either
direction. An operator cannot turn it on for somebody: that would be adding a credential
to another person's mailbox. The administration screen shows the state and nothing more.
Because the two switches are independent, an application can withdraw the method without touching a single account row, and turning it back on restores each account's own answer.
What makes a six-digit code safe enough¶
Not the hashing. A million possibilities is nothing to a KDF, so the code is stored as an
HMAC keyed by the installation secret and the user id — enough that a leaked table
hands out no live codes and a row copied between accounts is worthless, and no more than
that. What makes it safe is three limits, all enforced inside
Pramnos\Auth\EmailSecondFactor:
- ten minutes, after which it is refused;
- five attempts, after which the code is destroyed rather than merely refused — a code left alive after the cap can be guessed at while its owner is still holding it;
- single use — a correct verification deletes the row, so an intercepted code is spent.
Asking again replaces the code rather than adding one, so "send it again" never leaves two live codes.
Sending, and when not to¶
A code is not mailed when the password is accepted. It is mailed when the screen asks:
$flow->sendEmailCode(); // false when nothing is pending, or no address
$flow->hasLiveEmailCode(); // so the screen can ask without sending
$flow->completeEmailCode($code); // tagged `email`, not `twofactor`
Sending on password success would mail an account that has an app and email as a fallback on every sign-in it never reads — and would mail one for each of somebody else's failed password attempts, which is a way to use your login form as somebody else's mail flood.
The completion is recorded as its own method so an audit can tell which factor actually carried a login. They are not equally strong, and a log that calls them the same thing cannot answer that afterwards.
Passkeys from a SPA¶
A single-page application authenticates with a bearer token, not a session cookie, and its code is
modules rather than <script> tags. Neither difference touches the ceremony — a passkey sign-in is
the same conversation with the authenticator either way — so a SPA runs the framework's own
WebAuthn client, delivered as an ES module.
What a scaffolded SPA gets, without asking:
| Piece | Where | What it is |
|---|---|---|
Pramnos\Auth\Controllers\ApiPasskey |
Api\Controllers\Passkey in the project |
The web Passkey controller, answering a verified assertion with a bearer token |
/passkey/* routes |
app/api-routes.php |
The seven endpoints, documented in the generated OpenAPI |
lib/webauthn.js |
the SPA source tree | The framework's ceremony, with ES exports — framework-owned, not to be edited |
loginWithPasskey() and friends |
lib/api.js |
The calls a screen makes |
A sign-in screen needs this much:
import { passkeySupported, loginWithPasskey } from './lib/api.js';
// The button exists only where the browser can honour it: false on plain http://
// (localhost excepted) and in older browsers.
if (passkeySupported()) {
const user = await loginWithPasskey(); // no argument: the authenticator decides who
}
loginWithPasskey() stores the issued token exactly as login() does, so everything after it is a
normal authenticated call. Passing a username pins the ceremony to one account; passing nothing
makes it usernameless, which is the version worth offering — a sign-in that asks for nothing at all.
Enrolment and management are the same shape: registerPasskey(label), listPasskeys(),
renamePasskey(id, name), revokePasskey(id). For the autofill experience described above,
loginWithPasskeyAutofill() and cancelPasskeyAutofill() are the module's conditional pair.
The cookie still travels, and it is not a credential. A ceremony is two calls, and the challenge
issued by the first is held server-side so that only the session that started one can finish it.
That correlation is what the cookie carries; credentials: 'same-origin' in the generated client is
why it works. Authentication is still the token — /passkey/login seals the request's identity and
establishes no login session.
One implementation, and the reason it matters. lib/webauthn.js is generated from the same
source the server-rendered login page is served (PasskeyAsset::spaModule()), and the only thing
the SPA replaces is the transport: one injected function that carries the API key and the bearer
token instead of posting same-origin. Writing a second WebAuthn client is the obvious alternative
and the wrong one — the ceremony is exact in a way that does not survive being typed twice, and its
failure mode is a flat authentication_failed that names no field.
To add the module to a SPA project scaffolded before it existed:
lib/api.js is the project's own file and is never overwritten, so its passkey functions are a
copy-in from the current stub.
What the scaffolded SPA sign-in screen does¶
Both stacks — Svelte and build-less — ship the same three paths, because a screen that implements only the first one is a dead end rather than a missing feature:
- Password.
login(username, password). - The second factor, when the account has one.
login()throwsTwoFactorRequiredrather than returning — the password was correct and the account needs more — and the screen replaces the password form with a code form, posting only the code throughloginTwoFactor(). The pending login is held server-side, so nothing is carried across; a wrong code leaves it pending and the person tries again instead of starting over. - A passkey, where the browser can honour one (
passkeySupported()).
A screen that does not catch TwoFactorRequired shows "Could not sign in" to somebody whose
password was right, with no way forward from that screen at all — which is why
GeneratedSpaPathsTest asserts both stacks handle it, rather than leaving it to a reviewer.
The code field carries autocomplete="one-time-code", which is what lets a phone offer the
code from the message it has just received, and inputmode="numeric", which keeps the numeric
keypad without rejecting an alphanumeric backup code.
What the form says when it fails, and while it works¶
Two things the scaffolded sign-in screens do that are invisible on the machine of the person who built them.
A failed submission is announced. The error box carries role="alert", and — the part that
actually does the work — the first field of the form points at it:
$errorFieldAttributes = $errorText !== ''
? ' aria-invalid="true" aria-describedby="form-error"'
: '';
<div role="alert" id="form-error" class="alert alert-error">Wrong username or password.</div>
…
<input type="text" name="username" id="username" autocomplete="username webauthn"
aria-invalid="true" aria-describedby="form-error" autofocus>
role="alert" on its own is not enough here, and this is the easy thing to get wrong while believing
it is done: a live region is announced when it changes, and a server-rendered error has been in
the document since before the page existed. It never changed, so most screen readers say nothing. The
description is what works with no JavaScript at all — the message is read out as part of the field the
moment focus lands on it, and focus lands there on load because the first field carries autofocus.
The first field only. These errors are form-level — «wrong username or password» is about the pair — and marking four fields invalid to report one failure tells a screen reader four things that are not true.
The same split applies to every other message box on a scaffolded page: alert-error and
alert-danger get role="alert", which interrupts; alert-info, alert-success and alert-warning
get role="status", which waits for the next pause. A success message that interrupts whatever was
being read is how a well-meaning accessibility sweep makes a page worse.
A submitted form says it heard the button. Mark the form and pf-auth.js does the rest:
On submit the submit buttons are disabled, gain a pf-busy class, get … appended to whatever they
said (or data-pf-busy-label if the screen wants real words), and the form gets aria-busy="true".
The pause is real and unavoidable — the human-check proof runs in a worker and the form waits for it —
and a person who cannot tell whether the press registered presses again. A second sign-in attempt is
not free: it is a failed attempt against a lockout counter.
One subtlety worth knowing before you touch it. A submit listener that called preventDefault() did
one of two opposite things: refused the submit, in which case disabling its button hands somebody
a form that can never be submitted; or held it, in which case that hold is exactly when the second
press happens. Nothing in the event distinguishes them, so the indicator skips both — it runs a tick
after the event and returns if the default was prevented — and whoever is holding marks the form
itself:
which is what pf-humancheck.js does while it waits for its proof.
Telling an account it was used from somewhere new¶
NewSignInAlert compares the current sign-in's device fingerprint against
user_activity_log and emails the account when the combination is new. It reads history
that already exists, which is why switching it on does not notify everybody at once.
The site's policy sits around the user's own preference — auth_newsignin_policy on
the settings screen:
| Value | Meaning |
|---|---|
optin (default) |
the account's own preference decides; silence means no |
optout |
the same, except silence means yes — on unless the account turned it off |
always |
every account is notified, whatever it chose |
off |
nobody is, whatever they chose |
always exists because for a service where the account is the product, telling somebody
their credentials were used from a new device is closer to an obligation than a setting.
off exists for the incident where the alert stops being a security feature and becomes
the outage's own mailing list. optin is the framework's default so that an upgrade starts
nobody's mail.
A new project starts at optout: pramnos init writes it into app.php, where it acts as
the default the setting overrides.
optin stays the framework's default because existing installations rely on it — an upgrade
that begins mailing a user base would be a surprise delivered by a patch release. A newly
scaffolded project has no such user base.
optout is the one to reach for on a real user base. Under optin, the people who most
need this mail are the ones who will never find the checkbox, so the feature ends up
protecting the users who were already careful. optout keeps the choice with the account and
changes only where it starts from. It works because the preference stores '0' rather than
deleting its row: "chose no" and "never chose" are different states, and this policy is the
difference between them.
Nothing about it needs a consent record. The mail goes to the address the account signed up
with, about that account's own sign-in, and it can be turned off in one click — a security
notification, not marketing. If your installation sends it under optout, the unsubscribe
link and List-Unsubscribe headers described in the Email Guide are
what keep it that way in a mailbox provider's eyes.
The per-account state is on the user's admin screen, with a toggle when the policy leaves the decision to the user — and a sentence instead of a switch when it does not, because a control that decides nothing is worse than none.
…and making it do something about it¶
Notifying is the weakest useful response: by the time the mail arrives, whoever had the
password is already inside. auth_newsignin_action, beside the policy on the same screen,
is what such a sign-in must satisfy before it continues:
| Value | What a sign-in from an unrecognised device must do |
|---|---|
notify (default) |
nothing — the alert is sent and the login proceeds |
authlink |
wait for a single-use link mailed to the account |
require_2fa |
pass a second factor, even if the account would not normally be asked |
require_passkey |
pass a passkey — the one factor that cannot be phished or read out of a mailbox |
None of them can lock a user base out, and that is the design constraint rather than a
side note. A demand the account cannot meet — "use a passkey" to somebody who has none —
would turn this setting into an outage with a checkbox, so each strict reading falls back
to the strongest factor the account actually has, and to a mailed code last, because a
mailbox is the one thing every account has. require_2fa therefore imposes a mailed code
on an account with no factor at all, regardless of that account's own email-factor
switch: the demand is the site's, not the account's, and an account with nothing set up is
exactly the one a stolen password threatens most.
A mailbox is only a factor when the installation can send mail. With smtp_host empty,
authlink resolves as require_2fa does, and the mailed-code fallback becomes the passkey
when the account has one and nothing when it has neither. That is weaker than a code, and it
is the only answer that is not a wall in front of every account on the installation.
A device the account has used before is never questioned by any of them, so this costs a step on unrecognised browsers and nothing on the rest.
When the action applies is a separate setting, auth_newsignin_trigger
(NewSignInAlert::TRIGGER_SETTING):
| Value | The action applies to |
|---|---|
new_device (default) |
any browser this account has not been used from |
suspicious |
only a sign-in with a harder-to-explain signal (SignInRisk): a country the account has never used, two places at once, a different country too soon to have travelled, or a success straight after a run of failed attempts |
"A device this account has not used" fires constantly on a real user base — new phones,
cleared cookies, a borrowed laptop — so a demand attached to it is a step everybody pays
regularly. suspicious spends the friction where there is something to be suspicious about.
new_device is the default because it is the conservative reading: it asks more often, not
less.
The link is a link in an authentication email, which the alert deliberately refuses to
be. The difference is that this one is expected: the person submitted a password
seconds ago and is looking at a page telling them it is coming. It is useless to somebody
who has the password but not the mailbox — which is the entire point — it expires in
fifteen minutes, and it works once. LoginFlow::sendAuthLink() refuses when no login is
pending, so the endpoint cannot be used to mail an arbitrary account a way in.
It completes in a browser that never saw the password leg, because people read mail on a phone: the token is the authorisation, and it is spent before a session is established.
// What the flow exposes
$flow->sendAuthLink($returnUrl); // false with nothing pending
$flow->completeAuthLink($token); // tagged `authlink`
The four rules, and where they live¶
All four are in Auth\NewDeviceAuthLink, not in the callers — what makes the method safe is
not the token but these:
| Rule | Constant | Why it is the whole security when the others are absent |
|---|---|---|
| Single use | — | The mail stays in the inbox afterwards, and a provider's link-preview fetch counts as an open. The hash is cleared before an id is returned. |
| Fifteen minutes | TTL |
A link is a credential with no owner; the window is what bounds the loss. |
| One at a time | — | Issuing again replaces the stored hash, so "send it again" cannot leave two live ways in — the older being the one nobody is watching. |
| A rate limit | RESEND_INTERVAL, MAX_SENDS, SEND_WINDOW |
The button sits behind a correct password, so anybody who phished one can hold it down. |
Two decisions worth knowing before you rely on them:
- With no
authserver.user_activity_logtable, a send is allowed. The accounting reads the same rows that audit the sends, and refusing every link when the table is missing would refuse the login itself — a half-migrated installation must not lock everybody out. - A failed delivery is not recorded as a send. A dead mail server would otherwise tell the person to watch an inbox nothing is coming to, and spend their rate limit doing it. The stored token is deliberately left behind: it is unreachable, and clearing it would revoke a link an earlier successful send may still have out there.
notifier() is a protected seam, so a test can run send() without a mail server. The same
idiom as Controllers\Me::resolveUser() — override it, assert what was handed over.
Fields the users table does not have¶
User keeps anything outside its own columns in $otherinfo, reached with plain property
syntax:
$user->notifyByEmail = '1';
$user->notifyByEmail; // '1'
isset($user->notifyByEmail); // true
$user->neverSet ?? 'default'; // 'default'
All four magic methods read that one store. That is worth stating because the third and
fourth lines are not obvious: ?? asks __isset() first and only calls __get() when a
class declares no __isset(). A class whose __isset() answered from a different store than
its __get() would return the fallback for a value that is present — with no error, no
warning, and the value correctly in the database the whole time.
load(null) returns false rather than looking anything up: new User($record->userid) on
a record that did not load is a normal path, and 0 already means "load whoever is in the
session", which is a different question.
Self-service registration¶
/register is a real route with the auth feature enabled, and it is closed
until you open it:
Off is the default because a scaffolded application should not gain a public sign-up page by being upgraded, and most applications on this framework create their accounts by some other route entirely. With it off the page still renders — it says registration is closed rather than 404-ing a page the bundled views link to.
What the endpoint enforces, in this order:
- Already signed in → redirected away.
- Registration closed → refused before the request body is read at all, so a crafted POST to a closed server cannot write a row. A pending invitation opens it for that one address.
- CSRF token → checked before validation, so a form with no token is not even an account-existence oracle.
- The human check, when
auth.security.human_checklistsregister. This is a public write that creates a row and sends a mail, which makes it the form on the site most worth pricing. - Username 3–60 characters,
[A-Za-z0-9._-]only — the set that needs no escaping in a URL, a log line or an email subject. - A valid email address — with an invitation, the invited address
(
invite_email_mismatch); without one, an address at a domain inauth_registration_domainswhen that list is set (email_domain_not_allowed). - The same password policy
resetpassworduses: 8 characters, a digit, a symbol, and a matching confirmation. - A unique username (
username_taken) and a unique address (email_unavailable), and only then a write. - After the write: an invitation is accepted, or — with
auth_registration_verify_emailon — a confirmation link is mailed and the account cannot sign in until it is opened (/register/confirmemail). See Registration: open, by domain, by invitation.
A refused submission comes back with the username and the address filled in — retyping an address after a failed check is how people give up — and never with the password: the form is rendered into HTML that ends up in a browser's history, a proxy log and the occasional bug report screenshot.
The new account is active at the lowest privilege level. Nothing in the flow can
grant a usertype: createUser() names the five fields it sets, so a submission carrying
usertype or userid is ignored rather than obeyed.
What it tells an attacker. "That username is taken" confirms an account exists, and there is no way to both refuse a duplicate and not confirm it — a form that has to let somebody pick another name has to say why. So it reveals that, and the mitigations are the ones that work: leave registration off when you do not need it, and keep the login lockout in place, because the value of an enumerated username is what you do with it next. The email case is worded so that it does not add a second, independent confirmation.
Invitations and a domain allow-list are built in (see Registration: open, by domain, by invitation). To open registration on some other condition — a shared invite code, an organization policy — override one method, and keep the parent's answer so invitation links still work:
class Register extends \Pramnos\Auth\Controllers\Account
{
protected string $routeBase = 'register';
public function display()
{
return $this->register();
}
protected function registrationIsOpen(): bool
{
return parent::registrationIsOpen()
|| $this->post('invite') === $this->setting('signup_invite_code');
}
}
createUser(), usernameExists() and validateRegistration() are seams on the
same controller if you need to change how an account is stored or what a username
may look like.
The single sign-on status page¶
/sso answers "does this server already know me, and what have I authorized?".
Public, because for a signed-out visitor that negative answer is the useful half —
it is what another application sends somebody here to find out.
Creating Users¶
// Create a new user
$user = new \Pramnos\User\User();
$user->username = 'johndoe';
$user->email = 'john@example.com';
$user->firstname = 'John';
$user->lastname = 'Doe';
$user->active = 1;
$user->regdate = time();
$user->save(); // assigns the userid
$user->setPassword('password1!'); // hashed with the account's pepper, so it needs the id
$user->save();
Two saves, in that order: setPassword() keys the hash with the account's id, so a hash
written before the id exists is one no login can match. Assigning $user->password directly
stores whatever string you give it.
save() writes active as the object holds it, on every database: 0 and 1, or an
application's own states (2, 3, 5…), into the framework's smallint. A PostgreSQL schema
that declared it boolean gets a boolean, and one without the column is left alone.
Loading Users¶
// Load user by ID
$user = new \Pramnos\User\User(123);
// Load user by username or email
$userId = \Pramnos\User\User::getuserid('johndoe', 'username'); // false when there is none
$userId = \Pramnos\User\User::getuserid('john@example.com', 'email');
$user = new \Pramnos\User\User((int) $userId);
// Get current logged-in user
$currentUser = \Pramnos\User\User::getCurrentUser();
if ($currentUser) {
echo "Welcome, " . $currentUser->firstname;
}
A user that did not load is a new one¶
new User($id) for an id with no row gives a new user: save() inserts it, with an id of
its own. Check $user->userid > 1 after constructing when the id should exist. A save that finds
no row to update — the user was deleted after it loaded — writes nothing, details included, and
leaves There is no user N to save: it does not exist. in $user->_errors.
getCurrentUser() is a read¶
It returns the signed-in user, or false, and writes nothing. Call it as
often as a request needs to — the theme header and the controller both asking is
the normal case, and the first call caches the answer on the application so the
rest are free.
For ?->, use User::current(). getCurrentUser() answers false for
nobody, and the nullsafe operator short-circuits only null, so
getCurrentUser()?->userid throws Attempt to read property "userid" on false
from every cron job and CLI command. User::current() is the same lookup and
answers null:
getCurrentUser() keeps false, because code compares it with === false and
would read null as somebody being signed in.
The cache is per request, and a sign-in clears it. Auth::login(), loginById() and
logout() all call User::forgetCurrentUser(), so a process that signs in a second user — a
test, a worker acting for several accounts — is told about that user, not the first. Code that
changes $_SESSION['uid'] by any other route calls User::forgetCurrentUser() itself.
How a stored password is hashed, and read¶
Everything that hashes or checks a password goes through Pramnos\Auth\PasswordHash.
User::setPassword(), User::verifyPassword() and DatabaseAuthDriver all call it, so
the front door and a step-up in the middle of an account screen agree about what a stored
hash is.
$user->setPassword($plain); // writes the preferred scheme
$user->verifyPassword($plain); // true for any scheme it can read
The preferred scheme is bcrypt over an HMAC-SHA-256 digest of the password, keyed by a
per-account pepper (md5(securitySalt . userid)). The digest is there because bcrypt stops
at 72 bytes: the pepper scheme (still read) appends a 32-character pepper to the plaintext,
so everything typed past the 40th character is discarded and two long passwords sharing a
40-character prefix verify against each other. A fixed-length digest has no such ceiling.
The per-account pepper means the same password held by two accounts is two different hashes, so one leaked hash cannot be tested against every row.
verify() reads more than one scheme, and says which¶
$scheme = \Pramnos\Auth\PasswordHash::verify($plain, $storedHash, $userId);
// 'hmac' | 'pepper' | 'plain' | 'md5' | null
| Scheme | What it is | Accepted |
|---|---|---|
hmac |
the preferred scheme above | always |
pepper |
pepper appended to the plaintext | always |
plain |
password_hash($plain, PASSWORD_DEFAULT), no pepper |
always |
md5 |
a raw md5 from a very old table | only with $allowMd5 = true |
plain is there because an application sharing this user table may write the row itself,
with a bare password_hash(). Without it, such an account could not pass the framework's own
password step-up — the correct password would be refused, with nothing in the refusal to
suggest that hashing was the reason. Verification reads both, so either side may create a row.
It returns the scheme's name rather than a boolean because a caller cannot know whether to rewrite the row without knowing what it is holding.
Upgrading on the next sign-in¶
A successful sign-in is the only moment the plaintext exists, so it is the only moment a row can be rewritten in a stronger scheme. That happens automatically, and how far it goes is yours to choose:
off— never rewrite. For a table whose rows another writer must keep reading in the scheme it wrote them in.modern(the default) — rewrite the pepper-suffix scheme, the preferred scheme when its cost has moved past the stored hash, and md5. md5 needs no second opt-in: a row can only be read as md5 whenauth.legacy_md5is on, which already says the table has md5 rows in it. Leaves a plainpassword_hash()row alone, because such a row may belong to another application sharing this table, and rewriting it would leave that application unable to verify a password it wrote itself.all— that row too. This is how an application says the table is its own; for migrating one, where the alternative is asking every user to reset a password that is correct.
The same key governs the login driver and User::verifyPassword(), so a sign-in and a
step-up never disagree about whether a row may be rewritten. auth.auto_upgrade (boolean) is
still read, as an older name for the same decision.
Each rewrite is recorded as a password_hash_upgraded activity entry, so a migration is
something you can watch finish rather than assume.
Ordering a step-up: cheap checks first¶
Where a screen asks for more than one thing — a password and a 2FA code, as disabling two-factor does — check the password first and the single-use code last. Reversed, a mistyped password consumes the code: the user is told their password was wrong, and then has to wait for a new code before they can retype it. The cost only appears in somebody's hands, and the wrong order is the natural one to write.
users.language is the user's preference¶
The framework never writes users.language on its own — only an operator's edit on the
administration user form, or your application. It is yours to interpret — typically as the
language the account prefers the interface in.
It is read at sign-in. A login carries the stored language into the session, so the page after the login form is already in it — see Which language a request is served in for the whole order of precedence. Setting the column is all an application has to do.
The framework does not keep it in step with the interface language, and that is
deliberate: the two answer different questions. $lang->currentlang() is the
language this request is being rendered in, which a ?lang= parameter or a
session can change for one page view; users.language is what the account
chose. If you want a change of interface language to become the stored
preference, write it where the choice is made:
// Wherever your application lets a user pick their language.
$user = \Pramnos\User\User::getCurrentUser();
if ($user) {
$user->language = $chosen;
$user->save();
}
One place, visible in a diff, and it does not fire on an account that was merely looked at.
User Data Management¶
$user = new \Pramnos\User\User(123);
// Get user data as array
$userData = $user->getData();
// Update user information
$user->email = 'newemail@example.com';
$user->save();
// Delete the users row only
$user->deleteuser();
// Erase the account and everything the framework holds about it (GDPR)
(new \Pramnos\Auth\AccountErasure($database))->erase((int) $user->userid);
deleteuser() removes the users row and nothing else. For a person's data, use
AccountErasure — see Erasure.
A property that is not a column is a userdetails row. $user->kept_note = 'x' reaches
__set() and save() writes it as a userdetails row for that user. A name that begins with
_ never is, whether or not it was loaded: it is the place for a subclass's private cache (a
loaded model, a computed value), which must not become a row on every save.
userlog is deprecated¶
userlog holds one row per event about a user. It records the user the row is about but
never who acted, so an administrator's change to an account is indistinguishable from the
user's own. It is also a second store beside
pramnos.changelog_events,
which records the entity, the actor and the source, with details as JSON, for users and
everything else alike. Two stores for the same events means every reader has to consult
both.
- No framework migration creates it. A new installation has no
userlog. - An installation that already has it keeps it. The framework never drops it, and the DevPanel's user log shows its rows wherever the table exists.
- The framework does not write to it. Record user events in
changelog_events, throughModel::logEvent(), with thechangelogfeature enabled.
An application that still writes to userlog keeps working on its existing databases. On a
new installation it has to create the table in one of its own migrations, or move those
writes to changelog_events.
JWT Token Authentication¶
Generating JWT Tokens¶
// Create JWT token for user
$payload = [
'userId' => $user->userid,
'username' => $user->username,
'exp' => time() + 3600, // Expires in 1 hour
'iat' => time(), // Issued at
];
$secret = 'your-secret-key';
$token = \Pramnos\Auth\JWT::encode($payload, $secret, 'HS256');
Validating JWT Tokens¶
try {
$secret = 'your-secret-key';
\Pramnos\Auth\JWT::$leeway = 60; // Allow 60 seconds clock skew
$decoded = \Pramnos\Auth\JWT::decode($token, $secret, ['HS256']);
// Token is valid, load user
$user = new \Pramnos\User\User($decoded->userId);
} catch (\Exception $e) {
// Token validation failed
echo "Invalid token: " . $e->getMessage();
}
OAuth2 access tokens get the same 60 seconds. The resource server that
OAuth2ServerFactory::createResourceServer() builds accepts a token whose iat or nbf
is up to OAuth2ServerFactory::CLOCK_LEEWAY_SECONDS ahead of its own clock. Without that
allowance, a token is refused as "not yet valid" whenever the server that checks it is a
second behind the one that issued it. That happens with a resource server on another host,
or on the same host after its clock is corrected backwards, which a virtual machine's
clock routinely is. Expiry is checked with the same allowance.
API Authentication with JWT¶
// In API controllers, JWT is validated by the auth middleware before the
// controller runs. Ask who the request is — never read $_SESSION.
class ApiController extends \Pramnos\Application\Controller
{
public function secureEndpoint()
{
$user = \Pramnos\User\User::getCurrentUser();
if (!is_object($user) || (int) $user->userid < 2) {
return ['status' => 401, 'message' => 'Authentication required'];
}
return ['status' => 200, 'data' => 'Protected data for user ' . $user->userid];
}
}
Do not read $_SESSION in an API controller
An application that serves a website and an API from one origin shares a
session cookie between them. $_SESSION['user'] therefore answers with
whoever is signed in to the website, which is not the caller — a request
that presented no credential at all would be authenticated, and logout
could not work, because revoking the token would leave the cookie answering.
User::getCurrentUser() asks the request instead: an API request seals its
own identity (see below), and a sealed answer — including "nobody" — stops
the session being consulted at all.
Where a request's identity comes from¶
The framework keeps two ideas apart, and the separation is what makes a hybrid application safe:
| website | API | |
|---|---|---|
| credential | session cookie | token on the call |
| established by | login, stored in the session | auth middleware, per request |
| read with | User::getCurrentUser() |
User::getCurrentUser() |
| may write the session | yes | no |
Pramnos\Http\RequestIdentity is how a middleware says "this call is user X, or
nobody, and nothing else may answer":
RequestIdentity::seal($user, 'accessToken'); // this request is $user
RequestIdentity::seal(null); // this request is anonymous
Both ApiAuthMiddleware and UnifiedAuthMiddleware do this for you. Sealing is
request-scoped and writes nothing that outlives the call, so an API request can
never change who the browser's next page belongs to — and an anonymous API call
can never sign the browser out.
An API that genuinely needs both — an authserver whose own web UI calls its
own endpoints — should use UnifiedAuthMiddleware, which accepts a Bearer token
or a session cookie plus an X-CSRF-Token header. The CSRF token is not
optional there: a cookie is sent by the browser automatically, so without it any
site could make authenticated calls on the user's behalf.
The data export, section by section¶
buildExportData() reads a table per section, several belonging to features an installation may
not have enabled. Each section is resolved on its own:
foreach ([...'privacy_settings' => fn () => $this->getPrivacySettings($userId)...] as $section => $read) {
try {
$export[$section] = $read();
} catch (\Throwable $unreadable) {
$export[$section] = [];
Logger::log('Data export: the ' . $section . ' section could not be read …', 'auth');
}
}
Each section is read on its own so that one unreadable table cannot abort the whole export. Somebody exercising a data-subject request would otherwise get an error, and every readable section would go with it. For a document an installation is legally obliged to produce, that is the wrong failure: partial is worth more than nothing.
Two details of the recovery are deliberate:
- The section stays, empty. A missing key reads as this framework has no such concept; an empty one reads as you have none of it. Only the second is something the installation is entitled to say.
- The failure is logged. In the file, "you have none" and "we could not tell" are indistinguishable — so they have to be distinguishable to the operator, who is the only person who can do anything about it.
A listener contributes its own sections through account.data_export, and cannot overwrite a
core one: array_key_exists before writing, so an export's profile always comes from the
framework.
Erasure: account.data_erase¶
The other half of Article 17, and it fires before the framework deletes anything:
\Pramnos\Event\Event::listen('account.data_erase', function (int $userId) {
// This application's rows for $userId.
return true; // false stops the erase, with nothing deleted
});
Before, not after, and that is not a preference. An application's rows almost always
carry a foreign key to users; if the framework deleted the user first, its own
DELETE FROM users would fail on the children and what is left is an account half erased
— which is worse than one that was never started.
A listener returning false stops the erase: Event::fire() short-circuits at the first
one, so the listeners after it do not run either, and eraseUserData() raises.
deleteaccount() already turns that into an error on the page. Use it for a retention
obligation the application cannot ignore, not for "this is inconvenient".
Two things the framework deliberately does not decide:
- Whose data it is. In a multi-tenant application the rows belong to the organisation, not to the person, and deleting the last member of a tenant is a different act from deleting a colleague's login. There is no default that is right for both, so the listener owns it.
- What cascades. Nothing cascades into a table with no foreign key, which is most hypertables — a metrics table keyed by a tenant id has no constraint to follow. Delete those explicitly; they are usually the largest thing left behind and the easiest to forget.
What the framework deletes¶
The erase is Pramnos\Auth\AccountErasure, which Account::eraseUserData() calls and which
any code that removes an account can call too:
After the listeners, it deletes the account's rows from each of these tables, explicitly,
because none of them has a cascading foreign key to users on every installation:
| What | Tables |
|---|---|
| Sign-in | usertokens, authserver.user_twofactor, authserver.twofactor_setup, authserver.passkey_credentials, authserver.trusted_devices |
| Consent and privacy | authserver.oauth2_user_consents, authserver.user_privacy_settings, authserver.user_activity_log |
| What the person held | role assignments (Role::assignmentTable()), organisation memberships (Role::membershipTable()), userstogroups |
| Notifications | notifications addressed to a user class, pramnos.pushsubscriptions, pramnos.pushlog, pramnos.pushtests |
| The assistant | chat_conversations with every message in them, chat_memory, chat_asks, ai_consents, user_dashboards; its scheduled tasks (ai_routines) with their runs and readers, and its own report subscriptions. llm_calls keeps each call's cost and loses its userid, prompt, messages and reply |
| Elsewhere | invitations it sent or came from, its mailing-list rows, and finally users |
Scheduled tasks meant to outlive the account are handed over first. The delete-account page
(Account::deleteaccount()) asks, organisation by organisation, who takes over the person's
tasks there. Reports the organisation receives are proposed for the first member listed. The
person's own tasks go only if they tick it, and with nobody to take them, they are deleted. The
choice travels with the deletion form (tasks[org][to], tasks[org][personal]), and the
hand-over runs before the erase, so what was handed over is no longer theirs to delete. See
Scheduled tasks.
A table the installation does not have is skipped and logged. A notification addressed to
something else with the same id, such as an organisation, is left alone. The change log is
not touched: its userid is the author of a change to some other record.
The password-reset token¶
Auth\Controllers\Account stores it in userdetails rather than in a table of its own — the
same shape the new-device auth link uses, and for the same reason: it is the same kind of thing
with the same lifetime, and a second mechanism would be a second place to get the expiry check
wrong.
| Stored | password_reset_hash = sha256(token), password_reset_expires = a unix time |
| Lifetime | one hour |
| Live tokens per account | one — upsert on (userid, fieldname), so "send it again" replaces the previous link rather than adding a second |
| Cleared | when the password is actually changed, and when an expired one is resolved |
Only the hash is stored. A leaked userdetails row hands out no working links, which matters
more here than for most tokens: this one lets the holder choose a new password.
Resolving a token does not spend it¶
Easy to assume otherwise, and the assumption would be the wrong design:
$userId = $this->consumeResetToken($token); // resolves — does not clear
// … validate the new password …
$this->updatePassword($userId, $new);
$this->clearResetToken($userId); // spent here, after the change
resetpassword needs to know whose link it is before it can validate anything. Burning the
token at that point would mean somebody who mistypes their confirmation loses their only link and
has to start again from the forgot-password form — and the order matters the other way too: the
token is cleared after the password is written, so a failed write does not leave an account
with neither a password change nor a live link.
The single-use property is real. It lives in the flow, not in the lookup.
The mail is in the recipient's language¶
Not the language of the request. The forgot-password form can be submitted by anybody from any page — a Greek visitor asking for an English speaker's reset — and the person who reads the mail is the account holder.
Notifier does this for every notification; this mail is composed by hand, so it asks for the
same thing itself. A language the installation has no catalogue for is not switched to and the
mail still goes: there being nothing to switch to is not a reason to withhold somebody's reset
link.
A catalogue defines $lang; it does not return an array.
Language::load() includes the file and then checks isset($lang) — so a return [...] file
loads without error, defines nothing, and load() answers false. Nothing raises, nothing is
logged, and every string renders as itself, which looks like a site written in English rather
than a catalogue that was never loaded.
The forgot form answers the same either way¶
forgotpassword() renders the same "if that address has an account, we have sent a link"
whether or not one was found. Any difference — a different message, an error only one path can
produce, a redirect on one and a render on the other — turns the form into a way to ask the site
who its users are, one address at a time.
The difference is on the inside: a token is written and a mail composed for the address that exists, and nothing happens for the one that does not.
Two refusals come before that, and neither issues anything:
- the anti-CSRF token, because this form mails an address the submitter chose. Without it, a page anybody writes can make a signed-out visitor's browser ask this site to send mail, as often as it likes;
- the human check (
auth.security.human_check), for the same reason at scale — a form that mails a stranger on demand is the cheapest way to use a site to deliver unwanted mail. The typed address is kept in the re-rendered form: a refusal that also clears the field trains people to give up rather than to try again.
A refusal must not spend the link¶
Every refusal in resetpassword() leaves the token usable, and that is a property worth stating
because two of them are easy to get wrong in opposite directions:
| What happened | The link afterwards |
|---|---|
| no anti-CSRF token | still usable — the token is not resolved before the check |
| password fails the policy | still usable — resolved, but only cleared on success |
| password changed | spent |
| link already spent, or invented | nothing to spend |
The failure mode of getting either wrong is quiet: the account holder is told "that link is not valid", the link in their mailbox was valid a moment ago, and nothing in the message explains why one mistyped confirmation cost them a second trip through the mailbox.
Who cannot be sent one¶
findUserIdByEmail() answers null at or below userid 1 — 0 is anonymous and 1 is the system
user, which exists to own rows rather than to be signed into. A reset link for it would be a
password-choosing link for the account the framework itself acts as.
Token Management¶
User Tokens¶
The framework supports various token types for different purposes:
$user = new \Pramnos\User\User(123);
// addToken($tokentype, $token, $notes = '', $parentToken = null, $expires = null)
// returns $user, so calls chain; read tokens back with getToken() / getAllTokens().
// Add authentication token
$user->addToken('auth', bin2hex(random_bytes(32)), 'API access token');
// Add Apple Push Notification token
$user->addToken('apns', $deviceToken, 'iPhone device');
// Add a token issued under a parent token
$user->addToken('access_token', $oauthToken, 'OAuth2 access', $parentToken);
Token Operations¶
$user = new \Pramnos\User\User(123);
// Get user's active auth token
$authToken = $user->getToken();
// Get all user tokens
$allTokens = $user->getAllTokens();
// Load user by token
$user->loadByToken($tokenString, 'auth');
// Clean up old tokens (older than 30 days)
\Pramnos\User\User::cleanupAllAuthTokens(30);
Web-session tokens, and how they stop¶
A web_session token is created on every successful web login and lives in
$_SESSION['usertoken'], so same-origin AJAX is authenticated without a Bearer token.
One login, one row.
That row carries an expiry — 30 days by default, set at creation:
Generous next to the PHP session it belongs to, whose own idle timeout
(session.gc_maxlifetime) is 24 minutes out of the box, and short enough that the table
stops being append-only.
And something retires them. auth:token-cleanup marks every session-bearing token idle
for more than a month as status = 2 — kept for the audit trail, no longer accepted — and
the framework schedules it daily:
php pramnos auth:token-cleanup # the same thing by hand
php pramnos auth:token-cleanup --days=90 # be more patient
lastused is updated on every request that presents a token, so "idle for a month" means
nothing has used it for a month.
And a login retires what it replaces, immediately — a token superseded a month ago is
still a valid bearer credential until the cleanup reaches it, which is not good enough for
a credential that has been replaced. createWebSessionToken() retires two things before it
writes the new row:
- the token this request arrived with, from
$_SESSION['usertoken']; - every other live
web_sessiontoken from this same browser, matched on the device fingerprint insidedeviceinfo.
The fingerprint and nothing else. deviceinfo also carries the address the token was
issued from, and matching on the whole stored value would leave the older token valid after
a router reboot between two sessions — the dynamic-address problem that SignInFingerprint
exists to avoid. Tokens from other
devices are left alone: signing in on a laptop must not sign you out on a phone.
loadByToken() reads an expiry of 0 or NULL as "never". Without the expiry and the
cleanup, usertokens grows by a row per login for ever — and it is the table tokenactions
points a foreign key at.
What a logged request records¶
Token::addAction() holds a row and the request completes it. Two paths do that:
| Path | Completed by | Records |
|---|---|---|
| API | updateAction(), once the response is known |
status, duration, response body |
| Web | the shutdown flush | status (http_response_code()), duration since addAction() |
The endpoint is stored as a path — /api/v1/stations, not
https://host/api/v1/stations?token=…. urls is a deduplicated registry, and a query
string that carries an id or a hash gives every call a row of its own. The query is not
lost: it goes into params, where a request's inputs belong, whenever params would
otherwise be empty — which is every GET.
A negative $return_status still means "this happened, do not record what it returned":
the row is written with no status and no duration.
Both matter to the DevPanel's slowest-endpoints report: it needs a duration on every row, and it groups by endpoint, so an absolute URL with its query would make every call an endpoint of its own.
updateAction() repairs a tokenactions older than the columns it writes¶
An installation whose tokenactions predates return_status, execution_time_ms, return_data and
action_time gets them added on the first API call after the upgrade, from inside the catch that
handled the failed UPDATE. Then the write is retried.
You need to do nothing. It is documented because of when it runs: once, on a production database, during a request, in an error path — so it is worth knowing it exists, and it is tested on both engines.
Two details, both of the kind only a real database reveals:
- It asks the schema builder per column and per index.
ADD COLUMN IF NOT EXISTSis MariaDB, not MySQL — MySQL 8 rejects it as a syntax error, which inside thiscatchwould fail the request, lose the audit row and leave the table unrepaired. - On PostgreSQL the sync trigger treats only
servertime > 0as a real instant.servertimeis declaredDEFAULT 0, so a row written without one arrives as a zero rather than a null; reading that as a time would stamp the row 1 January 1970, and «no time was given» would be indistinguishable from «this happened at the epoch».
The PostgreSQL branch also installs a plpgsql function and a BEFORE INSERT OR UPDATE trigger that
keep servertime and action_time in step both ways, so code that writes only one of them does not
leave the other stale. The order inside it is load-bearing: the back-fill from servertime has to
happen before action_time becomes NOT NULL, or the ALTER fails on every existing row — after three
other ALTERs have already gone through.
deleteToken() revokes one device, and the cascade is MySQL-only¶
Status 2 with a removedate, not a deleted row — a revoked token is evidence. «This credential
stopped working at 14:12» is the answer to «was my account used after I signed out of that laptop», and
a deleted row cannot answer it.
The WHERE carries the user id as well as the token id, and that is not defensive tidying: token
ids are sequential integers that appear in URLs, so a method trusting the id alone would let any
authenticated request sign any account out, repeatedly, by counting.
One difference between the engines, and it is deliberate rather than an oversight. On MySQL the
revocation also takes any token whose parentToken is the one named; on PostgreSQL it takes only the
named token. parentToken is how a refreshed credential remembers the one it replaced, so cascading is
the behaviour you want — revoking what a device holds should take the refresh chain hanging off it, or
the device renews itself straight back in. If you rely on that, rely on it on MySQL only.
«Sign out everywhere else» keeps the credential you are holding¶
revokeOtherSessions(?string $keepSid = null): int spares the caller only when it is given the
current session's id (md5(session_id()), as the sessions table stores it). With it, the
session row with that id is kept, and so is the web_session token in $_SESSION['usertoken'] —
the session is the only place that knows which credential this request arrived with. Called
with no argument it ends every session and revokes every web_session token of the account,
the caller's included. It returns how many session rows it ended.
Passing the id is what a "sign out everywhere else" button needs: a button that signed the caller out as well is the bug a user notices immediately — they press it, they are ejected, and the natural conclusion is that the button is broken rather than that it worked.
Working with Token Objects¶
// Create token object
$token = new \Pramnos\User\Token();
$token->userid = $user->userid;
$token->tokentype = 'auth';
$token->token = bin2hex(random_bytes(32));
$token->notes = 'Mobile app access';
$token->expires = time() + (30 * 24 * 60 * 60); // 30 days
$token->save();
// Load existing token
$existingToken = new \Pramnos\User\Token($tokenId);
$details = $existingToken->getDetails();
// Track token usage
$existingToken->addAction(); // Logs the current request
Scopes on a token, and the shape of the column¶
$token->scope is always a list of strings, whatever the row holds.
$token = new \Pramnos\User\Token($tokenId);
if (in_array('mcp:db_read', $token->scope, true)) {
// …
}
To write scopes, either set the property and save(), or issue the token in one call:
Four shapes are in that column across installations:
| Shape | Where it came from |
|---|---|
profile email |
RFC 6749 §3.3, and what AccessTokenRepository writes |
["profile","email"] |
a JSON list, from older Token::save() rows |
profile,email |
comma-separated, from older rows |
[profile email] |
brackets round a space list — a stringified array somewhere upstream |
The rule is asymmetric on purpose:
| Reading | accepts all four, permanently — those rows exist and nobody is migrating somebody else's database |
| Writing | emits the standard, space-separated form only |
Token::parseScopes() is the one parser, and it is public so that anything reading the
column directly uses the same one rather than writing a second:
Do not explode(' ', …) a raw column value. That reads one of the four shapes, and
which one you get depends on how the row was written rather than on anything the caller
did. Every reader in the framework delegates to parseScopes(), so one installation gives
one answer whichever code path reaches the row first.
Which values get the forgiving parse, and which do not¶
| A column, or a stored token's scope | Token::parseScopes() |
An OAuth request parameter ($_GET['scope'], the token endpoint's scope) |
space-delimited, as the RFC defines |
The forgiveness exists for rows already in the column. It is not a licence for a client to invent a syntax: a request is a live conversation with
somebody who can be told they are wrong, so Scopes::hasInvalidScopes() still reads the
standard form only and reports profile,email as one unknown scope.
A value that is not a scope list at all — null, an object, a bare number — parses to an
empty list rather than a list containing it. json_decode('123') is a valid document and
an int; a scalar where a list belongs would make in_array($needed, $scopes, true) a
TypeError on PHP 8 — a 500 where a scope check should be. An empty list makes it a plain
refusal.
Permissions System¶
There is one permission store: authserver.permissions. It is created by
the auth feature's migrations, so every installation that has users has it —
running an OAuth server is not a prerequisite. Two APIs read it, and which one
you want depends on how much of the model you need:
Pramnos\Auth\Permissions |
Pramnos\Auth\PermissionResolver |
|
|---|---|---|
| Shape | one question, one answer | the user's whole effective grant list |
| Reads | grants for a user or role | grants, roles, priorities, expiry, audience, conditions |
| Writes | yes — allow() / deny() |
no |
| Use it for | a check in a controller | anything conditional, scoped or audited |
Permissions is the simple face; it cannot express what it cannot ask about.
Grants carrying ABAC conditions are skipped by it rather than treated as
unconditional — the resolver hands conditions to the application to evaluate
against its own request context, and this API has no way to receive one.
Setting Permissions¶
allow(), deny() and removePermission() are instance methods; get the
shared instance from the factory.
$permissions = \Pramnos\Auth\Permissions::getInstance();
// Grant a user the right to create articles
$permissions->allow(
$userId, // Subject — a user id
'articles', // Resource → object_type
'create', // Privilege → action
'', // Element: '' means all articles → object_id NULL
'module', // Resource type (not stored; see below)
'user' // Subject type: user | group
);
// Grant a group — stored as a role, which is what the new model calls it
$permissions->allow($editorsRoleId, 'articles', 'edit', '', 'module', 'group');
// Deny — stored above allow so it wins a tie
$permissions->deny($userId, 'admin', 'access');
// Remove — absence is not the same answer as deny
$permissions->removePermission($userId, 'admin', 'access');
Two mappings worth knowing: the admin privilege is stored as the * action
(everything on this object), and resourceType has no column in the new model —
object_type already carries that distinction.
A subject type other than user or group cannot be represented. Rather than
store it under the wrong subject_type, the call is refused and logged.
Checking Permissions¶
$permissions = \Pramnos\Auth\Permissions::getInstance();
// Default: an unknown permission is "no"
if ($permissions->isAllowed($userId, 'articles', 'create')) {
$this->showCreateForm();
} else {
throw new \Exception('Insufficient permissions', 403);
}
Pass false as the last argument when you need to tell "denied" apart from
"nobody ever said":
$verdict = $permissions->isAllowed(
$userId, 'articles', 'create', '', 'module', 'user', false
);
// true = allowed, false = explicitly denied, null = no rule at all
That distinction matters. Collapsing null into false is how a screen ends up
refusing a user for whom no rule was ever written.
Users with a role per organisation. isAllowed() reads the user's roles for the
organisation the request is about once the application has set
OrganizationScope::resolveWith(); for one named organisation, use
isAllowedInOrganization($userId, $organizationId, 'articles', 'create'). See
Checking permissions in an organisation.
Roles¶
The new model expresses groups as roles, and PermissionResolver folds a user's
active roles into their effective permissions automatically. Assign one by
inserting into authserver.user_roles; role definitions live in
authserver.roles.
For the full picture — priorities, expiry, audience scoping and ABAC conditions — use the resolver directly:
$resolver = new \Pramnos\Auth\PermissionResolver($database);
$effective = $resolver->resolve($userId, null)['permissions'];
Each entry carries object_type, object_id, action, grant and
conditions, with deny-over-allow already applied.
Session Management¶
Basic Session Operations¶
$session = \Pramnos\Http\Session::getInstance();
// Check if user is logged in
if ($session->isLogged()) {
$userId = $_SESSION['uid'];
$username = $_SESSION['username'];
}
// Create snapshot for post-login redirect
$session->snapshot($_SERVER['REQUEST_URI']);
// Get and clear snapshot
$returnUrl = $session->getSnapshot();
if ($returnUrl) {
$this->redirect($returnUrl);
}
Session Security¶
$session = \Pramnos\Http\Session::getInstance();
// Get session token for CSRF protection
$token = $session->getToken();
// Validate CSRF token
if ($session->checkToken('post', 'csrf_')) {
// Token is valid, process request
} else {
// Invalid token, possible CSRF attack
throw new \Exception('Invalid security token', 403);
}
// Reset session (for logout)
$session->reset();
Handing the browser's user an API token¶
A hybrid application — a session-authenticated site and a token-authenticated SPA on one origin — has two credentials with two lifetimes. The symptom is always reported the same way: "I am signed in on the site; if I leave it a while and then open the panel, it asks me to log in again." The site knows who they are; the panel has no way to ask.
use Pramnos\Auth\SessionExchange;
$token = SessionExchange::issue(minimumUserType: 90, ttl: 43200);
if ($token === null) {
return \Pramnos\Http\Response::redirect(sURL . 'login');
}
return \Pramnos\Http\Response::redirect(
SessionExchange::redirectUrl(sURL . 'panel/', $token)
);
Put that on a session-authenticated route. It answers null when nobody is signed in,
when the minimum is not met, or when there is no usable signing key — a route that
redirects sensibly either way is the intended caller.
The key it signs with is the API's, not its own front controller's¶
sURL is dirname(SCRIPT_NAME), so it is not one string: the API front controller
(www/api/index.php) sees https://host/api/ and every other request sees https://host/.
The signing key is derived from it, so an exchange running on a session-authenticated MVC
route derived a different key from the one the API verifies with — every exchanged token
refused with 403 InvalidAccessToken, which names neither the key nor the mistake.
Api::baseUrl() is the answer to that: the API's own base in every context — sURL
unchanged inside an API request, and sURL plus the API's directory anywhere else. Both
SessionExchange and mcp:token derive through it, so a token minted outside an API request
verifies inside one.
A project that serves its API from somewhere other than www/api/ says so:
// app/app.php
'api' => [
'prefix' => '/api/1.0',
'directory' => 'api', // where www/<directory>/index.php lives
],
The directory is deliberately not guessed from prefix: the prefix is a route namespace and
the directory is a place on disk, and a project is free to make them differ.
Only a session may be exchanged¶
A request whose identity was proved by anything other than a session is refused, whatever role it holds.
That is not a formality. User::getCurrentUser() prefers a sealed identity over the
session, so without the check an API request carrying a bearer token reached the minting
path and received a fresh one — a refresh, with rotation and revocation questions this
method answers neither of. Every twelve-hour token good for another twelve hours on
request, forever, from a method documented as exchanging a session.
If your application wants refresh tokens, that is a separate mechanism with a separate policy. This is not it.
The signing key¶
The token is signed with the same key the API verifier uses, resolved in this order:
- the current application's
authenticationKey, if it declares one —Apicomputes its own in the constructor, and an application may set one explicitly; - otherwise
Api::deriveAuthenticationKey(), which is the identical derivation the API itself performs.
The fallback is the normal path, not the exceptional one, and the reason is worth
knowing if you are reading this because an exchange returned null: authenticationKey is
declared on Api, not on Application. A session-authenticated MVC route — which is the
only kind that can call this — has an Application, so reading the property alone would
find nothing.
There is one case with no answer: no declared key and no sURL. The derivation then
reduces to md5('edge'), a constant every installation in that state would share, so a
token from any of them would verify against all of them. That is refused rather than
signed. A real request always has sURL.
A declared key must also be long enough for JWT::encode(), which rejects a short one
outright — the exchange reports that as null and logs the reason.
Not UnifiedAuthMiddleware¶
That solves the other direction: it lets an API endpoint accept a cookie plus a CSRF token. Reaching for it here has a cost worth naming — it makes the API authenticate with cookies, which quietly invalidates every decision an application made because it does not. A permissive CORS default is the usual one, and it was introduced a long way from where it would break.
An exchange goes one way, at one moment, and the API still never reads a cookie.
The four decisions it makes for you¶
Three are only wrong in ways nobody notices.
| Decision | Why it is not the caller's to make |
|---|---|
| The role is re-read from the database, not taken from the session | a remember-me cookie can outlive a demotion by a fortnight, and a token minted from that session is then good for its whole lifetime |
| The token travels in the URL fragment | a fragment is never sent to a server. ?token= works, reviews identically, and writes the credential into the access log of every hop and into Referer |
| Nothing is issued for an anonymous caller, a guest, or user id 0/1 | no implicit token, no partial credential; ids 0 and 1 are the guest by convention, and a token minted for one would authenticate as "somebody" everywhere |
Failure is null, not an exception |
the caller is a route that has to redirect somewhere either way |
The claim set matches the API login's, so an exchanged token is indistinguishable to
every verifier. The row is recorded with notes = 'session_exchange', so a session list
can say where the credential came from.
The activity-log entry is written once per session, not once per exchange. Every other entry in that log is a decision somebody made — signed in, changed a password, added a passkey — and each occurrence is worth a row. An exchange is what happens when a page opens without a token, so a new tab, a hard refresh, a private window and an expiry would each produce one, and the entries a person can act on would drown in them. What is audit-worthy is that this session obtained an API credential, and that is one fact however often the page asks again.
Every issued token carries a jti. The other claims — iss, aud, iat, nbf, exp
— name no user and are identical for anything minted in the same second, so without it two
tokens issued in the same second would be the same string, for two different people as readily
as for one. usertokens.token_lookup is unique, so the second insert would fail and that
sign-in would do nothing.
Nothing verifies the jti; a token is still resolved to its user by its usertokens row.
The one decision that stays yours¶
An SPA that bounces to the exchange route when it has no token must record that it has bounced before redirecting, not after. The route redirects back without a fragment when it cannot help, so a flag written afterwards is an infinite bounce — on the one page an operator opens when something is already wrong.
And clear the fragment once adopted (history.replaceState), or the token survives in
browser history and in whatever a visitor pastes when asking for help.
Requests that are somebody without being an account¶
RequestIdentity::seal() models an account or nobody. That is right for an API,
where anonymous means no identity at all, and not enough for an application whose
unauthenticated callers are people: a chat participant with a nickname and a session,
present in a room, mutable, bannable, addressable, and the same person across requests
for as long as they stay.
RequestIdentity::sealGuest($presenceId, 'presence');
RequestIdentity::isGuest(); // true
RequestIdentity::user(); // null — an account is still an account
RequestIdentity::guestId(); // the opaque id
RequestIdentity::subject(); // the id, whichever kind of identity this is
subject() is the reason to use this rather than a parallel mechanism: one question, one
answer, for all three states. Without it an application keeps a second notion of who the
caller is, and every consumer has to know which of the two to consult.
The id is opaque and the framework does not interpret it. A presence row, a signed cookie, a hash of a nickname and a session are all yours.
Three rules the framework enforces, because each is only wrong invisibly:
- A guest never replaces an account.
sealGuest()on an already-authenticated request is refused and logged. A middleware that seals a guest unconditionally, ordered after the one that authenticates, would otherwise demote the caller and every later permission check would answer for the wrong person. - An account does replace a guest, because that is a real login — and a request must not end up holding both identities.
user()keeps returning null. A guest is not ausersrow, and code asking for a user is not handed something that resembles one. That is whyisGuest()is a separate question.
An empty id is refused: every such guest would be indistinguishable, so a mute, a ban or a rate limit keyed on it would apply to all of them at once.
What a session record contains¶
Every token in usertokens carries a deviceinfo column, JSON-encoded:
Written for every token type at creation — session, API, OAuth access and refresh — so the active-sessions list can show a person which of their devices a session belongs to.
Token decodes it into an array. Both writers agree on the format — User::addToken()
at creation and Token::save() afterwards both json_encode(). The unserialize()
branch in Token::fillProperties() is a reader for rows written by an older path, not a
second format anything produces today.
It is written once, at creation, and never rewritten. It records the device the token was issued to, which is what makes it comparable later — and what makes a token used from a browser it was not issued to visible as such.
Token::addAction() does not touch it. A column rewritten on use would hold a different
shape depending on whether a token had ever been used, the session retirement could not match
a used token, and a token used from a browser it was not issued to would have its record
rewritten to say the new browser — destroying the evidence it appears to collect.
It stores the fingerprint, not the raw user agent, and that is the same decision as
in the section below: keeping the agent string would make every session look like a new
device after any browser update, turning a list meant for recognition into a list of
strangers. The ip is recorded for an administrator investigating an incident — it is
never used to decide anything.
New sign-in alerts¶
When enabled for an account, a sign-in from a browser/platform combination that account has
not used before triggers one email. Whether it is enabled follows the site policy,
auth_newsignin_policy (see
Telling an account it was used from somewhere new):
under optin (the framework default) it is off until the account turns it on; under optout
(what pramnos init writes for a new project) it is on until the account turns it off;
always and off override the account's choice.
use Pramnos\Auth\NewSignInAlert;
NewSignInAlert::setEnabledFor($userId, true); // the account's own preference
NewSignInAlert::isEnabledFor($userId); // the preference read through the site policy
The built-in Account controller exposes it as a checkbox on the privacy page, beside the analytics and marketing consents, in all three scaffolded themes.
What counts as "new", and what deliberately does not¶
Pramnos\Auth\SignInFingerprint reduces a User-Agent to a browser family and a
platform family — chrome|windows, safari|ios — and nothing else.
| Signal | Used? | Why |
|---|---|---|
| Browser family | yes | changes when a person actually uses a different browser |
| Platform family | yes | changes when they use a different kind of device |
| Browser version | no | Chrome and Firefox ship a major version about every four weeks. Including it is a monthly alarm for every user |
| Operating system point release | no | changes without the person doing anything |
| IP address | no | dynamic on most consumer connections. An alarm on a changed address fires on a router reboot, and by the second week nobody reads it |
The cost, stated plainly: two Chrome-on-Windows machines are indistinguishable, so signing in from a colleague's identical laptop raises nothing. That is the price of an alarm that stays rare, and rarity is the whole value — a security notification people learn to ignore is worse than none. If you need finer granularity, add a signed device cookie; that is the tool built for the job, and narrowing this fingerprint is not.
The current sign-in is excluded once, not everywhere¶
The history is read after the login lifecycle has logged this sign-in, so the current fingerprint is already in it and would always match itself. It is therefore excluded — but only one row of it.
Excluding every row carrying it looks equivalent and is not. An account that uses a laptop and a phone has both in its history: signing in from the laptop removed all the laptop's rows and left the phone's, which is a non-empty history missing the current device — the definition of "new". So both devices alerted on every single sign-in, forever, which is exactly the alert-nobody-reads failure this feature exists to avoid.
One row is what the current sign-in contributes, so one is what is dropped. If it has not been logged yet, the row dropped is an earlier sign-in from the same device and any others still mark it familiar; only a device being used for the second time ever can still look new, which is the conservative direction.
Where the history comes from¶
authserver.user_activity_log, which has recorded a user agent against every login
since the auth feature shipped.
That is load-bearing rather than incidental. A device detector with no history says everything is new, so on the day it is switched on, every user who opted in is notified at once — about a sign-in they are performing right now. Reading an audit trail that already holds months of user agents means the first sign-in after upgrading is recognised as familiar, which it is.
For the same reason, an account with no history at all is treated as not new.
Only successful login rows count. login_failed carries a user agent too, and letting
a failed attempt make a browser familiar would turn the log into a way of switching the
alarm off.
Storage¶
The preference is a userdetails row (fieldname = 'notify_new_signin'), not a column
on user_privacy_settings — so no migration is needed and the feature works on
every installation the moment the framework is upgraded, including those whose
migration_cutoff skips baseline migrations. It inherits that table's cascade on user
deletion, which a GDPR-relevant preference needs anyway.
The email¶
Names the browser and the kind of device, and the time. It does not print the IP address: nobody recognises their own, and printing one invites exactly the compare-with-last-time habit this feature refuses to encourage. The address is in the audit log, for an administrator investigating an incident — the right audience for it.
It offers one action — if this was not you, change your password — and no link. A link in an unexpected security email is the shape of the attack it warns about.
Mail, and a push where the account has a subscribed browser. Not a database notification: that would put the warning in the panel of the session that triggered it, which in the case worth warning about is the wrong person. Push passes the same test — a browser receives one only because somebody granted permission in it earlier, so the subscriptions on an account are the owner's devices rather than the one the sign-in just happened on. See the Web Push Guide for the channel itself.
It is also the one notification the framework sends with an unsubscribe list
(unsubscribeList() returns newsignin), because it is the one that belongs to a list:
the account can already turn these alerts off on its privacy screen, so honouring an
unsubscribe flips that same checkbox. Everything else — a password reset, a security
change — is transactional, and offering to unsubscribe from a password reset is offering
to disable the only way back into the account.
The URL registry behind token actions¶
Every token action records which URL the token was used on, and urls is a deduplicated registry
— one row per endpoint, not one per call. Token::urlId() resolves a URL to its id, inserting the
row the first time that URL is ever seen on the installation.
It is called from the spool drain, which is a long-running process, so the cache in front of it is the design rather than an optimisation: a site serves a few hundred distinct URLs, a worker learns them in its first minutes, and after that every action resolves from memory.
Three properties worth knowing when reading a report built on this table:
- The cache is bounded at
Token::URL_CACHE_LIMIT(2,000), and drops its oldest half when it fills. A site that puts an id in the path or a search term in the query string generates URLs without limit, and a process that never restarts would otherwise grow the cache without limit too. The half that survives is the most recently used, because on a worker those are the URLs it is currently busy with — clearing everything would make the next minute of work re-resolve exactly what it had just learned. - The cache is per process; the registry is not. A second worker resolving a URL the first one registered finds the existing row rather than inserting a duplicate, so ids are stable across processes and a report can group on them.
- An unreachable registry resolves to
0, and the drain carries on.0is the "no URL" value the action row takes, so a missing or brokenurlstable costs the URL of an action rather than the whole batch — a gap in a report instead of a gap in the audit trail.
A consequence for anyone reading urls: a query string is not part of the key. The registry is
per endpoint, so /orders?id=1 and /orders?id=2 are one row. That is deliberate — the alternative
gave a busy page a new row on every call — and it means a report cannot tell those two apart from
this table alone.
Authentication Addons¶
Addon::load($name) takes a file name or a fully-qualified class name, and refuses
anything that is neither — a null or an empty string returns false before the name is used
for anything. isActive() and getAddon() do the same.
That is not defensive coding for its own sake: the addons setting holds a serialized list,
and an entry saved from an admin screen with no addon selected stores a null name — without the
refusal, every request would ask an impossible question per such entry.
User Database Addon¶
The framework includes a user database addon for standard username/password authentication:
// The UserDatabase addon is automatically triggered during authentication
// It checks credentials against the users table
// Custom validation can be added by extending the addon.
// Auth calls onAuth() with five arguments; the parent declares four, with defaults, so an
// override keeps those defaults and may add `$validate = true` as an optional fifth.
class CustomUserAuth extends \Pramnos\Addon\Auth\UserDatabase
{
public function onAuth($username, $password,
$remember = true, $encryptedPassword = false, $validate = true)
{
$result = parent::onAuth($username, $password, $remember, $encryptedPassword);
if (!empty($result['status']) && !$this->isAccountApproved((int) $result['uid'])) {
return [
'status' => false,
'message' => 'Your account is awaiting approval',
'statusCode' => 403,
];
}
return $result;
}
}
The second factor does not belong here: the login flow (LoginFlow) asks for it after the
password is accepted — see Second factors.
Creating Custom Authentication Addons¶
class LdapAuth extends \Pramnos\Addon\Addon
{
public function onAuth($username, $password,
$remember = true, $encryptedPassword = false, $validate = true)
{
// LDAP authentication logic
$ldapConnection = ldap_connect(
(string) \Pramnos\Application\Settings::getSetting('ldap_server')
);
if (ldap_bind($ldapConnection, $username, $password)) {
// Authentication successful
$userInfo = $this->getLdapUserInfo($ldapConnection, $username);
return [
'status' => true,
'username' => $username,
'uid' => $userInfo['uid'],
'email' => $userInfo['email'],
'auth' => password_hash($password, PASSWORD_DEFAULT)
];
}
return [
'status' => false,
'message' => 'LDAP authentication failed'
];
}
}
Controller Authentication¶
Protecting Controller Actions¶
class ArticleController extends \Pramnos\Application\Controller
{
public function __construct(?\Pramnos\Application\Application $application = null)
{
// Define public actions (no authentication required)
$this->addAction(['display', 'view']);
// Define authenticated actions (login required)
$this->addAuthAction(['create', 'edit', 'delete', 'save']);
parent::__construct($application);
}
public function display()
{
// Public action - anyone can access
return $this->getView('article')->display();
}
public function create()
{
// Authenticated action - user must be logged in
// Framework automatically checks authentication
return $this->getView('article')->display('create');
}
}
API Authentication¶
class ApiController extends \Pramnos\Application\Controller
{
public function __construct(?\Pramnos\Application\Application $application = null)
{
// API endpoints typically require authentication
$this->addAuthAction(['list', 'create', 'update', 'delete']);
parent::__construct($application);
}
public function list()
{
// Who this request is — established by whatever authenticated it, and
// not by a session cookie the caller never meant as a credential.
$user = \Pramnos\User\User::getCurrentUser();
if (!is_object($user) || (int) $user->userid < 2) {
return ['status' => 401, 'error' => 'Authentication required'];
}
// Return data specific to the authenticated user
return ['status' => 200, 'data' => $this->getUserData($user->userid)];
}
}
Advanced Features¶
Login Lockout (Brute-Force Protection)¶
Pramnos\Auth\Loginlockout — progressive brute-force lockout for login endpoints.
Tracks failed login attempts per scope+identifier pair. Three scopes are supported: 'user' (by user ID), 'identifier' (by normalized email/username), and 'ip' (by remote address).
Default thresholds¶
| Failures | Lockout duration |
|---|---|
| 3 | 60 s (1 minute) |
| 5 | 300 s (5 minutes) |
| 7 | 900 s (15 minutes) |
| 10+ | 3600 s (1 hour) |
A sliding window of 900 seconds applies: if the gap between the previous failure and the current attempt exceeds the window, the counter resets to 1. This prevents indefinite accumulation from past brute-force campaigns.
The OAuth password grant shares the 'identifier' counter with the login form: a wrong
password at /oauth/token counts toward it, a success clears it, and a locked account is
refused before its password is checked — 400 invalid_grant with a Retry-After header in
seconds. Otherwise the token endpoint would be a way round the form's lockout.
Configuring the ladder¶
Two application settings, both editable from the settings screen:
| Setting | Meaning |
|---|---|
loginlockoutsteps |
JSON map, {"attempts": seconds} — e.g. {"3":60,"5":300} |
loginlockoutwindowseconds |
the sliding window, 60–86400 |
Both are read on every attempt, so a change on the settings screen applies to the next failed sign-in.
An unusable loginlockoutsteps (not JSON, empty, or no usable pair) falls back to
DEFAULT_STEPS, and a window outside 60–86400 falls back to 900 — never to no
lockout. A malformed setting must not be a way to switch brute-force protection
off, and a window of zero would reset the counter on every attempt.
Lifting a lockout while developing¶
The lockout is doing its job when it locks somebody out — and that is no help when you have mistyped a fixture password three times and cannot test the login flow you are working on:
php pramnos auth:unlock admin # this identifier, every scope
php pramnos auth:unlock 2 --scope=user # by user id
php pramnos auth:unlock --list # who is locked, and for how long
php pramnos auth:unlock --all # everything (development only)
It clears the failure counter and nothing else — a wrong password is still a
wrong password afterwards. --all refuses to run outside development: "clear
every lockout on this server" is precisely what somebody working through a
password list would want.
Four behaviours worth knowing before you rely on them, each one asserted:
- A scope narrows it. The same value can be locked twice —
10.0.0.5as an identifier and as an IP — and--scope=ipclears only that one. Without a scope, every scope for that identifier goes. - An unknown scope is refused, with the valid ones named. A typo that silently unlocked nothing would read as that account was not locked, and the operator would move on believing the lockout had gone.
--listclears nothing, and reports the remaining time in a form somebody reads (1h 1m). "Locked" without a duration does not tell an operator whether to wait or to act, which is the only decision they have.- An expired lockout is not listed. The row stays — it is the failure history the progressive backoff counts — but listing it would send somebody to unlock an account that is already usable.
And unlocking something that is not locked succeeds: it is "is this account locked?" asked in the imperative, and the state afterwards is the state the operator wanted either way.
Usage¶
use Pramnos\Auth\Loginlockout;
$lockout = new Loginlockout();
// Check BEFORE processing the login attempt
$status = $lockout->getLockoutStatus('identifier', strtolower($email));
// Returns: ['locked' => bool, 'remaining' => int]
if ($status['locked']) {
return ['status' => 429, 'retry_after' => $status['remaining']];
}
// Attempt authentication ...
if ($authFailed) {
// Record failure for all applicable scopes
$lockout->recordFailedAttempt('identifier', strtolower($email));
$lockout->recordFailedAttempt('user', (string) $userId);
$lockout->recordFailedAttempt('ip', $ipAddress);
} else {
// Clear state on success
$lockout->clearSuccessfulLoginState('identifier', strtolower($email));
$lockout->clearSuccessfulLoginState('user', (string) $userId);
$lockout->clearSuccessfulLoginState('ip', $ipAddress);
}
API¶
| Method | Description |
|---|---|
recordFailedAttempt(string $scope, string $identifier): void |
Increment failure counter; apply threshold; creates row if absent |
getLockoutStatus(string $scope, string $identifier): array |
Returns ['locked' => bool, 'remaining' => int]; 0 remaining when not locked |
clearSuccessfulLoginState(string $scope, string $identifier): void |
Deletes the tracking row — fully resets counter and lockout |
Constants¶
| Constant | Value |
|---|---|
Loginlockout::DEFAULT_WINDOW_SECONDS |
900 |
Loginlockout::DEFAULT_STEPS |
[3=>60, 5=>300, 7=>900, 10=>3600] |
Two-Factor Authentication (TOTP)¶
Two classes: TOTPHelper (pure static, no DB) and TwoFactorAuthService (stateful, DB-backed).
Compatible with Google Authenticator, Authy, and any RFC 6238 TOTP app.
TOTPHelper — setup and verification¶
use Pramnos\Auth\TOTPHelper;
// Generate a new shared secret (once per user, during setup)
$secret = TOTPHelper::generateSecret(); // e.g., 'JBSWY3DPEHPK3PXP'
// Verify a user-submitted code (±1 window drift tolerance)
$valid = TOTPHelper::verifyCode($secret, $userCode); // bool
// QR code as an inline data URI — no external API calls, CSP-safe
$dataUri = TOTPHelper::getQRCodeDataUri($secret, 'user@example.com', 'MyApp');
// Returns 'data:image/svg+xml;base64,…' (requires chillerlan/php-qrcode ^5.0) or null
// Build the provisioning URI
$uri = TOTPHelper::buildOtpAuthUri($secret, 'user@example.com', 'MyApp');
// 'otpauth://totp/MyApp:user%40example.com?secret=…&issuer=MyApp'
// Backup codes
$codes = TOTPHelper::generateBackupCodes(10); // ['ABCD2345', ...]
$hash = TOTPHelper::hashBackupCode($codes[0]); // bcrypt hash for storage
$isMatch = TOTPHelper::verifyBackupCode($code, $hash); // bool, case-insensitive
// Utilities
$remaining = TOTPHelper::getRemainingTime(); // seconds until window expires [1, 30]
$isValid = TOTPHelper::isValidSecret($secret); // validate base32 format
Security note:
getQRCodeUrl()sends the TOTP secret to an external API and is deprecated. Always usegetQRCodeDataUri()instead.
TwoFactorAuthService — full setup flow¶
use Pramnos\Auth\TwoFactorAuthService;
$svc = new TwoFactorAuthService(); // uses Factory::getDatabase()
// Step 1 — generate secret and show QR code to user
$info = $svc->startSetup($userId, $userEmail);
// ['secret', 'qr_code_data_uri', 'qr_code_url', 'manual_entry_key']
// No backup codes here — see below
// Step 2 — user scans QR, enters first code to confirm
$success = $svc->completeSetup($userId, $submittedCode); // bool
// Step 3 — NOW show the backup codes, once
$codes = $svc->takeNewBackupCodes(); // string[]; [] when there are none
// Verify on login
$valid = $svc->verifyCode($userId, $code); // accepts TOTP or backup code
// State queries
$enabled = $svc->isEnabled($userId); // bool
$remaining = $svc->getRemainingBackupCodes($userId); // int
$status = $svc->getStatus($userId);
// ['enabled' => bool, 'setup' => bool, 'backup_codes_remaining' => int]
// Management — the password is required; the unchecked path has its own name
$svc->disable($userId, $password); // refused when wrong or empty
$newCodes = $svc->regenerateBackupCodes($userId, $password); // string[]|false
$svc->disableForOperator($userId); // administrative
$svc->regenerateBackupCodesForOperator($userId); // administrative, destructive
$svc->cleanupExpiredSessions(); // removes used/expired setup rows
The issuer is the name the authenticator app files the account under. startSetup()
takes it as a third argument and defaults to Pramnos, which names the framework rather than
the site. The TwoFactorAuth controller passes auth_brand_name, then sitename, then the
application's name from app.php — the same order the sign-in card brands itself with, so the
name on the card and the name in the app agree. Override issuer() to choose another. It is only
a label: changing it later renames nothing in an app that has already scanned the code.
Backup codes come from enrolment, not from setup. startSetup() deliberately
returns none: completeSetup() generates and stores the account's codes, so they can only be
shown after enrolment — codes shown any earlier would not be the ones stored.
takeNewBackupCodes() returns what enrolment stored, once (it survives one redirect, in the
session, and is cleared on read).
It is also the right moment to show them: somebody who abandons setup halfway should not walk away holding recovery codes for an account with no second factor.
The password is required, and the unchecked path has a name. disable() and
regenerateBackupCodes() both take the account password and verify it. Without that check,
any signed-in session could strip the second factor, or rotate the backup codes (which
invalidates every code the owner wrote down and prints ten new ones to whoever asked).
| Call | Meaning |
|---|---|
disable($userId, $password) |
the user's own action — refused on a wrong or empty password |
disableForOperator($userId) |
administrative — an operator clearing 2FA off an account whose owner cannot reach it |
regenerateBackupCodes($userId, $password) |
the user's own action |
regenerateBackupCodesForOperator($userId) |
administrative, and destructive: it invalidates every code the owner holds |
The password is not optional, deliberately. PHP discards an extra argument to a userland function, and with an optional parameter a caller that omits it would silently skip the check. A step-up check in front of removing the second factor is not something to skip by accident, so skipping it has to be spelled. An empty string counts as wrong rather than absent, so a form that submitted nothing does not pass.
Whoever exposes the …ForOperator methods decides who may call them; they are not the
user's own action, and the codes the second one returns have to reach the account's owner
rather than the operator.
Replay protection: verifyCode() compares the current 30-second window against last_used. If the same window was already used, the code is rejected even if cryptographically valid.
Backup codes are one-time: the matching hash is removed from storage after successful verification.
Database tables¶
| Table | Description |
|---|---|
authserver.user_twofactor |
One row per user — enabled flags (TOTP and email), secret, backup code hashes, last_used |
authserver.twofactor_setup |
Temporary setup sessions (15-min TTL) |
authserver.twofactor_attempts |
Append-only attempt log (TimescaleDB hypertable where available) |
authserver.twofactor_email_codes |
Live mailed codes for the email factor — one row per user per purpose, hashed, with an expiry and an attempt cap |
Password Security¶
There is nothing to override. User::setPassword() hashes with the preferred scheme and
User::verifyPassword() reads every scheme PasswordHash knows (see
How a stored password is hashed, and read).
Replacing either with a plain password_hash() / password_verify() pair would stop the
account's existing peppered hashes from verifying, and verifyPassword(string $password): bool
cannot be overridden without its return type in any case.
The password policy — 8 characters, a digit, a symbol, a matching confirmation — is
Account::validatePasswordPolicy(), used by registration, the password change and the reset
alike. Override it on your Account subclass to change the rules in all three places.
OAuth2 Scopes and Policy¶
Pramnos\Auth\Scopes — static scope registry for OAuth2 consent screens and validation.
Your own catalogue¶
Subclass, override getScopes(), and name the class in app.php:
The second half is not optional. Every internal call in Scopes is static::, so the
subclass answers consistently for anything that names it — but ten sites in the framework
write Scopes:: literally, and those are the ones that face outward:
Discovery ×4 |
scopes_supported in the /.well-known/ documents |
Oauth::authorize() |
whether a requested scope exists |
Oauth consent view |
the description shown beside each grant |
OAuth2\Repositories\ScopeRepository |
whether a scope is registered at all |
Mcp\PublicRegistry |
which tools a token's scopes reach |
mcp:token, init |
validation, and what the scaffolder offers |
Without the config line an installation can define forty-four scopes, publish fifteen, render a consent screen with no description beside what the user is being asked to grant, and have its own identifiers refused as unknown.
A class that is not a Scopes subclass is refused with an exception rather than ignored:
a misspelt name that was quietly dropped is the same failure by a different route.
Replace or merge — say which, in code¶
The framework cannot pick, because a catalogue is not reliably a superset. The subclass says it:
// Merge — the framework's standard scopes, plus yours
public static function getScopes(): array
{
return parent::getScopes() + ['Devices' => [/* … */]];
}
// Replace — this installation's vocabulary is the whole vocabulary
public static function getScopes(): array
{
return ['Devices' => [/* … */]];
}
Replacing drops openid, profile, email and offline_access unless you declare
them, and the OAuth2 server needs those to answer an OIDC request. Merging is the safer
default; replacing is the honest one for an installation that has curated its own list.
The MCP scopes are appended either way. They are derived from what
PublicRegistry
actually offers rather than declared anywhere, so a catalogue written before the endpoint
existed cannot make it ungrantable.
use Pramnos\Auth\Scopes;
// All scopes grouped by category (consent screen)
$grouped = Scopes::getScopes();
// ['Personal User Data' => ['profile' => [...], 'email' => [...]], ...]
// Flat scope → description map
$descriptions = Scopes::getScopeDescriptions();
// Scopes implicitly granted to all clients
$defaults = Scopes::getDefaultScopes(); // ['profile', 'email', 'user']
// Validate a scope string
[$hasInvalid, $invalidList] = Scopes::hasInvalidScopes('profile email unknown_scope');
// Resolve inherited scopes transitively
$resolved = Scopes::resolveInheritedScopes('system:notifications_write');
// ['system:notifications_read', 'system:notifications_write']
Standard scopes: profile, email, phone, address, user, openid, offline_access, system:admin, system:audit_read, system:health, system:notifications_read/write.
Pramnos\Auth\OAuthPolicyHelper — server-wide OAuth2 policy defaults.
use Pramnos\Auth\OAuthPolicyHelper;
$methods = OAuthPolicyHelper::getDefaultAllowedAuthMethods();
// ['client_secret_basic', 'client_secret_post', 'private_key_jwt']
$grants = OAuthPolicyHelper::getDefaultAllowedGrantTypes();
// ['authorization_code', 'client_credentials', 'device_code', 'password', 'refresh_token', 'exchange_token']
These are the grants a client with no rows of its own in oauth2_application_grants may use.
The list includes password. To drop it — OAuth 2.1 deprecates the grant — set the list in
app.php; pramnos init writes this key for an authserver project:
'authserver' => [
'default_grants' => ['authorization_code', 'client_credentials', 'device_code', 'refresh_token', 'exchange_token'],
'default_auth_methods' => ['client_secret_basic', 'client_secret_post', 'private_key_jwt'],
'default_scopes' => [],
],
jwt_bearer is never a default grant, and a system: scope is never a default scope: either is
ignored with a warning if listed.
OAuth2 Integration¶
The OAuth2 server is built in — do not hand-roll an authorize() action. With the
authserver feature, Pramnos\Auth\Controllers\Oauth serves the authorize, token, revoke,
introspect, userinfo, logout, client-registration and device-authorization endpoints on league/oauth2-server (Pramnos\Auth\OAuth2\OAuth2ServerFactory), with
PKCE, consent, client authentication and per-client grant and scope policy. Authorization codes
are stored by AuthCodeRepository, not as user tokens. How a client application registers and
signs in is in the Third-Party Integration Guide;
signing in to another platform is the OAuth2 Client Guide.
Registration: open, by domain, by invitation¶
Self-service registration at /register is off unless something opens it. Three things can:
Both settings below are fields in System Settings → Security → Registration, unless app.php
keeps them: 'auth' => ['registration_admin_editable' => false] shows them read-only
(RegistrationPolicy::editableInAdmin()). A value in app/config/settings.php still wins over
the screen, as for every setting.
| Setting | Effect |
|---|---|
auth_allow_registration |
open to anybody |
auth_registration_domains |
with the above, only addresses at these domains — example.com, example.org; whole domains, so evil-example.com is not example.com |
| an invitation | open for one address, while everything else stays closed |
Confirming the address. auth_registration_verify_email holds a self-registered account back
until it follows a link mailed to it (register/confirmemail, valid auth_email_verify_ttl_hours,
48). It is always on when a domain list is set — otherwise anybody could type an address at the
domain and hold an account relying parties believe belongs to it. Until then sign-in answers
email_unverified rather than signing in, and signing in again after ten minutes mails a new link.
Only accounts that were held back this way are stopped; an older account with validated = 0 is
not. validated is what the ID token's email_verified is read from, so it is true only when
somebody checked.
Invitations¶
use Pramnos\Auth\Invitations;
$made = (new Invitations())->invite('maria@example.com', [
'invitedBy' => $admin->userid,
'organizationId' => 7, // joins on acceptance
'roleId' => 3, // and is given this role
'note' => 'Finance team',
'metadata' => ['plan' => 'gold'],
]);
$made['link']; // https://…/register?invite=… — shown once; only Token::lookup() of it is stored
- The link opens registration for that address only (
invite_email_mismatchotherwise), and following it confirms the address, so an invited account is not asked to. - It works once — the row is claimed by an update conditioned on it being unused, so two
registrations racing on one link cannot both succeed — and for
auth_invitation_ttl_hours(48). - One live invitation per address: a new one withdraws the old. None for an address that has an account. A role belonging to an organisation goes only with an invitation into it.
resend()issues a new token (the old one cannot be recovered from its hash) andrevoke()withdraws;all()lists with astate— waiting, accepted, revoked, expired.- Accepting fires
invitation.acceptedwith the row and the new account's id — the place for whatever an application attached asmetadata:
\Pramnos\Event\Event::listen(Invitations::EVENT_ACCEPTED, function (array $invitation, int $userId): void {
$extra = json_decode((string) $invitation['metadata'], true) ?? [];
// …
});
The Invitations administration screen (admin.invitations; usertype 98 under
admin_access = usertype) invites, shows the link once, resends and withdraws. Only somebody who
may open Roles can attach a role. The mail's text is the auth.invitation template; the
confirmation mail's is auth.verify_email.
Attaching your own data from the screen¶
The screen passes invitedBy, organizationId, roleId and note. To attach metadata (or
any other option invite() takes), override inviteOptions() in your wrapper controller and add
the form fields by overriding the invitations view in your theme:
class Invitations extends \Pramnos\Auth\Controllers\InvitationsController
{
protected function inviteOptions(): array
{
return ['metadata' => ['capabilities' => array_keys((array) ($_POST['off'] ?? []))]];
}
}
What it returns is merged under the screen's own options, so it cannot change who invited, the organisation, the role or the note — those the screen decided and checked.
The mail¶
The auth.invitation template gets {link}, {hours}, {days}, {inviter}, {note} and
{sitename}. {days} is the whole number of days when the lifetime is one (a 21-day link is
21, where {hours} says 504), otherwise the hours as a fraction of a day; the built-in text
says days whenever it can.
A stored template has no preheader, so without one the mail client shows the body's first
sentence. To choose it, override notification() in a subclass of Invitations — it builds the
mail from the link and the stored row, metadata included — and return it through your
controller's service():
class Invitations extends \Pramnos\Auth\Invitations
{
protected function notification(string $link, array $row): InvitationNotification
{
return parent::notification($link, $row)
->withPreheader('Publishing and analytics for your channels. Your link is inside.');
}
}
When an account is erased¶
Account::eraseUserData() calls Invitations::forgetUser(). The invitations the account sent
that nobody accepted hold other people's addresses and are deleted; the ones that were accepted
stay, with invited_by cleared, because they belong to the account they created; the invitation
the erased account itself came from is deleted.
Configuration¶
Authentication Settings¶
Two places configure authentication, and each key is described in the section that owns it.
Application settings — editable on the settings screen, or fixed in app/config/settings.php:
| Setting | Section |
|---|---|
web_session_lifetime |
Web-session tokens |
loginlockoutsteps, loginlockoutwindowseconds |
Login Lockout |
auth_allow_registration, auth_registration_domains, auth_registration_verify_email, auth_invitation_ttl_hours |
Registration |
auth_newsignin_policy, auth_newsignin_action, auth_newsignin_trigger |
Telling an account it was used from somewhere new |
auth_trusted_devices, auth_trusted_device_days, auth_push_approval, auth_push_number_matching |
Trusted devices and sign-in approval |
app/app.php — fixed per deployment, versioned with the code:
'auth' => [
'rehash_on_login' => 'modern', // 'off' | 'modern' | 'all'
'newsignin_policy' => 'optout', // the default the setting overrides
'registration_admin_editable' => true,
'security' => ['human_check' => ['register']],
],
'authserver' => [
'default_grants' => [...],
'default_auth_methods' => [...],
'default_scopes' => [],
],
See Upgrading on the next sign-in and
OAuth2 Scopes and Policy for the rehash_on_login and
authserver keys.
Email confirmation at registration is a setting rather than a configuration key — see Registration.
Database Setup¶
The tables are created by the framework's migrations — there is nothing to create by hand:
The auth feature creates users, usertokens, authserver.permissions and the other
authserver.* tables this guide names; the authserver feature adds the OAuth2 server's own.
Do not build these tables from hand-written SQL: usertokens.token holds an encrypted value and
authentication finds a token through usertokens.token_lookup, which a hand-made table would not
have.
If your installation has a hand-made
permissionstable, see Legacy Permissions Migration for the SQL that moves its rows intoauthserver.permissions.
Best Practices¶
Security Guidelines¶
- Use the query builder -
$db->queryBuilder()binds parameters instead of interpolating them, and resolvesauthserver.*table names per driver - Validate JWT tokens properly - Set appropriate expiry times and validate all claims
- Use strong passwords - Implement password complexity requirements
- Enable CSRF protection - Use session tokens for form submissions
- Implement rate limiting - Prevent brute force attacks on login endpoints
- Use HTTPS - Always transmit authentication data over secure connections
- Log security events - Monitor failed login attempts and permission violations
Performance Considerations¶
- Cache permissions - Use the caching system for frequently checked permissions
- Optimize token queries - Index token tables properly
- Clean up expired tokens - Regularly remove old authentication tokens
- Use efficient session storage - Consider Redis for session storage in production
Error Handling¶
try {
$auth = \Pramnos\Auth\Auth::getInstance();
$result = $auth->auth($username, $password);
if (!$result) {
$response = $auth->lastResponse;
\Pramnos\Logs\Logger::log(
'Failed login attempt for ' . $username . ': ' . ($response['message'] ?? ''),
'auth'
);
return ['status' => 401, 'message' => 'Authentication failed'];
}
} catch (\Throwable $e) {
\Pramnos\Logs\Logger::logError('Authentication error', $e); // message, then the exception
return ['status' => 500, 'message' => 'Internal authentication error'];
}
This authentication system provides a robust foundation for securing your Pramnos Framework applications with support for modern authentication patterns, comprehensive user management, and flexible permission systems.
Related Documentation¶
- Framework Guide - Core framework concepts and MVC patterns
- Database API Guide - Database operations for user data management
- Cache System Guide - Caching user sessions and permissions
- Console Commands Guide - CLI tools for user management
- Logging System Guide - Logging authentication events and security monitoring
- Email System Guide - Password reset and notification emails
- Internationalization Guide - Multi-language authentication flows
For additional information on implementing authentication in your controllers and APIs, see the Framework Guide section on authentication patterns.