История коммитов

.
feat(auth): hash passwords with password_hash instead of double md5
Passwords were stored as md5(md5($password)): unsalted, and fast enough that a
leaked users table is a list of passwords rather than a list of hashes — two
accounts with the same password even had the same value in the column.

They are now hashed with password_hash(), bcrypt by default and argon2id where a
site configures it, with a per-password salt and a cost that can be raised later.

Nobody is asked to reset anything. A stored value in the old scheme is still
accepted, and the first successful sign-in replaces it with a current hash — the
only moment the password exists in the clear is the only moment this can be done.
Raising the cost later reaches existing accounts the same way.

The column is out of the model's fillable list and into its hidden one: a handler
filling a model from a form can no longer set a password however the form was
crafted, and a user that ends up in a JSON response does not carry the hash.

An empty column is refused outright rather than handed to password_verify(),
which matters for the accounts that will have no password at all.
.
refactor(auth): move the sign-in core out of the login module
There were two implementations of signing in: the login module's use case and a
second one inside the admin controller, written in raw SQL with its own captcha
handling. Two copies of the same decision, and every change to it had to be made
twice or silently made once.

The decision now lives in AuthenticateUserUseCase, in the core. Not in the login
module, because the admin panel cannot depend on it: a site may have no public
sign-in at all — the theme may not offer one, the module may be switched off —
and an administrator still has to get in. Both screens stay, both are now thin.

The use case answers with a status rather than a message: the wording differs
between the two screens, and the admin panel deliberately says the same thing for
wrong credentials, an unconfirmed address and an account awaiting approval —
distinguishing them there would confirm which logins exist.

Signing in with correct credentials but without administrator rights now answers
403 on the sign-in screen itself, instead of signing the visitor in and letting
the panel refuse them on the next page.

The failure counter stops at the threshold instead of climbing forever: past it
the form asks for a verification code anyway. Issuing that code and checking the
answer moved into LoginCaptcha, so the two screens stop keeping their own copies
of the session key, and a wrong answer now spends the code — it used to stay
answerable for a second guess on the admin screen.

Fixes the "remember me" carried through the admin captcha step: the hidden field
was always submitted, empty value included, so declining to be remembered turned
into being remembered as soon as a verification code was asked for.
.
feat(profile): add the my-devices screen
Lists where the account is signed in and lets any of it be closed — the screen
that only became possible once sessions moved to the server. The previous cookie
had nothing to list and nothing to revoke.

Closing the session the page is open in is signing out and clears the cookie
too, otherwise the browser would keep sending a token that no longer works.
Sessions are matched against the visitor before anything is closed: the
identifier arrives in a form, so without that check any number would close
somebody else's device.

Impersonation sessions are absent from the list, so an administrator browsing as
this user stays invisible to the account it is done under — the audit trail is
where that visit is recorded.
.
feat(auth): clean up sessions and recovery links on a schedule
Both tables grow with every sign-in and every forgotten password, and nothing
shrinks them on its own — which is how cms_users_iphistory became what it is.

Sessions are kept for a month after they stop working, so the profile can still
show that a device was signed out instead of the row silently disappearing.
Cleaning by expiry alone would never reach them: a session signed out today
keeps a future expiry, so revocation time counts too. Recovery links have
nothing to display and go as soon as they are dead.

Runs nightly through the scheduler and is available as a maintenance task in the
admin panel for installations without cron.
.
feat(auth): sign visitors in with a session instead of a password cookie
The cookie pair goes away. cuid held the user id in the clear and cups held
md5 of the password, so the cookie was a password equivalent: it could not be
revoked, it stayed valid until the password changed, and anyone who read it
held the account. Signing in now opens a session and hands out a token that
means nothing without the row behind it.

Every screen that signs somebody in goes through SignInManager: the public
form, the admin panel, the end of registration and the installer. Each of them
used to set two cookies itself, with its own flags and its own order — which
is how they ended up not HttpOnly, not Secure and without SameSite. Signing in
also gives the PHP session a new id, so a value planted in the visitor's
browser beforehand does not survive it.

Signing out closes the session on the server rather than only clearing the
browser, so a copied cookie stops working too. Changing a password closes every
other session of that account.

The public form gains a "remember me" checkbox, pre-checked. Without it the
cookie is a session one and the row lives twelve hours; with it, thirty sliding
days. The admin form already had the field.

Identifying the visitor now happens in one place for every runtime — the kernel
— and the boot no longer does it. It could not stay there: the kernel clears
the per-request state of the shared services at the start of each cycle, so a
cookie reissued during boot was thrown away moments later, and the sliding
lifetime silently stopped sliding.

The two legacy current-user models are filled from the identity the
authenticator chain produced instead of re-reading cookies and re-checking a
password hash. That removes the second and third authentication per request,
along with the rule that let a cookie through after three failed sign-ins as
long as the address and the user agent matched.

