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

.
feat(auth): convert access levels into roles
Gives every member of staff the role their users.rights value stood for, and keeps
that number in step with the roles from now on.

Only accounts above zero get a row: the role everybody has is applied without
one, so a site with a hundred thousand accounts comes out of this with a handful
of rows rather than a hundred thousand.

users.rights does not go away yet. Three hundred checks across the modules and
the templates still compare against it, and they cannot all be rewritten in one
commit without leaving the site broken in between — so the number stays, computed
from the roles instead of being the source of truth. Granting a role updates it,
and every one of those checks goes on working while they are converted one module
at a time.

The undocumented values are the reason this is not a straight lookup. The column
is a tinyint and checks like `rights >= 1` gave 1, 2 and 8 meaning too, so
accounts holding them exist. Each is mapped to the closest role below it, which
never grants more than the account had, and every one of them is listed in the
report for somebody to look at.

The numbers are written to a file before anything else happens. The mirror
recomputes the column from the roles afterwards, so 8 becomes 7 and the original
is otherwise unrecoverable.

Running again leaves accounts that already have roles alone: somebody may have
arranged them by hand after the first run, and a second run must not undo that.
--reset redoes them anyway, for when that is what is wanted.
.
feat(auth): add roles and permissions
Replaces one number with a model that can express more than one thing at a time:
roles carrying permission keys, and a chain of voters deciding what those keys
mean for a given request.

users.rights could only say how much authority somebody had, on a single scale.
A forum moderator and a downloads moderator were 3 and 4, and nothing could
describe someone who was both. Every check was a comparison against a magic
number, so what a role could do was spread across three hundred call sites
rather than written down anywhere.

The built-in roles keep the numeric value they stand for, so the old column can
go on answering while the call sites move over. Only the 'user' role is applied
without a row per account, which is what keeps user_roles small on a site with a
hundred thousand of them: the table holds the exceptions, not the rule.

Three voters arrive with it. Roles allow; a ban denies, and a Deny outranks an
Allow, which is how a ban now beats an administrator — the previous code got the
same effect by zeroing the number in memory, an approach that only worked while
everything read that one number. Supervisor level allows everything, so a
mistake in the permission matrix cannot lock a site out of its own admin panel.

Who a visitor is and what they may do are resolved separately, in that order.
Guests go through it too: anonymous visitors have a role, and it is what will
decide whether the forum and the library are open without signing in.

can() finally answers truthfully and is available to templates.

Existing installations need auth:upgrade-schema before this code serves a
request: the roles are read on every one, including a guest's.
.
feat(admin): show how many accounts still use the old password hash
Accounts move off the old scheme as their owners sign in, so what is left is the
people who have not come back. Without somewhere to see that number, support for
the old scheme eventually gets removed on the assumption that it is empty — and
the accounts it was still holding up become unreachable.

Shown as a Security section on the system check page, as a count against the
total. It is informational, not a fault: nobody has to do anything about it, and
resetting those passwords is exactly what this design avoids.

The query lives next to the verifier that recognises a single value, since the
two share the same knowledge and are meant to be deleted together.
.
feat(auth): throttle sign-in attempts and stop generating guessable codes
Guessing at the sign-in form was limited by users.failed_login: a counter on the
account, which made it possible to lock any account out by typing a wrong
password for it three times. It also counted nothing at all when the guesser
walked through logins that do not exist.

Attempts are now counted in the cache under two keys at once — the login being
tried and the address trying it — so neither rotating addresses against one login
nor walking through logins from one address gets around the limit, and failing on
somebody's behalf runs the guesser's own address out first.

Two thresholds, because they answer different problems: a verification code after
three failures stops a script while barely inconveniencing somebody who mistyped,
and a wait after ten stops a script that solves verification codes. The wait grows
with each further failure, so a run that keeps going becomes slower rather than
merely postponed. A refused attempt does not extend the wait, or hammering the
form would keep the real owner locked out indefinitely.

The confirmation codes for registration and for an e-mail change were built with
uniqid(), which is derived from the clock: knowing roughly when an account was
created narrows the possibilities to something a script can walk through. Same
for the CSRF token, where a guessable value defeats the whole check. All three now
come from the same random generator as every other secret here.

users.failed_login is no longer read or written; the column goes with the rest of
the legacy ones.
.
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.