23 July 2026¶
1 change:
- Login method recorded in the activity log
Login method recorded in the activity log¶
The activity log now distinguishes how a user logged in — password,
twofactor or passkey — instead of recording every login as a generic
login. This makes the security audit trail meaningfully richer: a step-up
login is now visibly different from a plain password login.
Added¶
-
Auth::setLoginMethod(?string $method)— a new, additive public method that tags the authentication method for the next login the built-in lifecycle establishes. The tag is consumed by that login and then reset, so it can never leak into a later login on the sameAuthinstance. A null (or unset) tag falls back topassword— the default for the plainAuth::auth()path. -
LoginFlownow tags each completion path.attempt()(straight password login) tagspassword,completeTwoFactor()tagstwofactor, andcompletePasskey()tagspasskey. Theloginrow'sdetailsJSON therefore carries the real method, e.g.{"method":"twofactor","remember":false}.
Why the signature did not change (BC)¶
The natural fix — threading the method through Auth::loginById() — would add a
parameter to a public method that scaffolded apps override, an incompatible
signature change PHP rejects (CLAUDE.md §6). Instead the method is set on the
Auth instance just before the session is established: LoginFlow::finishLogin()
calls $this->auth()->setLoginMethod($method) ahead of establishSession(), and
Auth::buildLoginResponse() reads it into the login response the lifecycle
records. No public signature changed; the capability is purely additive.
Tests¶
- Unit (
LoginFlowTest) — each completion path tags the correct method, and a failed step-up tags nothing (no session, no tag). - Characterization (
AuthCharacterizationTest) —setLoginMethod()stores a one-shot tag that reaches the login lifecycle and is reset afterwards, and an untagged login defaults topassword. - Integration (
LoginFlowActivityTest, real MySQL) — a password, a two-factor and a passkey login each write exactly oneloginrow whose details recordpassword/twofactor/passkeyrespectively.
Fixed — DataTables recordsTotal in Model::_getApiList()¶
Model::_getApiList(format: 'datatables') set both recordsTotal and
recordsFiltered to the filtered row count. DataTables treats recordsTotal
as the grand total before the search box and recordsFiltered as the count
after it — so once a server-side search was applied, the "showing X of Y
(filtered from Z)" label and the pagination totals were wrong.
recordsFiltered now stays the filter+search count, while recordsTotal is
recomputed from the base $filter only (the extra count query is skipped when no
search is active, so the common case pays nothing). The same fix is applied to
the User::_getApiList() override, where recordsTotal is now the grand total
of all users. Both the paginated and unpaginated datatables paths are covered.
Verified with characterization tests across MySQL, PostgreSQL and TimescaleDB
(ModelListApi*CharacterizationTest, UserCharacterizationTest): a search that
matches a subset now yields recordsTotal > recordsFiltered.
Fixed — DataTables "Show all" capped at the default page size¶
Datatable\Datasource's modern DataTables 1.10+ parameter translation mapped a
page length of -1 (the "Show all" option) to maxlimit (default 50), so a
client asking for every row silently received only the first 50 — unlike the
legacy path, which honours -1 as "no limit". The translation also ignored a
pre-set legacy iDisplayLength, so a caller mixing the modern draw parameter
with the legacy length param (a DT 1.9 → 1.10 transition pattern) was capped
too.
The translation now (1) preserves length = -1 as unlimited, matching the
legacy path, and (2) falls back to a pre-set legacy iDisplayLength when no
modern length is supplied, before defaulting to maxlimit. Ordinary requests
without any length parameter still fall back to maxlimit, so the safety bound
is unchanged. Legacy (non-modern) callers are unaffected.
Covered by three DatasourceTest regression cases (with maxlimit forced low so
the cap is observable): modern length=-1 → all rows, modern draw + legacy
iDisplayLength=-1 → all rows, and no length → maxlimit.