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'];
}
Login with Remember Me¶
// Login with persistent session
$auth = \Pramnos\Auth\Auth::getInstance();
$result = $auth->auth('user@example.com', 'password', true); // Third parameter enables "remember me"
if ($result) {
// User will stay logged in across browser sessions
$user = \Pramnos\User\User::getCurrentUser();
}
Encrypted Password Authentication¶
// Login with pre-encrypted password (for API scenarios)
$auth = \Pramnos\Auth\Auth::getInstance();
$hashedPassword = password_hash('plaintext_password', PASSWORD_DEFAULT);
$result = $auth->auth('user@example.com', $hashedPassword, false, true); // Fourth parameter indicates encrypted password
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 <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. It used to sit after
the try/catch, which meant the two early returns above it — «client_id is required» and «Application
not found» — skipped it: a browser form hitting either one got a flash queued for a page that was
never rendered, so a blank response, and the message surfacing later on whatever the visitor opened
next. The AJAX caller was answered correctly in both, which is why it went unnoticed. If you override
sendRevokeResponse(), it owns ending the response.
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_2026-09-01.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. >= 90 is an
administrator (UserCreate::ADMIN_USERTYPE), and the administration area's floor is
whatever admin.min_usertype says — so a comparison, not an equality, is what the
framework's own guards are written in.
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.settings, admin.permissions, devpanel |
90 |
Administrator | warning | admin.area, admin.users, admin.users.write, admin.logs, admin.applications, admin.organizations, admin.queue, admin.messages, admin.tokens |
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.
Three rules make the numbers behave:
- A value is a threshold.
95is an Administrator and has an administrator's capabilities;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 — that is 98 and above
\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()
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 map rather than merging with it. A
capability list is a security decision: quietly adding defaults underneath would grant
things the application did not ask for.
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, which is what it did before: three screens, three sets of thresholds, three answers to "what is 85?" and three opinions about which number was 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 default is80, which is below the lowest named administrative type — an application that wants only administrators sets it to90.
What the administration screen shows about a user¶
The framework records a user's history in ten stores, and until they were joined on one screen most of it was invisible outside the DevPanel:
| Store | What it holds | Where it appears |
|---|---|---|
authserver.user_activity_log |
sign-ins, logouts, whatever the application records | 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.
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¶
Three, 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 |
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 two the framework ships are adaptors like any other, held in
Pramnos\Auth\SecondFactorRegistry. An application adds a third from a service
provider's boot():
Pramnos\Auth\SecondFactorInterface is five questions:
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.
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.
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. The default is the behaviour every installation had before
the setting existed, so upgrading starts and stops 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 that is what existing installations rely on —
an upgrade that begins mailing a user base would be a surprise delivered by a patch release. A
project scaffolded today has no such history.
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 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.
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.
- 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.
- The same password policy
resetpassworduses: 8 characters, a digit, a symbol, and a matching confirmation. - Uniqueness, and only then a write.
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.
To decide on something other than a global flag — an invite code, a domain allow-list, an organization policy — override one method:
class Register extends \Pramnos\Auth\Controllers\Account
{
protected string $routeBase = 'register';
public function display()
{
return $this->register();
}
protected function registrationIsOpen(): bool
{
return $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->password = password_hash('password', PASSWORD_DEFAULT);
$user->firstname = 'John';
$user->lastname = 'Doe';
$user->status = 1; // Active
$user->save();
Loading Users¶
// Load user by ID
$user = new \Pramnos\User\User(123);
// Load user by username/email
$user = new \Pramnos\User\User();
$user->loadByUsername('johndoe');
// Get current logged-in user
$currentUser = \Pramnos\User\User::getCurrentUser();
if ($currentUser) {
echo "Welcome, " . $currentUser->firstname;
}
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.
Changed 2026-08-23. It did write. On every call after the first in a request it compared
users.languagewith the interface language and, when they differed, overwrote the column and saved the user. Two things followed: a stored language preference was reverted by the act of viewing a page rendered in another language, and on an account with no email address the save could raise from the address validation in_save()— so a lookup ended the request. Both are gone. If your application worked around this by keeping its language preference in a column of its own,users.languageis now safe to use again.
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 what solves a defect worth
naming, because it is silent: bcrypt stops at 72 bytes, and the scheme this replaced
appended a 32-character pepper to the plaintext. Everything a user typed past the 40th
character was discarded, and two long passwords sharing a 40-character prefix verified
against each other — both passwords worked, which reads as success. 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 |
the previous scheme — 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 for a reason worth spelling out: an application sharing this user table
may have written the row itself. One did, with a bare password_hash(), and its accounts
could not pass the framework's own password step-up — the correct password was refused,
with nothing in the refusal to suggest that hashing was the reason. Verification reads
both, so either side may have created 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(), which is worth saying
because it did not: DatabaseAuthDriver had its own boolean auth.auto_upgrade. A project
that set rehash_on_login => 'off' still had its rows rewritten by the login, and a project
that set auto_upgrade => false still had them rewritten by a step-up. auto_upgrade is
still read, as the 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¶
Nothing in the framework writes users.language. It holds whatever your
application put there, and 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 user
$user->delete();
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();
}
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, which is what used to happen when the API
"cleared" the ambient session to protect itself.
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 eleven tables, 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');
}
}
Composed as one array literal — which is what it was — a single unreadable table aborted the whole export. Somebody exercising a data-subject request got an error, and the ten sections that were perfectly readable went 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 came from the
framework.
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);
// Add authentication token
$token = $user->addToken('auth', bin2hex(random_bytes(32)), 'API access token');
// Add Apple Push Notification token
$apnsToken = $user->addToken('apns', $deviceToken, 'iPhone device');
// Add OAuth2 access token
$accessToken = $user->addToken('access_token', $oauthToken, 'OAuth2 access', $refreshTokenId);
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 now 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 meant a router reboot between two
sessions left the older token valid — the dynamic-address problem that
SignInFingerprint exists to avoid, reintroduced one layer down. Tokens from other
devices are left alone: signing in on a laptop must not sign you out on a phone.
Added 2026-08-20. Neither existed:
createWebSessionToken()set no expiry,loadByToken()reads 0 and NULL as "never", andcleanupAllAuthTokens()covered onlyauthandaccess_token— and had no caller anywhere in the framework. A two-day-old development installation with a single user had 7,255web_sessionrows, none of them expiring, arriving at about 230 an hour. It is also the tabletokenactionspoints a foreign key at, which is how a buffered write outlives the row it references.
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.
Corrected 2026-08-20. The web path recorded neither status nor duration — only the API path calls
updateAction(), and the shutdown flush wrote the held row exactly as it was held. Every page view in the audit log hadexecution_time_msempty, so the DevPanel's slowest-endpoints report read0.0 mson every row. And the endpoint was the absolute URL with its query, so that same report was twenty distinct URLs of one call each. Both reported from the same screen.
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 that it is now tested on both engines rather than believed.
Two things it got wrong until 2 September 2026, both of the kind that only a real database reveals:
ADD COLUMN IF NOT EXISTSis MariaDB, not MySQL. MySQL 8 rejects it as a syntax error, so the whole repair — on the engine most installations run — threw from inside thecatchthat was already handling a failure. The request failed, the audit row was lost, the table was not repaired, and the next request did it all again. It asks the schema builder per column and per index now.- On PostgreSQL the sync trigger read
servertime = 0as a real instant.servertimeis declaredDEFAULT 0, so a row written without one arrives as a zero rather than a null — andIS NOT NULLstamped every such row 1 January 1970. An audit log where «no time was given» and «this happened at the epoch» are the same row cannot be read. The condition is> 0.
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() reads the token to spare from $_SESSION['usertoken'], because the session is
the only place that knows which credential this request arrived with. A button that signed the caller
out as well is the bug a user notices immediately and cannot work around: 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
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 — which is exactly what the
class did before it knew which store it was reading.
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.
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 found
nothing every single time.
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, and the exchange is written to the activity log.
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::load() 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.
Fixed 2026-08-29.
Token::addAction()overwrote it on every request withHelpers::getBrowser(), a different shape entirely —{"userAgent": …, "browser": …}, with nodevicekey. So the column held two shapes depending on whether a token had ever been used, and the session retirement below could never match a token that had. Every supersededweb_sessiontoken stayed valid for its full thirty days; reopening a browser looked like it minted a new session each time, because it did, and nothing retired the previous one. It also destroyed the evidence it looked like it was collecting: a token used from a browser it was not issued to had its record rewritten to say the new browser.
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.
Until 2026-08-16 this column was written as an empty string at every call site, while its own comment described it as holding "browser, OS, IP at token creation". The reader had existed for years; there was simply never anything in it.
New sign-in alerts¶
Opt-in. When a user has asked for it, an account signed in to from a browser/platform combination that account has not used before triggers one email.
use Pramnos\Auth\NewSignInAlert;
NewSignInAlert::setEnabledFor($userId, true);
NewSignInAlert::isEnabledFor($userId); // false unless asked for
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. One
installation had 19 such rows out of 24, so a request asked 19 impossible questions before
this refused them.
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
class CustomUserAuth extends \Pramnos\Addon\Auth\UserDatabase
{
public function onAuth($username, $password, $remember, $encryptedPassword, $validate)
{
// Add custom validation logic
$result = parent::onAuth($username, $password, $remember, $encryptedPassword, $validate);
if ($result['status']) {
// Additional checks (e.g., account verification, 2FA)
if (!$this->isTwoFactorVerified($result['uid'])) {
return [
'status' => false,
'message' => 'Two-factor authentication required',
'statusCode' => 401
];
}
}
return $result;
}
}
Creating Custom Authentication Addons¶
class LdapAuth extends \Pramnos\Addon\Addon
{
public function onAuth($username, $password, $remember, $encryptedPassword, $validate)
{
// LDAP authentication logic
$ldapConnection = ldap_connect($this->config['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.
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. Until 2026-08-27 they were read nowhere:
calculateDuration() consulted DEFAULT_STEPS and the window arithmetic used
DEFAULT_WINDOW_SECONDS, so the whole progressive-lockout section of the settings
screen — the editor, its validation, and its "adjusted to safe defaults" warning —
configured nothing. An operator tightened the ladder, the page confirmed the save,
and every account kept locking on the shipped 3/5/7/10.
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
Backup codes come from enrolment, not from setup. startSetup() deliberately
returns none. It used to return a generated set, and the setup screen listed them under
"save these, they will not be shown again" — but completeSetup() generates and stores
its own set, so the codes on screen were dead before the user finished reading them.
The account's real recovery codes were known to nobody, and the user found out the first
time they lost their phone. 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. Both used to take
one parameter while the controller in front of them collected a password and passed it —
and PHP discards an extra argument to a userland function, so nothing was checked. 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. An optional parameter fixes the call site that had the bug and leaves the hole open for the next one — omit the argument and the check silently does not happen. 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 |
|---|---|
user_twofactor |
One row per user — enabled flag, secret, backup code hashes, last_used |
twofactor_setup |
Temporary setup sessions (15-min TTL) |
twofactor_attempts |
Append-only attempt log (TimescaleDB hypertable where available) |
Password Security¶
class SecureUser extends \Pramnos\User\User
{
public function setPassword($plainPassword)
{
// Validate password strength
if (!$this->isPasswordStrong($plainPassword)) {
throw new \Exception('Password does not meet security requirements');
}
// Hash with current best practices
$this->password = password_hash($plainPassword, PASSWORD_ARGON2ID, [
'memory_cost' => 65536,
'time_cost' => 4,
'threads' => 3
]);
}
public function verifyPassword($plainPassword)
{
return password_verify($plainPassword, $this->password);
}
private function isPasswordStrong($password)
{
// Implement password strength requirements
return strlen($password) >= 8
&& preg_match('/[A-Z]/', $password)
&& preg_match('/[a-z]/', $password)
&& preg_match('/[0-9]/', $password)
&& preg_match('/[^A-Za-z0-9]/', $password);
}
}
OAuth2 Scopes and Policy¶
Pramnos\Auth\Scopes — static scope registry for OAuth2 consent screens and validation.
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', 'refresh_token', 'exchange_token']
// 'password' grant excluded (deprecated per RFC 9126 / OAuth 2.1)
OAuth2 Integration¶
// OAuth2 server capabilities are built into the API system
class OAuth2Controller extends \Pramnos\Application\Controller
{
public function authorize()
{
// Handle OAuth2 authorization requests
$clientId = $_GET['client_id'];
$redirectUri = $_GET['redirect_uri'];
$scope = $_GET['scope'] ?? 'read';
// Validate client application
$app = new \Pramnos\Application\Api\Apikey($clientId);
if ($app->appid == 0 || $app->callback !== $redirectUri) {
throw new \Exception('Invalid client application');
}
// If user is not logged in, redirect to login
if (!\Pramnos\Http\Session::staticIsLogged()) {
$this->redirect('/login?return_to=' . urlencode($_SERVER['REQUEST_URI']));
return;
}
// Generate authorization code
$user = \Pramnos\User\User::getCurrentUser();
$authCode = bin2hex(random_bytes(32));
$user->addToken('auth_code', $authCode, 'OAuth2 authorization code', null);
// Redirect back to client with code
$this->redirect($redirectUri . '?code=' . $authCode . '&state=' . ($_GET['state'] ?? ''));
}
}
Configuration¶
Authentication Settings¶
// In your application configuration
return [
'authentication' => [
'jwt_secret' => env('JWT_SECRET', 'your-secret-key'),
'jwt_expiry' => env('JWT_EXPIRY', 3600), // 1 hour
'session_timeout' => env('SESSION_TIMEOUT', 1800), // 30 minutes
'remember_me_duration' => env('REMEMBER_ME_DURATION', 2592000), // 30 days
'password_hash_algo' => PASSWORD_ARGON2ID,
'require_email_verification' => env('REQUIRE_EMAIL_VERIFICATION', true),
'enable_mfa' => env('ENABLE_MFA', false),
],
'permissions' => [
'cache_permissions' => env('CACHE_PERMISSIONS', true),
'default_user_permissions' => [
'profile' => ['read', 'update'],
'content' => ['read']
]
]
];
Database Setup¶
The authentication system requires several database tables. Use the framework's migration system to set them up:
-- Users table
CREATE TABLE `users` (
`userid` int NOT NULL AUTO_INCREMENT,
`username` varchar(255) NOT NULL,
`email` varchar(255) NOT NULL,
`password` varchar(255) NOT NULL,
`firstname` varchar(255),
`lastname` varchar(255),
`status` tinyint DEFAULT 1,
`created_at` timestamp DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`userid`),
UNIQUE KEY `username` (`username`),
UNIQUE KEY `email` (`email`)
);
-- User tokens table
CREATE TABLE `usertokens` (
`tokenid` int NOT NULL AUTO_INCREMENT,
`userid` int NOT NULL,
`tokentype` varchar(50) NOT NULL,
`token` varchar(255) NOT NULL,
`created` int NOT NULL,
`lastused` int DEFAULT 0,
`expires` int DEFAULT NULL,
`status` tinyint DEFAULT 1,
`notes` text,
PRIMARY KEY (`tokenid`),
KEY `userid` (`userid`),
KEY `token` (`token`),
FOREIGN KEY (`userid`) REFERENCES `users` (`userid`) ON DELETE CASCADE
);
Permissions are not in this list on purpose. They live in
authserver.permissions, created by theauthfeature's migrations — there is nothing to create by hand. An older revision of this guide printed aCREATE TABLE permissionsstatement for a table no migration has ever created; if you built one from it, see Legacy Permissions Migration for the SQL that moves its rows across.
Best Practices¶
Security Guidelines¶
- Always use prepared statements - The framework's
prepareQuery()method prevents SQL injection - 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::logWarning('Failed login attempt', [
'username' => $username,
'ip' => $_SERVER['REMOTE_ADDR'],
'user_agent' => $_SERVER['HTTP_USER_AGENT'],
'reason' => $response['message']
]);
return ['status' => 401, 'message' => 'Authentication failed'];
}
} catch (\Exception $e) {
\Pramnos\Logs\Logger::logError('Authentication error', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
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.