Skip to content
Viames Marino edited this page May 10, 2026 · 6 revisions

Pair framework: User

Pair\Models\User is Pair's central authentication model. It extends ActiveRecord and coordinates:

  • local login with password verification
  • session-free login for native mobile/API bearer sessions
  • login by external factor (doLoginById()), useful for Passkey or SSO flows
  • ACL checks against modules and actions
  • session creation and logout
  • remember-me cookies and token rotation
  • locale, timezone, landing-page, and impersonation helpers

When to use

Use User whenever backend code needs authenticated identity, access checks, session bootstrap, password-reset completion, or remember-me behavior.

Main methods (deep dive)

1) doLogin(string $username, string $password, string $timezone): stdClass

This is the main entry point for local username or email login.

Current behavior:

  • loads the user by username or email depending on PAIR_AUTH_BY_EMAIL
  • rate limits password attempts by normalized identifier and client IP
  • rejects disabled users and users with more than 9 failed attempts
  • verifies the password with checkPassword()
  • creates a Session, updates lastLogin, resets faults, clears pwReset, and writes audit logs
  • returns an object with error, message, userId, and sessionId

The rate limiter is controlled by PAIR_AUTH_RATE_LIMIT_ENABLED, PAIR_AUTH_RATE_LIMIT_MAX_ATTEMPTS, and PAIR_AUTH_RATE_LIMIT_DECAY_SECONDS. Blocked attempts still return the generic authentication failure message, so the response does not reveal whether the identifier exists.

Typical controller usage:

use App\Models\User;

$timezone = (string)($_POST['timezone'] ?? 'Europe/Rome');

$result = User::doLogin(
    trim((string)($_POST['username'] ?? '')),
    (string)($_POST['password'] ?? ''),
    $timezone
);

if ($result->error) {
    $this->toastError('Login failed', (string)$result->message);
    return;
}

$user = new User((int)$result->userId);

if (!empty($_POST['remember'])) {
    $user->createRememberMe($timezone);
}

$this->redirect('dashboard');

2) doTokenLogin(string $username, string $password): stdClass

Use this for mobile/API login flows that must verify local credentials without creating a PHP session or mutating browser remember-me state.

Current behavior:

  • loads the user by username or email depending on PAIR_AUTH_BY_EMAIL
  • uses the same AuthAttemptLimiter as doLogin()
  • rejects disabled users and users with more than 9 failed attempts
  • verifies the password with checkPassword()
  • updates lastLogin, resets faults, clears pwReset, and writes audit logs
  • does not create a Session, set cookies, revoke browser remember-me records, or close other web sessions

The built-in ApiController::authAction() uses this method before issuing an ApiToken. Both token login and normal web login are limited by normalized identifier and client IP.

use App\Models\User;
use Pair\Models\ApiToken;

$result = User::doTokenLogin($email, $password);

if ($result->error) {
	return null;
}

$user = new User((int)$result->userId);
$issued = ApiToken::issueForUser($user);

3) doLoginById(int $userId, string $timezone): stdClass

Use this when the identity has already been verified by another factor, for example Passkey/WebAuthn, OAuth callback handling, or a trusted SSO flow.

It follows the same safety rules as doLogin(): locked or disabled users are still rejected, and a normal Pair session is created.

Example from an API or Passkey flow:

use App\Models\User;
use Pair\Api\ApiResponse;

$result = User::doLoginById($verifiedUserId, 'Europe/Rome');

if ($result->error) {
    return ApiResponse::errorResponse('UNAUTHORIZED');
}

return ApiResponse::jsonResponse([
    'userId' => $result->userId,
    'sid' => $result->sessionId,
]);

4) doLogout(string $sid): bool

This closes the session, removes persistent state cookies, unsets remember-me data, resets Application::currentUser, and writes the logout audit entry.

use App\Models\User;

$ok = User::doLogout(session_id());

if ($ok) {
    $this->redirect('user/login');
}

5) canAccess(string $module, ?string $action = null): bool

This is the main ACL check used by Pair.

Current behavior:

  • super users always pass
  • the user module is always allowed
  • public is always allowed
  • the method accepts either module + action or a single module/action string
  • custom routes are resolved before ACL matching
  • rules are loaded once and cached on the user object

Examples:

if (!$user->canAccess('orders', 'edit')) {
    throw new \RuntimeException('Access denied');
}
if ($user->canAccess('reports/export')) {
    // module/action combined in one string
}

6) Remember-me lifecycle: createRememberMe(), loginByRememberMe(), renewRememberMe(), unsetRememberMe()

These methods implement the persistent login flow.