FunctionalTestCase gains actingAs(): it opens a real session and drives the
request through the real authenticator, so tests exercise the path visitors
take and production code needs no seam for them.
.
feat(auth): identify the visitor by the session cookie
Wires the session layer into the request cycle: SessionCookieAuthenticator reads
the sign-in cookie, finds the session behind it and answers with the identity.
Nothing issues those cookies yet, so every request still resolves to a guest
through this path and behaviour is unchanged.

The sliding lifetime is applied here, and the cookie is reissued whenever the row
is extended. Those two have to happen together: a row that slides forward while
the browser keeps the cookie it was handed at sign-in signs the visitor out
exactly one lifetime after signing in, however often they came back — the usual
way a sliding session turns out not to slide.

A cookie matching no usable session is not an error; it belongs to a session that
was signed out or expired. It is dropped from the browser so it stops being sent
on every request for the rest of its year.

Cookies decided on before a response exists are queued instead. CookieQueue is
emptied between requests, so nothing decided for one visitor can reach the next,
and it keeps this code out of setcookie(), which writes to the output directly
and cannot be undone, asserted on, or used in a worker runtime.
.
feat(auth): store sign-in sessions on the server
Adds the auth_sessions table and the layer around it: one row per signed-in
device, holding the digest of the cookie that device carries.

This is what the previous scheme could not do at all. The cookie used to be
md5 of the password, so it was valid until the password changed and there was
nothing to revoke: no signing out elsewhere, no list of active devices, no
dropping every session when a password changes. A row per device gives all
three, and the cookie itself becomes 32 random bytes that mean nothing without
the row.

Lifetimes slide from the last visit rather than from the sign-in, so coming
back on day 14 pushes the end to day 44 and only a real month of absence signs
anyone out. Extending is a write, so it happens at most once every five
minutes — measured against last_used_at on the row and not against anything
this process remembers, or a visitor returning after a fortnight would never be
extended. The absolute cap is off by default: switching it on would sign the
most active visitors out every few months, which is exactly what the sliding
window is meant to avoid.

Rotation replaces the secret while the device keeps its row, so a value known
before signing in or before a password change stops working afterwards.

AuthCookieFactory is the only place a sign-in cookie is built, which is how its
flags stop being forgettable: HttpOnly and SameSite=Lax always, Secure
following the scheme of the request. Without "remember me" the cookie is a
session one, but the row expiry is what actually limits the visit — browsers
restore session cookies when reopening tabs.

The manager stays free of HTTP so the lifetime arithmetic is testable without a
request; nothing is wired into the request cycle yet.
.
fix(auth): make password recovery links unguessable and single-use
The recovery code was md5(random_int(1000, 9999)) — nine thousand possible
values, stored in the clear in users.rest_code. Anyone who knew an account id
could walk the whole space in seconds and take the account over, and a database
dump handed out working links directly.

Recovery links are now 32 random bytes, kept only as a SHA-256 digest in the new
password_reset_tokens table. The identifier still in the URL is a convenience:
the token decides whose account it is, and a mismatch is rejected.

Checking the link and spending it are separate steps, because the flow has two:
opening the form only asks whether the link is still good, submitting it is what
consumes the token. So an opened-but-abandoned link keeps working while a
submitted one is dead, and a resubmitted form is a no-op instead of a second
password reset.

Asking for a new link drops the outstanding one, so only the newest letter works
instead of every letter staying valid for its hour. The one-per-day limit now
counts letters sent rather than links followed: spending a token no longer opens
the door to asking for another one straight away.

The table definition lives in AuthSchema, which the installer, the new
auth:upgrade-schema command and the tests all call — a copy in any of them would
drift and the drift would only show on somebody else's site. users.rest_code and
users.rest_time are left in place for now; they are dropped with the rest of the
legacy columns.

Recovery links already in flight stop working when this is deployed.
.
feat(auth): add the authentication and authorization skeleton
Introduces Johncms\Auth: the layer the new sign-in and permission system is
built on. Nothing calls it yet, so behaviour is unchanged.

Authentication is a chain: every AuthenticatorInterface tagged
johncms.auth.authenticator is asked in turn and the first one to recognise the
request wins; a request nobody recognises belongs to a guest. CurrentUser
resolves that once per request, lazily, and is reset between requests.

Authorization is a chain of voters: any Deny refuses, a single Allow is enough,
and a check nobody voted on is refused. The asymmetry is what later lets a ban,
an impersonation limit or the abilities of an API token take away what a role
grants. Refusing by default means a permission whose voter was forgotten closes
the door instead of opening it.

Identity carries ids, role slugs and permission keys and nothing that needs a
query, so authorization is testable without a container or a schema —
tests/Support/IdentityFactory is what that buys.

The can() helper and its Twig function are not here: with no voters registered
they would answer false to everything, and a silent denial is the worst kind of
placeholder. They arrive with the role voter.
.
chore(mail): drop the unused smtp name option
A leftover of laminas-mail, where it was the HELO hostname. MailFactory builds the DSN from host, port, username, password, encryption and auth_mode and never reads this key, so it only misled whoever configured SMTP.