This document summarizes how authentication, session validation, remember-me, and permission checks work in the project.
All requests pass through public/index.php, which boots the session and CSRF helpers. The App\Core\Router runs middleware before dispatching to a controller method:
AuthMiddleware::handle()— if no active session, attempts auto-login via remember-me cookie; otherwise validates timeout and anti-hijacking, then callsAuth::refreshPermissionsIfStale().GuestMiddleware::handle()— attempts auto-login via remember-me cookie before checking session; redirects authenticated users away from guest-only pages (login, forgot-password).PermissionMiddleware::handle($name)— callsAuth::hasPermission($name); returns a 403 view on failure.
Middleware is declared per route in routes/web.php:
['method' => 'GET', 'path' => '/users', 'controller' => 'User@index', 'middleware' => ['auth', 'perm:users']],All authentication, session, and remember-me concerns are centralised in the static class App\Core\Auth. No instantiation needed.
| Method | Description |
|---|---|
Auth::check() |
Returns true if a valid authenticated session exists |
Auth::id() |
Returns the current user's ID or null |
Auth::user() |
Returns current user array (id, name, email, role, image) or null |
Auth::isAdmin() |
Returns true if $_SESSION['user_is_admin'] is true (set from roles.is_system) |
Auth::hasPermission(string $name) |
Checks session cache; '*' grants all (admins) |
Auth::permissions() |
Returns the full permission name array from session |
Auth::login(array $user, array $permNames) |
Regenerates session ID, writes all session keys, caches permissions |
Auth::logout() |
Reads user ID from session, clears remember-me cookie, destroys the session |
Auth::checkTimeout() |
Destroys session and returns false if idle > SESSION_LIFETIME; fail-closed if last_access key missing |
Auth::checkSecurity() |
Destroys session and returns false if IP or User-Agent changed; fail-closed if keys missing |
Auth::refreshPermissionsIfStale() |
Reloads permission cache from DB if permissions_updated_at is newer than session timestamp |
Auth::issueRememberCookie(int $userId) |
Generates token, stores SHA-256 hash in DB, sets cookie |
Auth::attemptRememberLogin() |
Validates cookie token, auto-logs in, rotates token |
Auth::clearRememberCookie(int $userId) |
NULLs DB token, expires cookie |
| Key | Value |
|---|---|
user_id |
int — primary key |
user_name |
string — first name |
user_email |
string |
user_role |
string — role display name (from roles.name) |
user_is_admin |
bool — derived from roles.is_system |
user_image |
string — avatar path |
user_permissions |
string[] — union of direct + role permissions; ['*'] for admins |
permissions_ts |
string — Y-m-d H:i:s timestamp used for stale-check |
last_access |
int — Unix timestamp for inactivity timeout |
ip |
string — client IP for anti-hijacking |
user_agent |
string — User-Agent for anti-hijacking |
Implemented in App\Core\Auth (called by AuthMiddleware):
- Session cookie hardening (
httponly,SameSite=Lax,use_strict_mode) — set inpublic/index.phpbeforesession_start() - Inactivity timeout via
Auth::checkTimeout()— readsSESSION_LIFETIMEfrom.env(default: 1800 s). Fail-closed: a session withoutlast_accessis destroyed immediately. - Anti-hijacking validation via
Auth::checkSecurity()(IP + User-Agent). Fail-closed: a session withoutiporuser_agentis destroyed immediately.
If validation fails and no valid remember-me cookie exists, the user is redirected to login and receives a session message.
Implemented in App\Core\Auth. Controlled by three .env variables:
| Variable | Default | Description |
|---|---|---|
SESSION_LIFETIME |
1800 |
Seconds of inactivity before session expires |
REMEMBER_ME_LIFETIME |
2592000 |
Seconds the persistent cookie lives (30 days) |
REMEMBER_ME_COOKIE_NAME |
remember_me |
Cookie name |
How it works:
- User checks "Remember me" on login →
AuthControllercallsAuth::issueRememberCookie($userId). - A 64-char hex token is generated with
random_bytes(32); its SHA-256 hash is stored inusers.remember_tokenwith an expiry inusers.remember_token_expires. The plain token goes into the cookie. - On every request without an active session,
AuthMiddlewarecallsAuth::attemptRememberLogin():- Reads the cookie, hashes it, looks up
userswhereremember_token = hash AND expires > NOW() AND status = 1. - On match: calls
Auth::login()to rebuild the session (including the permission UNION), then rotates the token to mitigate cookie theft. Also callsAuth::refreshPermissionsIfStale()before returning. - On no match: clears the stale cookie and returns
false.
- Reads the cookie, hashes it, looks up
- On logout:
Auth::logout()reads the user ID from session internally, NULLs the DB columns, expires the cookie, then destroys the session.
Security properties:
- Token never stored in plain text in DB — only SHA-256 hash.
- Token rotated on every successful auto-login.
- Cookie:
HttpOnly(no XSS),SameSite=Lax(mitigates CSRF),Secureflag set when HTTPS is detected. - Deactivated users (
status = 0) and pending users (status = 2) cannot auto-login — the query filtersstatus = 1. - Expired tokens ignored via
NOW()comparison in the query. - Token revoked on password change —
User::updatePassword()callsclearRememberToken($id)after every successful password hash update, regardless of whether the change was initiated by the user or an admin. This prevents a stolen cookie from remaining valid after a password reset.
DB columns in users:
remember_token CHAR(64) NULL DEFAULT NULL
remember_token_expires DATETIME NULL DEFAULT NULLImplemented in App\Services\LoginThrottleService + App\Models\User. Controlled by two .env variables:
| Variable | Default | Description |
|---|---|---|
LOGIN_MAX_ATTEMPTS |
5 |
Consecutive failures before lockout |
LOGIN_LOCKOUT_MINUTES |
15 |
Minutes the account stays locked |
How it works:
AuthController::login()resolves the user row viaUser::findByEmail()orUser::findByDocumentNumber()before callingpassword_verify.- If the user exists,
LoginThrottleService::isLocked()evaluateslocked_untilvsNOW()(lazy — no write). If locked, the request is rejected with a human-readable message andpassword_verifyis never called. - On a credential failure (wrong password),
LoginThrottleService::registerFailure()callsUser::recordFailure()— a single UPDATE that incrementslogin_attemptsand setslocked_untilwhen the threshold is reached. Non-existent emails do not generate any DB write. - On a successful login,
LoginThrottleService::clearOnSuccess()resets all three throttle columns to their defaults. - Lazy unlock —
locked_untilis evaluated on the next attempt. No cron job required. Oncelocked_until ≤ NOW(),getLockStatus()returnslocked=false.
Admin manual unlock:
POST /users/{id}/unlock-login(middleware:auth + perm:users) →UserController::unlockLoginAjax().views/users/show.phpconditionally shows a "Locked until HH:MM" badge and an "Unlock Login" button whenlocked_until > NOW().- JS handler in
show-user.jsusesAlertUtils.confirm→ToastUtils.loadingWithMinTime→location.reload().
DB columns in users:
login_attempts INT NOT NULL DEFAULT 0
locked_until DATETIME NULL DEFAULT NULL
last_attempt_at DATETIME NULL DEFAULT NULLSecurity properties:
- Locked accounts never reach
password_verify— no timing information leaks. - Non-existent emails are silently ignored — user existence is not revealed.
- IP-based blocking intentionally omitted: internal admin system where false positives (shared NAT) outweigh the benefit.
- Lockout by account is a known DoS vector; mitigated by admin manual unlock and configurable lockout duration.
Permissions are cached in session at login and checked via Auth::hasPermission(string $name).
roles ──┐
├── role_permissions ──► permissions ← "this ROLE can do X"
│
users ──┼── user_permissions ──► permissions ← "this USER specifically can do X"
│
└── role_id ──► roles ← "this user belongs to role Y"
user_permissions— direct per-user permission overrides.role_permissions— permissions inherited by all users of a role.- At login and on cache refresh,
Authcomputesarray_unique(merge(direct, from_role))and stores the result in$_SESSION['user_permissions']. - Admins (role with
is_system = 1) always get['*']—hasPermission()returnstruefor any name without iterating the array.
Auth::isAdmin()reads$_SESSION['user_is_admin'], which is set fromroles.is_systemat login — not from the role name. The role can be freely renamed.- Roles with
is_system = 1cannot be deactivated or deleted via the UI (RoleControllerenforces this server-side).
Permission changes are cached in session and refreshed when stale.
- Session value:
$_SESSION['permissions_ts'] - DB value:
users.permissions_updated_at - Refresh trigger:
Auth::refreshPermissionsIfStale()— called byAuthMiddlewareon every authenticated request — compares both values and reloads the UNION from DB if the DB timestamp is newer.
After changing direct user permissions: call $userModel->updatePermissionsTimestamp($userId).
After changing role permissions: call $userModel->updatePermissionsTimestamp($uid) for every user of that role — Role::getUserIdsByRole($roleId) returns the list. RoleController::syncPermissions() already does this automatically.
users.status is a tinyint with three values:
| Value | Constant | Login | Reset password | Remember-me cookie |
|---|---|---|---|---|
0 |
User::STATUS_INACTIVE |
❌ | ❌ (generic msg) | ❌ |
1 |
User::STATUS_ACTIVE |
✅ | ✅ | ✅ |
2 |
User::STATUS_PENDING |
❌ | ❌ (generic msg) | ❌ |
Pending users are created via the invitation flow. AuthController::login() blocks status 2 before Auth::login() is called.
| Permission name | Module | Description |
|---|---|---|
profile |
Profile | Access to own profile and password changes |
admin |
Global | General administration — granted to all system-role users (*) |
users |
Users | Full user management (CRUD, activation, unlock) |
permissions |
Permissions | Create, edit, and assign/revoke permissions |
roles |
Roles | Create, edit, and manage role↔permission assignments |
audit_log |
Audit Log | Read-only access to the activity/audit log |
Administrators (role with
is_system = 1) receive['*']in session —hasPermission()returnstruefor any permission name without requiring explicit assignment.
For any new protected route, declare middleware in routes/web.php:
// Requires authentication only
['method' => 'GET', 'path' => '/profile', 'controller' => 'User@profile', 'middleware' => ['auth']],
// Requires authentication + named permission
['method' => 'GET', 'path' => '/products', 'controller' => 'Product@index', 'middleware' => ['auth', 'perm:products']],Within the controller method, additional inline checks are available via the base Controller helpers:
$this->csrfCheck(); // validates CSRF; returns JSON 403 for AJAX or redirects
$this->requirePermission('products'); // re-checks permission and renders 403 view on failureViews and layouts can gate nav items and action buttons directly:
<?php if (\App\Core\Auth::hasPermission('users')): ?>
<a href="<?= URL ?>users">Users</a>
<?php endif; ?>