createRememberMe():

  • generates a random token
  • stores only the hashed token in user_remembers
  • writes a versioned cookie payload
  • keeps other active remember-me tokens for the same user so remembered browsers/devices do not compete with each other

loginByRememberMe():

  • is used automatically by Application during unauthenticated web requests
  • validates the cookie, loads the related user, creates a fresh session, rotates the remember-me token, and sets the current application user

renewRememberMe() rotates only the cookie and DB token pair represented by the current browser cookie.

unsetRememberMe() removes the current browser cookie and only the matching server-side token. Without a current remember-me cookie, Pair leaves other remembered devices untouched.

Example after a successful login:

$result = \App\Models\User::doLogin($username, $password, $timezone);

if (!$result->error && !empty($_POST['remember'])) {
    $user = new \App\Models\User((int)$result->userId);
    $user->createRememberMe($timezone);
}

Example manual auto-login check in a custom bootstrap path:

if (\App\Models\User::loginByRememberMe()) {
    \Pair\Core\Application::getInstance()->redirectToUserDefault();
}

7) Landing helpers: landing() and redirectToDefault()

landing() returns the default module/action for the user's ACL group. redirectToDefault() turns that into a browser redirect.

$landing = $user->landing();

if ($landing) {
    echo $landing->module . '/' . $landing->action;
}
$user->redirectToDefault();

8) Password reset and password helpers

The main reset path is:

  • getByPwReset(string $token): ?User
  • setNewPassword(string $newPassword, string $timezone): bool

setNewPassword() clears the reset token, stores the new hash, creates a new session, resets faults, and writes the audit event.

use App\Models\User;

$user = User::getByPwReset((string)($_GET['token'] ?? ''));

if (!$user) {
    throw new \RuntimeException('Invalid reset token');
}

$user->setNewPassword((string)$_POST['password'], 'Europe/Rome');

The low-level helpers are also useful:

  • checkPassword($plain, $hash) verifies a local password
  • getHashedPasswordWithSalt($plain) builds the stored hash

New password hashes use Argon2id when the PHP runtime supports it, with bcrypt as the compatibility fallback. Successful web and token logins rehash older verified passwords in place when the stored algorithm is outdated.

9) Impersonation: impersonate(), impersonateStop(), isSuper()

impersonate() swaps the active session user while remembering the former user ID. impersonateStop() restores the original user. isSuper() also checks the former user during impersonation so elevated access is preserved correctly.

$admin->impersonate($targetUser);
$currentUser->impersonateStop();

Secondary methods (short reference)

  • current(): ?static returns the Application current user or null.
  • avatar(string $classPrefix = 'user'): string renders initials with a deterministic template-based color.
  • fullName(): string returns "name surname".
  • Virtual properties fullName and groupName are available through __get().
  • getGroup(): Group loads the related group.
  • getLocale(): Locale returns the stored locale or the default locale.
  • getLanguageCode(): ?string returns the cached language code derived from the locale relation.
  • getDateTimeZone(): DateTimeZone reads the current session timezone or falls back to BASE_TIMEZONE.
  • getValidTimeZone(string $timezone): DateTimeZone validates an IANA timezone name.
  • isSuper(): bool checks both the current and former impersonating user.
  • isDeletable(): bool refuses self-deletion and then applies the normal ActiveRecord FK checks.
  • isLocaleSet(): bool tells whether localeId is currently set.

Hooks you can override

Authentication hooks:

  • beforeLogin(), afterLogin()
  • afterLoginFailed()
  • beforeLogout(), afterLogout()

Remember-me hooks:

  • beforeRememberMeCreate(), afterRememberMeCreate()
  • beforeRememberMeLogin(), afterRememberMeLogin()
  • beforeRememberMeRenew(), afterRememberMeRenew()
  • beforeRememberMeUnset(), afterRememberMeUnset()

These are useful when you need application-specific audit, telemetry, or side effects without rewriting the core flow.

Practical notes

  • PAIR_AUTH_BY_EMAIL=true switches local login lookup from username to email.
  • PAIR_SINGLE_SESSION=true deletes the user's other sessions after a successful login.
  • The current implementation treats users with more than 9 faults as locked for login purposes.
  • Remember-me cookies store a versioned payload, while the DB keeps only a deterministic hash of the token.
  • Remember-me records are device-scoped: login, renewal, and logout for one browser do not revoke other remembered browsers unless the application adds an explicit account-level revocation flow.

See also: Session, AuthAttemptLimiter, Rule, Locale, UserRemember, OAuth2Token, SocialAuth, PasskeyAuth.

Clone this wiki locally