Skip to content

feat(session): add a database session driver and logout from other devices - #400

Merged
techmahedy merged 7 commits into
doppar:4.xfrom
techmahedy:feat/database-sessions
Nov 11, 2026
Merged

techmahedy merged 7 commits into
doppar:4.xfrom
techmahedy:feat/database-sessions

Conversation

@techmahedy

@techmahedy techmahedy commented Nov 5, 2026 •

Copy link
Copy Markdown
Member

Pull Request Checklist

Q A
Branch? 4.x
Bug fix? no
New feature? yes
Deprecations? no
Issues -
License MIT

Summary

Adds a database session driver, and lets a signed-in user sign out of every other device while staying signed in on the current one. Signing out works with every session driver; with the database driver the other sessions are also deleted immediately.

Database session driver

  • DatabaseStore (SESSION_DRIVER=database) for MySQL/MariaDB, PostgreSQL and SQLite, with new connection and table options.
  • Migration: php pool session:table generates it for the configured table. When session.connection is set, the migration uses Schema::connection('…') and the command prints the matching php pool migrate --connection=…. last_activity is a big integer, so it does not overflow in 2038 on PostgreSQL.
  • Table name: letters, digits and underscores only. Schema-qualified names such as app.sessions are rejected, because the schema builder and the store would quote them differently; on PostgreSQL, set search_path on the session connection instead.
  • Cost per request:
    • loading a session is one primary-key lookup; saving is one upsert;
    • statements are prepared once per connection and reused, with no query builder;
    • a request that does not change the session does not write it, but looks it up once more at the end to check that another request did not end or rotate it;
    • expiry, IP address and user agent are refreshed at most about once a minute;
    • nothing connects to the database until the session is used.
  • Concurrency: implements LockableSessionStore with a lock on one session only: GET_LOCK() on MySQL, an advisory lock on PostgreSQL, a short write transaction on SQLite. Locks are re-entrant and never wait longer than the lock timeout (2 seconds). Concurrent saves are merged exactly as with the file driver.
  • User tracking: implements a new UserSessionStore contract. Each row records user_id (the default actor's user), ip_address and user_agent.
  • Safety:
    • payloads are binary-safe (base64);
    • user agents are stored as at most 1024 printable ASCII characters, so a hostile header cannot break the insert;
    • a missing table gives a SessionException that says how to create it.
  • Housekeeping: expired sessions are swept in batches of at most 1000 using the last_activity index.

Logout from other devices

  • auth()->logoutOtherDevices($password) confirms the password, then:
    • re-hashes it, which invalidates every other session's password fingerprint;
    • replaces the remember-me token, so other devices can't sign back in. Every actor on this device whose remember-me cookie is valid for this user gets a cookie for the new token; an invalid or out-of-date cookie is expired instead;
    • keeps the current device signed in: every actor it is signed in to as this user, including one signed in only by its remember-me cookie, and its other requests that are running at the same time;
    • with the database driver, deletes the other sessions immediately, each under its own lock, following automatic id rotations, so a request that is saving or rotating one of them can't keep it alive or write it back.
  • Credentials are written with one keyed UPDATE rather than save(). save() silently skips attributes missing from $creatable, and model hooks could hash the new hash a second time. A model that overrides getAuthPassword() without getAuthPasswordName() gets a LogicException before anything is written.
  • Password changes through the model: Authable::save() and update() save a new password of a user this device is signed in as together with this device's fingerprint, so the device and its running requests stay signed in. Changing another user's password leaves this session alone.
  • AuthenticateSession middleware (opt-in): signs out sessions whose fingerprint is stale, which works with every driver. A mismatch is checked again under the session lock against the stored session and the database, so a request that loaded the session before a password change of this device is not signed out. After the request, the fingerprint follows a password changed on the signed-in user model; it is never reloaded from the database, because a change there may come from another device that is signing this one out.
  • auth()->refreshSessionFingerprint() records the password from the database, for a password changed with a query instead of the model.
  • Sessions from before the middleware have no fingerprint, so they are signed out once. They keep their other session data, and a valid remember-me cookie signs them straight back in.
  • The fingerprint is an HMAC of the password hash keyed with the app key, so the session never contains the hash itself.
  • auth()->sessions() lists the signed-in user's devices (IP, user agent, last activity, current or not) and never exposes session IDs.
  • Non-default actors: sessions are never deleted using the default actor's user IDs; those actors rely on the fingerprint.
  • New Authable methods: getAuthPasswordName() and getRememberTokenName(), for custom column names.

New contracts and methods

  • UserSessionStore: userSessions(int|string $userId): array and destroyUserSessions(int|string $userId, array $keep = []): int. $keep holds this device's session ids; what they were rotated into is kept too.
  • CommittableSession (implemented by Session): storedId(), exclusively(), storedValue() and putThrough(), for code that must change the stored session while it is locked.
  • RotationPointer: the format of the pointer an automatically rotated session id leaves behind, shared by Session and the database store.

Behavior changes

  • A remember-me cookie whose token no longer matches now only expires that device's cookie. It used to clear the user's stored token, which also signed out the device holding the current one.
  • With AuthenticateSession registered, sessions created before it are signed out once (their other data is kept; a valid remember-me cookie signs them back in).
  • Authable::save() and update() save a password change of the signed-in user together with this device's session fingerprint.
  • Logins also store a password_hash_{actor} fingerprint key in the session.

Existing drivers otherwise behave as before, and the middleware is opt-in.

Known limits

  • With the cookie driver, a request of this device already carrying the old cookie can't see the new fingerprint, so it is signed out. Keeping running requests signed in needs the file or database driver.
  • A password changed with a query bypasses the model. Calling refreshSessionFingerprint() afterwards keeps this device signed in, but a parallel request that checks the session in between can still be signed out.

Testing

  • Regression tests for every review finding, including parallel requests on the database, file and array drivers.
  • DatabaseStore tests pass on SQLite, MySQL and PostgreSQL, including the migration generated for a configured connection.
  • The full suite and PHPStan pass.
  • Two test stubs replaced config() for every later test once loaded, which hid that AuthenticateTest failed when run on its own. Both now apply only while their own tests run.

Checklist

  • Tests have been added or updated
  • Documentation has been updated if necessary
  • Code follows the project coding standards
  • All tests pass locally

@techmahedy techmahedy added the feat new feature label Nov 5, 2026

@abdulmajidcse abdulmajidcse left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@techmahedy Found these at e63b325 by reading the code (not reproduced locally):

Fix before merge

  1. Authenticate::user(): after logoutOtherDevices(), an old device's cookie hits the "possible theft" branch and sets the new remember token to NULL, so the current device's new cookie stops working too.
  2. logoutOtherDevices(): after regenerate() or a re-login, it only excludes the new session ID, so this device's stored row is deleted and the browser keeps a dead cookie.
  3. refreshSessionFingerprint(): it only sees resolvedUser, so a password change through another instance (User::where(...)->update(...)) signs out that device.
  4. isSessionCurrent(): sessions with no fingerprint are stamped with the new one, so pre-upgrade sessions survive logoutOtherDevices() on drivers that rely on the fingerprint.
  5. create_sessions_table.stub: it ignores session.connection, so the table is created on the default database while the store uses the configured one.

Smaller

  • destroyUserSessions() doesn't follow rotation pointers, so a concurrent rotation can leave a session alive or delete the current device's new row.
  • A model that only overrides getAuthPassword() still gets password from getAuthPasswordName().
  • logoutOtherDevices() issues a new remember token if any remember cookie exists, without validating it.
  • A dotted table name like app.sessions may be quoted differently by the migration and the store (unconfirmed).
  • The table-name regex is duplicated in SessionTableCommand and DatabaseStore.

@techmahedy

Copy link
Copy Markdown
Member Author

@abdulmajidcse please check now

@abdulmajidcse abdulmajidcse left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@techmahedy Most of my last review is fixed, and the tests and PHPStan pass at 451f6ea, also when merged with 4.x. I reproduced these against this commit, using a scratch test on SQLite:

Fix before merge

  1. logoutOtherDevices(): the new password is saved immediately, but the session's fingerprint is only saved at the end of the request. A parallel request from the same device in that gap looks stale and its session is deleted, so the device that should stay signed in is signed out.
  2. logoutOtherDevices() only refreshes the calling actor's fingerprint. After actor('admin')->logoutOtherDevices(), the web check fails on the next request and invalidates the whole session, signing out both actors.
  3. isSessionCurrent(): a session with no fingerprint is signed out and loses its data unless a remember cookie restores the sign-in. The description and docs say turning the middleware on signs nobody out, so either the code or both claims need to change.

Smaller (from reading the code)

  • A wrong remember token no longer clears the stored token. The description still says there are no breaking changes.
  • touch() only updates last_activity, so sessions() can show an old IP with a recent time.
  • last_activity is a signed INTEGER on PostgreSQL and overflows in 2038.
  • An unchanged session reads the full row a second time in stillCurrent(), which the description's cost per request doesn't mention.
  • Session::ROTATED_TO and ROTATED_UNTIL are unused, and the explode('.') in table() can't do anything now that dots are rejected.

@techmahedy

Copy link
Copy Markdown
Member Author

Thanks @abdulmajidcse, all reproduced and fixed:

  1. logoutOtherDevices() saves the new fingerprint into this device's stored session under the session lock before writing the password, and a mismatched request checks again under the same lock. A parallel request of this device stays signed in (tested on the database and array stores).
  2. It updates the fingerprint of every actor this device is signed in to as the same user, and re-sends the remember cookie to each actor that had a valid one.
  3. A session without a fingerprint is still signed out once, but keeps its other data; the description and docs now say so.

Smaller: touch() refreshes the IP and user agent, last_activity is a big integer, the unused constants and the explode('.') are gone, and the description now lists the remember-token change and the extra lookup of an unchanged session.

Also fixed two test stubs that replaced config() for every later test, which had hidden that AuthenticateTest failed when run on its own.

@abdulmajidcse abdulmajidcse left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@techmahedy The three bugs from my last review are fixed at 9941f04, and I confirmed each one. The tests and PHPStan pass too. Two things are left before merge:

  1. The description wasn't updated. It still says "turning it on signs nobody out" and "Breaking changes: None", and it doesn't mention the remember-token change or the extra lookup of an unchanged session.
  2. A normal password change still has the parallel-request bug. With AuthenticateSession on, the controller saves the new password on auth()->user(), but the fingerprint is only saved at the end of the request. A parallel request in that gap deletes the session, so the device is signed out. I reproduced this on the database and file drivers. The docs promise this device stays signed in, so either fix it or update the docs.

Smaller (also reproduced)

  • An actor signed in only by its remember cookie is still signed out when another actor calls logoutOtherDevices(), because its cookie isn't re-sent and the token is cleared.
  • On the cookie driver, a parallel request during logoutOtherDevices() is still treated as a guest.

@techmahedy

Copy link
Copy Markdown
Member Author

Thanks @abdulmajidcse, both fixed and reproduced first:

  1. Description updated: the middleware note, the per-request cost, and a behavior-changes section replacing "Breaking changes: None".
  2. Authable::save() and update() now save a password change of the signed-in user through the same locked path as logoutOtherDevices(): the new fingerprint goes into this device's stored session before the password is written, so a parallel request stays signed in. The new tests cover the database, file and array drivers and update(). Changing another user's password leaves this session alone.

Smaller:

  • An actor signed in only by its remember cookie now keeps its token and gets the new cookie.
  • Cookie driver: a request already carrying the old cookie can't see the new fingerprint, so this is now documented as a limit of that driver.

Docs updated too: a password changed with a query still needs refreshSessionFingerprint(), with a short window during which a parallel request can be signed out.

@abdulmajidcse abdulmajidcse left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@techmahedy Everything from my last review is addressed at 279aad5, but the new Authable::save()/update() hook adds two regressions. I reproduced both, and both worked at 9941f04:

  1. A rolled-back password change signs the device out. The new fingerprint is saved before the password and isn't undone if a surrounding transaction rolls back:

    DB::transaction(function () {
        $user = User::find($id);
        $user->password = $newHash;
        $user->save();
        throw new RuntimeException('a later step failed');
    });

    The password in the database doesn't change, but the device is signed out on its next request (database and file drivers).

  2. One broken actor config blocks password changes. actorsSignedInAs() now calls getModel() for every actor in auth.actors before checking the session. If an unused actor points to a removed model class, save(), update() and logoutOtherDevices() all throw on a password change, and the password stays unchanged. Checking the session first, as before, fixes it.

@techmahedy

Copy link
Copy Markdown
Member Author

Thanks @abdulmajidcse, both reproduced and fixed:

  1. A password change now saves the new fingerprint as a pending one next to the current one. It becomes current once the database holds the new password, so a change rolled back by a surrounding transaction leaves the device signed in. The middleware no longer copies the fingerprint from the in-memory model after the request, since that model still holds the rolled-back hash. Tested on the database, file and array drivers.
  2. actorsSignedInAs() checks the session and the remember cookie before it loads an actor's model, so an unused actor with a removed model class no longer blocks save(), update() or logoutOtherDevices().

@abdulmajidcse abdulmajidcse left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@techmahedy Both issues from my last review are fixed at 6e96e3d, and the tests and PHPStan pass. One new regression needs fixing before merge. I reproduced it on the database and file drivers, and it worked at 279aad5:

A model hook that hashes the password signs the device out.

$user = auth()->user();
$user->password = 'plain';
$user->save(); // a before_updated hook hashes it

The pending fingerprint comes from the plain value, but the database holds the hash the hook created, so they never match. The middleware refresh that used to cover this is gone. Setting the pending fingerprint again from the model's final password after the write should fix it.

Smaller (also reproduced)

  • The same hook case with User::find() also signs the device out. This isn't new, and the same fix should cover it.
  • A committed password save followed by a rolled-back one in the same request signs the device out, because the second pending fingerprint overwrites the first.
  • On the file driver, an after-hook that throws leaves the new password saved but undoes the fingerprint, so the device is signed out.
  • The description still says the fingerprint follows the password after the request, which the middleware no longer does.

@techmahedy

Copy link
Copy Markdown
Member Author

Thanks @abdulmajidcse, all reproduced and fixed:

  1. The pending fingerprint is now recorded after the model's before_updated hooks, right before the write, so a hook that hashes the password is covered, on auth()->user() and on User::find() alike.
  2. Pending fingerprints are a short list, so a committed change survives a later rolled-back one in the same request.
  3. Nothing is undone on failure: a pending fingerprint only becomes current once the database holds that password, so an after hook that throws no longer signs the device out.

Also found while fixing 3: the password write used to run inside the session lock, which on SQLite is a transaction, so a throwing after hook also rolled back the application's own update. The write now runs outside the lock; only recording the fingerprint is locked, before the write, which still covers parallel requests.

@techmahedy
techmahedy merged commit 88f3aca into doppar:4.x Nov 11, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feat new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants