Framework
Subsystems

Authentication

Credentials, JWT access & refresh tokens, device tracking, password resets and spam protection.

The Auth class is the entry point: it signs a credential in, issues the JWT tokens, and answers the questions your code and routes ask about the current request.

Signing in

Resolve a credential by email, confirm it may sign in and that the password matches, then call Auth::login() — which issues the tokens and starts the session. Auth::logout() ends it:

use Framework\Auth\Auth;

$credential = Auth::getLoginCredential($email);
if (Auth::canLogin($credential) && Auth::isPasswordCorrect($password)) {
    Auth::login($credential);
}

Once signed in, everything about the current request is available statically — you never thread the user through your code:

MethodReturns
getCredential()The current credential entity.
getID() / getAdminID()The signed-in id (and the admin id while impersonating).
getAccessName()The current access level.
getLanguage()The user's language.
isLoggedIn() / isAdmin()Session checks.

Access & refresh tokens

Sessions are stateless. Signing in issues a pair of tokens — a short-lived access token that authorizes each request and a long-lived refresh token that renews it — and every request carries them in its xAccessToken / xRefreshToken values. Framework::execute() validates them and attaches fresh ones to the response, so the client simply stores whatever it last received:

use Framework\Auth\Auth;

Auth::getAccessToken();   // the current JWT access token
Auth::getRefreshToken();  // the refresh token

The access token (JWT)

The access token is a JWT (through firebase/php-jwt), signed HS256 with your AUTH_KEY. It carries the standard iat (issued-at), nbf (not-before) and exp (expiry) claims plus a data payload with the credential id, and expires AUTH_HOURS hours after it is issued. Because it is signed, it is validated by decoding it with the key — no database lookup — which is what keeps the request path stateless. AuthToken creates and verifies it.

The refresh token

The refresh token is the opposite: an opaque token stored in the database (one row per device), valid for AUTH_DAYS days. Once the access token has expired, the client sends its refresh token and Framework::execute() looks it up to resolve the credential and mint a new access token — so the user stays signed in without re-entering a password. Being stored means they can be revoked: signing out removes the row, and RefreshToken::removeOld() prunes expired ones on a schedule.

Configuring the tokens

Three environment keys tune them:

KeySets
AUTH_KEYThe secret the JWTs are signed with — keep it private; changing it invalidates every issued token.
AUTH_HOURSAccess-token lifetime, in hours (shorter is safer, but refreshes more often).
AUTH_DAYSRefresh-token lifetime, in days — how long a session survives before a full sign-in.

API access

For server-to-server calls, set a static AUTH_API_TOKEN and send it as the token field or an Authorization header. Framework::execute() validates it with Auth::validateAPI() and marks the request as an API call — those may send a JSON body instead of form fields. That token grants the API role; a token of its own grants any other.

Access roles & the Access class

Access levels are declared with AccessRole::register() and grouped in a config file. The build turns them into a typed Access enum — one case per role, ordered by the level you registered them at:

use Framework\System\Access;

Access::Admin;                    // an enum case
Access::getLevel(Access::Admin);  // its numeric level
Access::getName(Access::Admin);   // its localized name

For each role the class generates checks against the current user, so authorization reads naturally — and because levels are ordered, you get OrHigher / OrLower variants:

Access::isAdmin();          // exactly Admin
Access::isAdminOrHigher();  // Admin or above
Access::isGeneralOrHigher();

The second argument to register() is a group — a named set of roles, separate from the level ordering. Register several roles under the same group name to bundle them (a role always belongs to exactly one group):

config/Access.config.php
AccessRole::register("Owner",   "Staff");
AccessRole::register("Manager", "Staff");
AccessRole::register("Client",  "Customer");

For every group the Access class generates membership and listing helpers, named after the group. For the Staff group above:

Access::inStaffs();                   // is the current user Owner or Manager?
Access::isValidStaff(Access::Owner);  // is a given role part of the group?
Access::getStaffs();                  // [Access::Owner, Access::Manager]
Access::getStaffSelect();             // a Select list of the group's roles

Use a group when a permission spans several roles that a single OrHigher check can't express — membership is by set, not by level. The same enum is what every route declares in its #[Route("/path", Access::Admin)] attribute, and Framework::execute() enforces it before your method runs.

A role reached with a token

A role can be given a token, and a request that carries it is that role — no credential, no sign in. It is how an integration of its own gets in: one token per role, each with the access that role has, so what it can reach is declared the same way everything else is. The fourth argument to register() is the name of the environment key that holds the token, never the token itself:

config/Access.config.php
AccessRole::register("Zapier", "API", tokenKey: "ZAPIER_TOKEN");
.env
ZAPIER_TOKEN = "the-secret-value"

The build writes the key into the Access class, and the value is read from the environment as the request comes in, so no secret is ever written into the code:

Access::getTokenKey(Access::Zapier);  // "zapierToken"
Access::getTokenAccesses();           // [Access::Zapier]

The request sends it as the token field or an Authorization header, the same way the API token is sent, and Auth::validateAPI() tries each registered token before the one of the config. A match sets the access of the request to that role, and from there it is treated as an API call: it may send a JSON body, and it is answered without tokens. An empty token is refused rather than compared, so an app with none configured does not let in a request that brings none either.

Where the role sits decides what the token reaches. Registered in the API group, as above, it reaches every route of that group and of General. Registered anywhere else, it reaches what its level reaches, the way a signed-in user of that role would:

#[Route("/orders/import", Access::Zapier)]   // only that token
#[Route("/orders/list", Access::API)]        // any of the API roles

Password resets

Reset drives the forgot-password flow: create() issues a one-time code to email the user, codeExists() validates the code they return, and getCredentialID() resolves it back to an account so a new password can be set. deleteOld() prunes expired codes:

use Framework\Auth\Reset;

$code = Reset::create($credentialID);   // email this code/link to the user

// …when they follow the link:
if (Reset::codeExists($code)) {
    $credentialID = Reset::getCredentialID($code);
    // set the new password, then:
    Reset::delete($code);
}

Spam protection

To blunt brute-force and abuse, Spam::protect() rate-limits repeated attempts from the same source and returns whether the action should be blocked; Spam::reset() clears the counter after a success. Framework::execute() also runs a spam check on incoming requests:

use Framework\Auth\Spam;

if (Spam::protect()) {
    return;   // too many attempts — reject
}
// …on success:
Spam::reset();

Signing in as a user

An admin can act as another user — for support, say — without their password. loginAs() switches the session to the target credential while remembering the admin (getAdminID() and isLoggedAsUser() report it), and logoutAs() switches back. canLoginAs() guards it: an admin may only impersonate users at or below their own level.

use Framework\Auth\Auth;

Auth::loginAs($userCredentialID);   // act as the user
Auth::logoutAs();                   // return to the admin

Configuration

Authentication is toggled and tuned through environment values:

KeyControls
AUTH_ACTIVEEnables the auth module.
AUTH_KEYSecret used to sign the JWT tokens.
AUTH_HOURSAccess-token lifetime, in hours.
AUTH_DAYSRefresh-token lifetime, in days.
AUTH_API_TOKENStatic token for server-to-server API access.
AUTH_FIELDSExtra credential fields to expose.