# Sign-in: how S8 is built

What S8 built (plan CORE §3.5; TuteDesk's sign-in, 24 October 2026, made for Inertia) and how later slices add to it.

## The pieces

| Piece | What it does |
|---|---|
| `routes/auth.php` | Fortify's controllers behind Atrium's own routes (`Fortify::ignoreRoutes()`): sign in and out, the second step, the reset, step-up, the session's keep, Your security. Each write has its named limiter. Fortify's endpoints that switch two-factor on and off don't exist |
| `FortifyServiceProvider` | The views (Vue pages), the pipeline (`RefuseLockedAccount` first, then `ApplyRememberRule`, then the second step, then the session), and the events: sign-in, failure, two-factor failure, recovery code used, reset, sign-out, each in the sign-in log; failures counted by `AccountLock`; a sign-in with the box ticked given its time (`RememberMe::given`) |
| `Limiters` | `sign-in` (e-mail and address), `two-factor` (the pending sign-in), `password-reset` (e-mail and address), `step-up` (the account), `api` (the token, S12), `search` (the person, S16), `privacy-code` (a copy's link and the address, S17), each figure a setting |
| `SignInPages` | Too many tries: the form again with the wait (`retry`, counted down on the button). A page's form after its session ended (an `AuthenticationException` or a 419): a 401 the shell catches, so the page stays |
| `TwoFactor` | Required by a role's `requires_two_factor`, by the account holder's role while `security.holder_two_factor` is on (as shipped; `Role::requiringTwoFactor`, 25 Oct 2026), or for the Service (`service.require_two_factor`, on in production); the new secret pending in the session until a code confirms it; eight recovery codes; off only when not required. A used recovery code is spent (`User::replaceRecoveryCode` drops it) |
| `AccountLock` | `security.lock_attempts` failures in a row lock for `security.lock_minutes`; a sign-in resets the count; a reset or a person unlocks |
| `KnownDevices` | The Service's browsers (a five-year encrypted cookie; its hash a `known_device` row of the account, the newest ten kept; a list in the preferences until plan SN D5); a new one is mailed |
| `RequireTwoFactor` | Whoever must use two-factor and hasn't is sent to set it up (Your security, sign-out, step-up and the session stay open) |
| `RequirePassword` (`password.confirm`) | Step-up: within `security.step_up_minutes` of a confirmation, through; a page's POST goes back with `step_up` (the shell's dialog, which repeats the visit); a GET goes to the confirm page and back |
| `RequireFreshSignIn` (`fresh`; plan SEC, SEC-8) | A sign-in within 15 minutes (`SignInProof`'s `auth_time`) or the step-up window; otherwise answered as `password.confirm` answers, and a JSON request (adding a passkey) is told 423 |
| `SignInController::byCode` | A code goes to an account that isn't the Service, and never from the console's host (plan SEC, SEC4 F13): there it's held as for a number with no account |
| `StepUpCodeController` (plan SEC, SEC-9) | Step-up for an account with no password: `GET /user/confirm-code` its verified phones and e-mails (part hidden), `POST /user/confirm-code/send` (limiter `sign-in-code-again`) a code through `SignInCodes` with its waits and pauses, `POST /user/confirm-code` (limiter `sign-in-code`) checks it, opens the step-up window and logs `confirmed_by_code`. 409 for an account with a password, or while a product asks for the password (CL-5). The shell's dialog and the confirm page choose by the shared `session.step_up_by` |
| `EndIdleSession` | `security.idle_minutes` without use end the session (before `auth` reads it); `X-Background` requests and `/session/status` (outside the session) don't count; a remembered browser turns it off only while `RememberMe` holds, and one that doesn't is forgotten here first; the sign-in page says why. It also sets the remember cookie's length from the setting |
| `RememberMe` (25 Oct 2026) | "Keep me signed in": `security.remember_days` (14; 0 doesn't offer it), and not for someone `TwoFactor::required` names unless `security.remember_two_factor`. The browser's expiry isn't trusted: a second encrypted cookie (`core_remembered`, the account and when) is judged on every request, so a shorter setting, or a role that starts requiring two-factor, ends a kept browser at once. Its settings are read at the platform's level, never through the signed-in person, which would let the cookie sign in before it is judged |
| `ApplyRememberRule` (25 Oct 2026) | A step of the pipeline: the box ticked for an account `RememberMe` doesn't allow is taken as not ticked, before the second step keeps the choice |
| `PasswordChange`, `Sessions` (25 Oct 2026) | The one door an existing account's password changes through (the reset, `service:reset`; Your account (`AccountController`) form too): saved, then `Sessions::endOthers` deletes the account's other session rows and changes the remember token, in the sign-in log as `password_changed` with how many ended. The request's own session stays when it is the account's; a reset is a guest's, so all end. Signing out everywhere uses `Sessions::endOthers` too. Not a model hook: Laravel rehashes a password on sign-in when its cost changes, which would sign everyone out |
| `SessionController` | `/session/status` (read without starting the session) and `/session/keep` |
| `StepUp.vue`, `IdleWatch.vue` | In the shell: the "Confirm it's you" dialog; the "Still there?" warning with its ring, and the lock that signs in again over the page (password, then the code) so what was typed stays |

## Settings (Security, all the platform's)

`sign_in_attempts`, `two_factor_attempts`, `password_reset_attempts`, `password_change_attempts`, `step_up_attempts`, `lock_attempts`,
`lock_minutes`, `step_up_minutes`, `idle_minutes` (env `IDLE_MINUTES`), `idle_warning_minutes`, `invitation_days`,
`remember_days` (env `REMEMBER_DAYS`, 14; 0 to 90) and `remember_two_factor` (off) since 25 October 2026, and
`holder_two_factor` (env `HOLDER_TWO_FACTOR`, on; off in the test suite, `phpunit.xml`, and turned on by
`Auth/HolderTwoFactorTest`). A security setting read before anyone is signed in is read with `Setting::platform()`.

## Changes that need a fresh proof (`fresh`; plan SEC, SEC-8)

At id: `setting.domain.store` and `.destroy`, `setting.product.store`, `person.store`, `.destroy`, `.active`, `.invitation.store`,
`import.person.invitation`, and `import.person.run` (the run of a person import only). `Auth/FreshCheckTest` lists them; add `fresh` to a new
route that hands someone a way in or points a product at a new address. Tests of those routes start with `signedInNow($this)`.

## Sensitive routes (step-up)

Roles (`role.store`, `role.update`, `role.destroy`, `person.role.update`, `ownership.update`), the lens
(`platform.lens.store`), the platform's settings and exceptions, and Your security's turning off, new codes and signing
out everywhere. Add `password.confirm` to a new route that changes access, tokens or purges (S10's Run it now has it). The password
check (`password.confirm*`, `session.keep`) is open to the Service without a lens and to a person with no workspace
(`ResolveTenant`): until S10 the Service's dialog was answered by the no-lens page.

## Mail (the engine's events)

`two_factor.enabled`, `two_factor.disabled`, `recovery_code.used` (codes left), `account.locked` (until), and
`service.new_device`: each to the account's owner, platform mail, locked on.

## Your account (plan WA)

`me/Account.vue`, in the user menu: the person's own name, sign-in e-mail and password. All three are the account's; each
workspace's record of them stays its own (plan SP), except as below. The Service has its name only: its address is
`service:transfer`'s, its password `service:reset`'s, and both routes refuse it.

**The name** writes `person`. **The password** goes through `PasswordChange` after the step-up (WA-3: no current-password
field). That door also sends `password.changed`, so the owner hears of every change: from here, from a reset link, or from
`service:reset`.

**The sign-in e-mail** waits as one row of `email_change` (the account's only one; the token kept hashed):

1. Asking (`RequestEmailChange`, step-up, limiter `email-change`): the current address hears at once
   (`email_change.requested`, the new one masked), and the link goes to the new one (`email_change.link`, a secret, mail
   only). It works for `security.email_change_hours`.
2. An address already an account's, or the one the Service's identity names, gets the same answer and the same page, but
   no link (WA-6): the row holds a token nobody was sent. So the form can't test which addresses have accounts, and
   nobody becomes the Service by taking its address before it has an account.
3. Opening the link (`GET /account/email/{token}`) changes nothing, because mail scanners open links; the button does.
   It works signed in or not, and signs nobody in.
4. Confirming (`ConfirmEmailChange`), in one transaction: `user.email`, and every membership of the person holding the old
   address, each saved within its workspace so its audit log says so, and so the account keeps reaching them
   (`Membership::lets`). A membership holding some other address is that workspace's own record, and stays. Reset links
   for the old address go. If the new address is another member's in one of those workspaces, the whole change is refused
   (WA-4) and the link is spent.
5. `email_change.done` goes to both addresses, one e-mail each, so the old address never reads the new one.

A password change cancels a waiting request (`PasswordChange`), and `model:prune` removes one past its time each night.

## The sign-in log's new events

`unlocked`, `two_factor_enabled`, `two_factor_disabled`, `two_factor_failed`, `recovery_code_used`,
`recovery_codes_regenerated`, `password_confirmed`, `signed_out_everywhere`, `new_device`; `password_changed` (with
`ended`, 25 October 2026).

## The guards

| Test | Proves |
|---|---|
| `Guards/NamedLimiterTest` | no plain throttle; only catalogued limiters; every sign-in write limited |
| `Guards/SettingGuardTest` | every limiter's figure a setting |
| `Guards/PasswordRuleTest` | one password rule everywhere, and the same refusal from each form; an existing account's password written only by `PasswordChange` |
| `Auth/PasswordChangeTest` | a reset ends every session and remembered browser of the account (others' kept); the changing session stays; `service:reset` too |
| `Auth/HolderTwoFactorTest` | the account holder sent to set up two-factor from the start, in a workspace made before too; locked on its role page and in the wizard; the workspace's choice back when the platform turns it off |
| `Auth/RememberMeTest` | kept for the setting's days past the idle clock, then forgotten; shortened or 0; a cookie without its time, or another account's, refused; two-factor required refuses it unless the setting allows; the choice kept through the second step |
| `Auth/TwoFactorTest` | set up only by a right code, codes shown once; the second step, a recovery code spent; required by a role, never off; the Service in production |
| `Auth/FreshCheckTest` | the fresh check on each change SEC-8 names; a code step-up for an account with no password, its ways, its tries and its log line; the confirm page sending back where it was going |
| `Auth/SessionHardeningTest` | the lock both ways and unlocking; step-up on every sensitive route and its window; the idle clock and background requests; a form after the end keeping its page; too many tries as a page; signing out everywhere; the Service's new browser |
