Authentication
Login, "remember me", gates
# Authentication and authorisation
## Scaffolding it
```sh
askr new shop --auth # or answer the question it asks
askr make auth # into a project that already exists
askr build && askr migrate
```
That writes a `User` model, the migrations, an `AuthController` and the
mails it sends, under `mail/` — and wires it into `app.lpr`. You get these
routes:
| | |
|---|---|
| `/login`, `/register`, `/logout` | Signing in and out |
| `/forgot-password`, `/reset-password/:token` | Resetting a forgotten one |
| `/verify-email`, `/verify-email/:id/:hash` | Confirming the address |
| `/two-factor-challenge`, `/settings/two-factor` | Two-factor sign-in |
| `/dashboard` | Where signing in lands you, once the address is confirmed |
| `/settings/profile` | Name and email |
| `/settings/security` | Change password, manage passkeys |
| `/settings/passkeys`, `/login/passkey` | The WebAuthn ceremonies |
`askr new` asks when it is run from a terminal. `--auth` and `--no-auth`
answer for a script; without a terminal and without a flag the answer is no,
because a command that waits for input from a pipe hangs forever.
`askr make auth` refuses to overwrite an existing install without `--force`.
If it cannot find its markers in `app.lpr` — because you have edited it, as
you should be able to — it writes the files and prints the lines to add
yourself. Guessing at where to insert code in a file someone wrote is worse
than asking.
## After signing in
Signing in lands on `/dashboard`, not on `/`. It is a real page with a
sidebar, a profile form and a security page — not a placeholder, and not a
redirect into your app's own routes, because a new project does not have
any yet.
**It is yours to replace.** The point is that `askr new shop --auth`
produces something you can sign into and look around in, rather than a
login form that dumps you on the welcome page. When you build the real
thing in Inertia, point `/dashboard` at your own handler and delete these
three.
`/settings/security` lists your **passkeys**, with a button to add one
and a link to remove each. `/login` gains *Sign in with a passkey*. It
works out of the box on `localhost`; for production, set the RP ID and
origin — see [Passkeys](webauthn.md).
### What you get, and why it looks like that
**The pages are plain HTML, not Inertia.** A new project has Inertia set up
but not installed, and `npm install` is something you do afterwards.
Requiring it before you can sign in would make sign-in useless in exactly
the window where you need it. The pages use system fonts and inline CSS and
need neither npm nor a network, like the welcome page. The generated file
says how to turn them into Inertia pages.
**All of it is yours.** That is the point of a scaffold: you must be able to
change the login page. So the templates generate into your project rather
than living in the framework.
Choices in the generated code that are not arbitrary:
- **One message for "no such account" and "wrong password".** Saying which
turns the form into a directory of who is registered. The same applies to
the password-reset form: the answer is identical whether or not the
address exists.
- **A throttle on the login form**, five attempts per email in fifteen
minutes, via the cache. Without it the form is a target for guessing at
scale. The counter is on the email, not the IP: an attacker has many IPs
and usually one account to get into. If no cache is configured it is
skipped rather than taking sign-in down.
- **The reset table stores the hash of the token, not the token.** A leaked
table must not let anyone reset passwords — the same reasoning as for the
passwords themselves.
- **Reset tokens are single use and expire in an hour**, and *all* tokens
for the address are deleted on use: if someone asked for two links, the
other one must not still work.
- **A password reset replaces the session.** If someone else was signed in
as that user, they should not stay signed in.
- **Rehash on sign-in.** The plaintext is in hand right then, so a hash made
with weaker parameters is upgraded without asking the user anything.
- **Twelve characters minimum, and no other rule.** Requirements about
capitals and digits produce weaker passwords in practice, because people
write `Password1!`.
In development, mail goes through `TLogTransport` and writes to
`storage/mail.log`, so the reset link can actually be followed without an
SMTP server. In production the generated code uses `TSmtpTransport` and
requires `SMTP_HOST`.
## The parts
Two things that get conflated:
- **Authentication** is knowing who someone is. It lives in the session.
- **Authorisation** is deciding whether they may. It lives in gates.
```pascal
uses Askr.Auth;
UseAuth(R); { after UseSessions }
```
## Askr does not own your user model
The framework stores **one thing** — the user's id, as text — and lets your
app look up the rest through a loader it registers.
That is deliberate. A `TUser` from the framework would force a particular
schema, a particular table and a particular set of columns, and the first
thing any real app does is need one more column.
```pascal
function FindUser(const Id: string): TObject;
begin
Result := TQuery<TUser>.New.Find(StrToInt64Def(Id, 0));
end;
SetUserLoader(@FindUser);
```
## Logging in
Passwords live in [`Askr.Core.Crypto`](crypto.md). This unit never sees one;
your app verifies and calls `Login` with an id.
```pascal
U := TQuery<TUser>.New.Where(Users.Email, Eq, Email).First;
if (U = nil) or not VerifyPassword(Password, U.PasswordHash) then
begin
{ One message for both. "No such account" would tell anyone asking
who is registered. }
CurrentSession.Flash('error', 'Those credentials do not match.');
Exit(Redirect('/login', 303));
end;
Login(IntToStr(U.Id), Remember);
```
It sounds like a detour, but it is the one order that cannot go wrong: the
framework cannot know which column the hash is in, and an API that guessed
at that would have to guess at how the user is looked up too.
```pascal
Login(UserId); { the session id is replaced — see below }
Login(UserId, True); { and a "remember me" cookie is set }
Logout; { clears the whole session }
```
```pascal
Check; { is anyone logged in }
Id; { the id, or '' }
User; { the object from the loader, or nil — looked up once per request }
```
`Logout` clears the **whole** session, not just the user key. A cart or a
half-finished form belonged to whoever was logged in.
## Session fixation
`Login` replaces the session id. Without that, an attacker who set your
cookie in advance is logged in as you afterwards. It is one line, and there
is a test that fails if it is removed — verified by removing it.
## Remember me
A **signed cookie**, not a token in the database.
```pascal
Login(UserId, True);
```
The value is `<user id>|<expiry>` signed with the [app key](crypto.md). It
is **readable** — the signature proves we made it, it does not hide it. A
user id is not a secret, and the cookie alone grants nothing without a valid
signature.
The expiry is inside the signed payload, not only in the cookie's `Max-Age`.
A client that keeps the cookie longer than we asked must not get in.
> **It cannot be revoked individually.** Doing that requires storing a token
> per user, and the framework cannot know which table it would go in. That
> is a real limitation, stated here rather than discovered.
`UseAuth` restores the login from the cookie when the session is empty, and
gives the restored session a fresh id — it is a login, just without a form.
An already-logged-in session always wins, so an old cookie cannot override a
newer login.
## Gates
```pascal
function CanEdit(const UserId: string; Resource: TObject): Boolean;
begin
Result := (Resource <> nil) and
(TPost(Resource).AuthorId = StrToInt64Def(UserId, 0));
end;
DefineGate('edit-post', @CanEdit);
```
```pascal nocheck
if Allows('edit-post', Post) then ...
if Denies('edit-post', Post) then Exit(RespondText('Forbidden', 403));
Authorize('edit-post', Post); { raises EForbidden }
GateExists('edit-post');
```
`Resource` is `nil` for gates that are not about a particular object —
`admin`, `view-dashboard`.
**A gate that does not exist answers no.** A typo in a gate name must close
the door; the opposite looks exactly like everything working.
**Nobody logged in answers no**, for every gate.
Defining the same name twice replaces the earlier one, so an app can
override a gate from a library.
### Gates, not policies
Policy classes are a convention over reflection: names resolved at runtime.
A gate is a function with a name, and the compiler can see it.
## Email verification
A new account is signed in straight away and sent a mail with a link.
`/dashboard` wants a confirmed address and sends anyone without one to
`/verify-email`, which names the address, has a button to send the link
again — once a minute — and a way to fix a mistyped address. The profile
and security pages need only the sign-in, because they are where that
mistake is fixed.
- **The link is signed, not stored** — see *Signed links* below. It lasts a
day, and its path carries a hash of the address it went to, so changing
the address retires every link sent before: the old one says it was for
an earlier address rather than confirming the new one it never reached.
- **The link confirms the mailbox, and signs nobody in.** Opened in another
browser, it confirms the address and sends you to sign in.
- **A changed address is not confirmed.** Saving a new one on the profile
clears `email_verified_at` and sends a new link.
- The mails are `mail/auth/verify-email.html` and `.txt`, filled with
`Template` — see [Mail](mail.md#templates). A `verify-email.nb.html` next
to them is taken for a Norwegian reader.
The framework does not own the user model, so the app says what verified
means, next to its user loader — the scaffold registers it at the bottom
of the controller:
```pascal
SetVerifiedCheck(@UserVerified); { function(const Id: string): Boolean }
```
A handler that needs a confirmed address asks for one:
```pascal
Result := RequireVerified('/verify-email');
if Result <> nil then Exit;
```
It gives nil when the address is confirmed, and otherwise the answer each
client can use: a 303 to the notice for a browser, Inertia's 409 with
`X-Inertia-Location` for an Inertia visit, a 403 problem document for JSON.
With no check registered it says no — a page that asks for a confirmed
address and gets a yes nobody checked has let in exactly who it meant to
keep out.
It is a call in the handler, not router middleware, because a router's
middleware covers every route, and the pages that fix a mistyped address
must not need a confirmed one.
`./askr auth:check` drives a scaffolded app over a socket: register, read
the link out of the mail log, see a changed id, an added parameter and a
later expiry refused, confirm, change the address, and see the old link
refuse to confirm the new one.
## Signed links
```pascal
uses Askr.Signed;
Link := SignedUrl('/unsubscribe?list=news&user=42', 7 * 24 * 60 * 60);
```
```pascal nocheck
case CheckSignature(Req) of
scValid: ...;
scExpired: ...; { ours, but old: offer a new one }
scInvalid: ...; { changed, cut short, or never ours }
end;
```
A signed link carries an expiry and an HMAC under `APP_KEY` over the path,
the query and the expiry. Nothing is stored: the link is its own proof.
`SignedPath` gives the path alone; `SignedUrl` puts `app.url` in front and
raises when it is not set, because a link in a mail that points nowhere is
worse than no mail.
- **The host is not signed.** Behind a proxy the app may not see the host
the link was made for, and the path is what says what the link does.
- **Anything added is refused.** A parameter after the signature is read as
part of it, and a changed id or expiry no longer matches.
- **The signature is kept apart from everything else `APP_KEY` signs**, so
a signature from the remember cookie is never one for a link.
- **A new `APP_KEY` retires every link.** That is what rotating it is for.
- **Sign the path as the browser will send it** — percent-encoded. A path
with `æ` in it is signed as `æ` and requested as `%C3%A6`, and the two do
not match.
A signed link works as often as it is valid. Where once matters — a
password reset — a token in the database is the tool, because only a table
can forget a token when it has been used.
## Two-factor sign-in
`/settings/security` has a card for it. **Set up** makes a secret, seals it,
and shows a QR code and the key for an authenticator app — Google
Authenticator, 1Password, Authy, any that does TOTP — with a link that opens
it straight in the app on a phone. It is not on until a code from the app has
been typed; then eight recovery codes are shown, once.
With it on, a correct password is half of it: the session remembers who, for
ten minutes, and `/two-factor-challenge` asks for the six digits or a
recovery code before anyone is signed in.
- **Every way in with a password asks.** A password reset used to sign you
straight in; now it goes to the same question, so a reset link in somebody
else's mailbox does not get round the code. A passkey does not ask — it is
two factors in itself.
- **A code works once.** The step it belongs to is kept, and a code at or
before it is refused, so a code read over a shoulder or typed into a
phishing page is spent the moment you use it. One step either side of now
is taken, for a phone whose clock is a little off.
- **Wrong codes are counted** like wrong passwords, per user: after five,
even the right one waits.
- **The secret is sealed** with `SealText` under `APP_KEY` — see
[Cryptography](crypto.md#encrypting-a-column) — and the recovery codes are
kept as SHA-256 hashes. A table that leaks gives neither. A new `APP_KEY`
means codes cannot be checked any more; the challenge says so and a
recovery code still works.
- **New codes and turning it off need the password**, as changing it does:
a machine left signed in is not a way to either.
The pieces are in the framework, for an app of your own: `Askr.Totp`
(`NewTotpSecret`, `VerifyTotp` with the last step, `TotpUri`,
`NewRecoveryCodes`, `RecoveryCodeHash`), `SealText`, and `Askr.Qr` below.
`./askr auth:check` sets it up with the code computed in Python rather than
in the Pascal it is checking, and sees the password alone refused, a wrong
code refused, the same code refused a second time, a recovery code taken
however it is typed and then only once, a password reset asking for the
code, the password needed to turn it off, and a correct code waiting after
five wrong ones. **The ten minutes are not tested**: a gate that waits ten
minutes is not one anybody runs.
## QR codes
```pascal
uses Askr.Qr;
Html := QrSvg(QrEncode(TotpUri('Shop', U.Email, Secret)),
'Scan with your authenticator app');
```
For the page where two-factor sign-in is set up: a phone scans it and has
the secret. `QrEncode` takes bytes — UTF-8 as it is — at one of four levels
(`qrMedium` unless you say), uses the smallest of the forty versions that
holds them, and picks the mask by the standard's penalty score.
`QrSvg` draws it as an inline SVG with the four-module quiet zone, dark on
light whatever the page's theme, so it needs no script, no network and no
file next to the binary — like the rest of the sign-in pages.
Three things hold it:
- **python-qrcode**, module for module, at a fixed mask, at every level and
across the versions — every module of such a code is set by the
standard.
- **Nayuki's qrcodegen** for the mask the score picks. python-qrcode is no
reference there: it scores each mask with the format bits left light.
- **Chrome's own barcode reader**, in `./askr qr:check`: codes from a few
bytes to what version 40 holds, at every level, read back as the text
they were made from. The reader is macOS's, through Chrome's
`BarcodeDetector`; elsewhere the gate says it did not run.
Byte mode only — what a URI needs. No numeric, alphanumeric or kanji mode,
and no structured append.
## Requiring login
```pascal
RequireAuth(R); { redirects to /login }
RequireAuth(R, '/sign-in');
```
Everything registered from there on is behind it.
An ordinary browser request is redirected. **An Inertia or JSON request gets
401**, because a 302 to an HTML page is useless to a client that asked for
JSON — it would follow it and receive the login page as JSON.
## A caller that is not a browser
`Login` writes to a session, and a session is a browser mechanism. A
program presents its credential on the request instead:
```
Authorization: Bearer askr_...
```
`UseTokenAuth` resolves it and signs the request in for that request only,
with no session and no cookie. `Check`, `Id`, `User` and every gate then
answer as they do for a browser, so authorisation is written once. See
[API tokens](tokens.md).
```pascal nocheck
procedure LoginForRequest(const UserId: string);
function IsRequestIdentity: Boolean;
```
`LoginForRequest` is the hook underneath it, for plugging in a scheme of
your own. The identity lasts exactly as long as the request: it is
cleared when the arena resets, which is the first thing the next request
on that worker does. It **wins over the session** when both are present —
an `Authorization` header is the caller naming which credential to use.
## Doing it yourself
`Login` needs an ambient session, so it works inside a request handled by a
router with `UseSessions`. If you host requests yourself, set the session
first — see [Sessions](sessions.md).