Metadata-Version: 2.4
Name: splent_feature_auth
Version: 1.28.0
Author-email: DiversoLab <diversolab@us.es>
License-Expression: LGPL-3.0-or-later
Requires-Python: >=3.13
Description-Content-Type: text/markdown
License-File: COPYING
License-File: COPYING.LESSER
Requires-Dist: email_validator
Requires-Dist: pytz>=2024.1
Requires-Dist: splent_framework>=1.30.0
Dynamic: license-file

# splent_feature_auth

The `auth` feature of a [SPLENT](https://splent.io) product line.

Accounts, login, logout and sessions. It owns the `User` model, the login
page, the accounts screen for administrators (`/admin/users`), a person's own
account page (`/account`), the login throttle and the password policy. Every
SPLENT product installs it.

A SPLENT feature is a Python package that a product composes with others.
Its models, routes, services, templates and hooks are registered when the
product starts. What this feature provides and requires is listed in the
`[tool.splent.contract]` section of `pyproject.toml`, which
`splent feature:contract --write` keeps in sync with the source.

## The login page

The login page opens three slots, so other features add to it without
overriding the template. A callback takes no arguments and returns HTML, or
an empty string to add nothing on this request.

| Slot | Where it renders | When |
|---|---|---|
| `auth.login.providers` | After the submit button, under the words "or continue with" | Only when a callback returns something. Otherwise nothing is drawn, not even the words |
| `auth.login.error_hint` | Under the error, inside the form | Only after a wrong email or password. Never for a throttled attempt or an account that is not active, whose messages stay as they are |
| `auth.login.form_footer` | At the bottom of the form | Always |

A sign-in provider puts its button in `auth.login.providers`.

```python
# hooks.py of a provider feature
from flask import render_template
from splent_framework.hooks.template_hooks import register_template_hook


def login_button():
    return render_template("hooks/login_button.html")


register_template_hook("auth.login.providers", login_button, order=20)
```

A hint reads what was typed from the request.

```python
from flask import render_template, request


def password_hint():
    email = request.form.get("email", "")
    if not signs_in_with_a_provider(email):
        return ""
    return render_template("hooks/password_hint.html")


register_template_hook("auth.login.error_hint", password_hint)
```

A hint that depends on the address tells whoever types it whether an account
exists there. A feature that adds one should let the product turn it off.

Logging in and out sends a reader who asked for no page in particular to
`AUTH_LANDING_ENDPOINT` (`public.index`), and to `/` when the product has
no such endpoint, as a storefront that owns `/` without the `public` feature.

## Menus and shells

The feature declares its entries in the framework's menu registry, and an
application shell that draws `menu_sections()` and `user_menu_sections()`
shows them.

| Key | Menu | Section | Order | Opens | Shown to |
|---|---|---|---|---|---|
| `login` | Sidebar | `anonymous` | 10 | `auth.login` | Visitors who are not signed in |
| `auth_users` | Sidebar | `admin` | 20 | `auth.admin_users` | The roles the accounts screen asks for, administrators |
| `auth_my_account` | Sidebar | `account` | 50 | `auth.my_account` | Every signed-in account |
| `logout` | Sidebar | `account` | 900 | `auth.logout` | Every signed-in account |
| `user_login` | User menu | `anonymous` | 10 | `auth.login` | Visitors who are not signed in |
| `user_my_account` | User menu | `account` | 50 | `auth.my_account` | Every signed-in account |
| `user_logout` | User menu | `account` | 900 | `auth.logout` | Every signed-in account |

Both My account entries are current on its pages (confirming the address,
proving who you are, deleting the account, the export) and carry the badge
of an account carried over whose address waits for its confirmation.

A product hides or reorders them by key, without overriding a template.

```toml
[tool.splent.layout.menu]
hide = ["auth_my_account"]
```

The sidebar and navbar hooks (`layout.anonymous_sidebar`,
`layout.authenticated_sidebar`, `layout.navbar.anonymous` and
`layout.navbar.authenticated`) still draw the same entries for one minor,
for the shells that draw those slots. A shell draws the registry or those
slots, never both, or every entry shows twice. The next minor drops the
hooks.

The login page extends `shell('login')`, which is `AUTH_LOGIN_LAYOUT` when
the product names one and the application shell otherwise. My account and
the accounts screen extend `shell('app')`, so they follow `SPLENT_SHELL_APP`.

## Password policy

One rule for every place that sets a password. The accounts screen, My
account and the `create-user` and `set-password` commands apply it, and so
does any feature that sets a password and imports it. It applies when a
password is set, never at login, so an account whose password predates a
longer minimum keeps signing in.

| Setting | Default | Meaning |
|---|---|---|
| `AUTH_PASSWORD_MIN_LENGTH` | 8 | The shortest password accepted, from 8 to 128. A value below 8 becomes 8 and one above 128 becomes 128, with a warning in the log |
| `AUTH_PASSWORD_CONFIRM` | false | Whether a form that sets a password asks for it twice |
| `AUTH_PASSWORD_BREACH_CHECK` | false | Whether a password found in a known data breach is refused (Have I Been Pwned, by k-anonymity) |
| `AUTH_PASSWORD_BREACH_URL` | `https://api.pwnedpasswords.com/range/` | The range API it asks, for a mirror inside the network |
| `AUTH_PASSWORD_BREACH_TIMEOUT` | 3 | Seconds it waits. A service that does not answer lets the password through, with a warning in the log |

The breach check sends only the first five characters of the password's
SHA-1, with `Add-Padding: true`, and compares the suffixes that come back
here. It runs last, once the length and the repeat are right, and only once
per form.

No password longer than 128 characters (`MAX_LENGTH`) can be set, whatever
the minimum. That leaves room for a passphrase or a password manager's
output, and a form that sets a password never hashes a pasted megabyte.

`AUTH_PASSWORD_CONFIRM` adds a second box where a form would ask once. My
account always asks twice. The commands ask twice whenever they prompt.

A product sets them in its own `[tool.splent.config]`.

```toml
[tool.splent.config]
AUTH_PASSWORD_MIN_LENGTH = 12
AUTH_PASSWORD_CONFIRM = true
```

The API lives in `splent_io.splent_feature_auth.passwords`.

| Name | What it does |
|---|---|
| `password_problem(password, confirmation=None)` | The translated reason a new password cannot be used, or `None`. A `confirmation` of `None` is not compared, and an empty one does not match |
| `PasswordPolicy(repeats=None)` | WTForms validator built on `password_problem`. With `repeats`, it checks that this field repeats the one named |
| `confirmation_required()` | Whether the product asks for a new password twice |
| `min_length()` | The shortest password the product accepts, never below `MIN_LENGTH_FLOOR` (8) nor above `MAX_LENGTH` (128) |
| `MAX_LENGTH` | The longest password accepted, 128 characters |

A form that sets a password.

```python
from flask_babel import lazy_gettext as _l
from flask_wtf import FlaskForm
from wtforms import PasswordField
from wtforms.validators import DataRequired

from splent_io.splent_feature_auth.passwords import (
    PasswordPolicy,
    confirmation_required,
)


class SignupForm(FlaskForm):
    password = PasswordField(
        _l("Password"), validators=[DataRequired(), PasswordPolicy()]
    )
    confirm_password = PasswordField(
        _l("Repeat the password"), validators=[PasswordPolicy(repeats="password")]
    )

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        if not confirmation_required():
            del self.confirm_password
```

The template draws the second box only when the form still has it, with
`{% if form.confirm_password %}`. A length error sits under the first box
and a mismatch under the second. Put `Optional()` instead of `DataRequired()`
on a field that may stay blank, such as a new password on an edit form.

Code that is not a form calls the helper directly.

```python
from splent_io.splent_feature_auth.passwords import password_problem

problem = password_problem(password, request.form.get("confirm_password", ""))
if problem:
    flash(problem, "danger")
```

## Account state

Whether an account may sign in is two facts, kept apart:

- `email_verified_at`, when the owner proved the address is theirs (a
  confirmation link, a reset link that reached it, a provider that vouches for
  it, or an administrator), or empty when nobody knows;
- `disabled_at`, when an administrator switched the account off, or empty.

`User.active` is computed from them, in Python and in SQL
(`User.query.filter_by(active=True)` works): switched on, not waiting for a
new password ([below](#a-new-password-before-the-next-sign-in)), and
verified when the product requires it. `AUTH_REQUIRE_VERIFIED_EMAIL` decides; empty, the
default, requires it exactly when a feature that confirms addresses
(`confirmemail`) is installed. Confirming an address never switches an
account on.

An account created directly (`User(...)`, the accounts screen, `splent
feature:auth create-user`) is taken as vouched for. `create_user`, which
public sign-up and provider sign-ins use, creates it unverified unless the
caller passes `email_verified=True`. Assigning `user.active` still works as
it did when it was a column: `True` opens the account (switched on, and
verified if it was not) and `False` switches it off. New code calls
`enable()`, `disable()` and `mark_email_verified()`.

Every lookup by address compares `email_canonical` (NFKC and casefold, under
`utf8mb4_bin`, unique), so `ADA@example.com` and its full-width spelling
reach `ada@example.com`, and `ádá@example.com` reaches nothing. A row
written past the model (an import, an `INSERT`) has to fill it with
`models.canonical_email`. Use `AuthenticationService.find_by_email`, or
`UserRepository.get_by_email`, rather than filtering on `email`.

`email` keeps the address as typed and compares it exactly too, under
`utf8mb4_bin` (`auth0009_email_collation`, DM-15). Its unique key refuses
only the very same text, and `email_canonical`'s refuses another case or
width of an address in use, so `ádá@example.com` may open an account next to
`ada@example.com` and `ADA@example.com` may not, whoever writes the row. The
migration's downgrade puts the column back under the table's collation, and
stops, naming the accounts, when two addresses that collation takes for one
would break its key.

`last_login_at` is stamped at every sign-in and `password_changed_at` at
every new password.

The migration `auth0004_account_state` carries existing accounts over: an
active one is verified (as of its creation) and switched on; an inactive one
waits for its link where the product confirms addresses and is switched off
where nothing could confirm it. `AUTH_MIGRATION_INACTIVE_ACCOUNTS`
(`pending` or `disabled`) overrides that reading. It stops before touching
the table when two accounts would share a canonical address, naming them.
The downgrade puts `active` back from the two columns.

## E-mail confirmation, as a plug-in

Public sign-up opens the account at once, unless the product installs a
feature that confirms the address first. That is a contract auth defines,
and confirmemail implements: a feature that confirms addresses registers a
service named `ConfirmemailService` (`services.EMAIL_CONFIRMATION_SERVICE`)
from its `init_feature` and listens to `user-registered` to send its mail.

```python
from splent_framework.services.service_locator import register_service


def init_feature(app):
    register_service(app, "ConfirmemailService", ConfirmemailService)
```

While it is registered (`services.email_confirmation_installed()`), a new
account starts unverified and, unless `AUTH_REQUIRE_VERIFIED_EMAIL` says
otherwise, cannot sign in until the feature marks it verified as the link
is followed (`confirm_email_with_token`, which never switches an account
on). It may offer `send_confirmation(user, wait=True)`, which the login
calls to mail a new link, and moves an account to a new address with
`send_email_change(user, old_email)` and `confirm_email_change_with_token`.

The login says why an account with the right password stays out, and
nothing more to anyone else:

| The account | The login says |
|---|---|
| Waits for a new password | "Your account needs a new password before you sign in again", with a link mailed where a feature resets passwords, else "Ask an administrator to set one" |
| Waits for its confirmation, with a confirmation feature | "This account is not active yet. Follow the link in the confirmation email we sent you.", and a new link goes out |
| Is closed otherwise | "This account is not active. Ask an administrator to activate it." |
| Any, with a wrong password | "Invalid credentials", so the message says nothing about an address to someone who does not hold the account |

## Where an account came from

`account_origin` says where the account was opened: `local` (sign-up, the
accounts screen, the terminal), a provider's name (`orcid`, `github`, passed
to `create_user` as `provider` or `account_origin`), or `legacy_v2`, an
account a migration carried over from an older site. Accounts that predate
the column have none. `email_verified_via` says what proved the address:
`confirmemail` (a link), `reset` (a reset link reached it), `admin`, a
provider's name, or what a migration found (`legacy_reset`,
`legacy_github`). `has_usable_password` is false for an account whose
password nobody knows, one a provider opened (`create_user(...,
usable_password=False)`, `User.set_unusable_password()`); setting a password
anywhere makes it true. The migration `auth0006_account_origin` adds the
three columns, with no origin and no proof for the accounts that exist, and
a usable password.

## Accounts carried over from an older site

An account with `account_origin = "legacy_v2"` and no `email_verified_at`
(`User.is_legacy_unconfirmed`) signs in whatever `AUTH_REQUIRE_VERIFIED_EMAIL`
says: the older site never confirmed an address, and keeping them out would
shut out everyone whose mail does not arrive. Instead:

- its first sign-in mails the confirmation link, through the confirmation
  feature's `send_confirmation` (auth imports no such feature);
- the first page of every session asks it to confirm, and My account says
  so, with a button for a new link, and a badge on its menu entry;
- what would hand the account over waits for the link:
  `User.may_act_sensitively()` is false until then. The gate is
  `decorators.verified_email_required`, or `confirm_email_first()` inside a
  view. A browser is sent to `/account/confirm-email`, which mails a new
  link on request (the confirmation feature decides how often) and comes
  back afterwards; a JSON caller gets a 403 with
  `{"code": "email_unconfirmed", "confirm_url": ...}`.

```python
from splent_io.splent_feature_auth.decorators import (
    confirm_email_first,
    verified_email_required,
)


@dataset_bp.route("/dataset/<int:dataset_id>/publish", methods=["POST"])
@verified_email_required
def publish(dataset_id):
    ...


# or, where only one branch is sensitive
if publishing and not current_user.may_act_sensitively():
    return confirm_email_first(json=True)
```

auth itself guards a new address on My account. The tokens such an account
already holds keep working: the API checks a token, not its owner's
address. An account opened here behaves as before: `may_act_sensitively()`
is true once its address is confirmed, and true where the product signs in
unverified accounts.

The migration that brings the accounts calls
`AuthenticationService.record_legacy_proof(user, proof, proved_at)` for each
one it can prove from the older site's data (`VERIFIED_BY_LEGACY_RESET`,
`VERIFIED_BY_LEGACY_GITHUB`, with the moment of the proof). It refuses any
other proof and any account that was not carried over, keeps the first
proof, and commits unless passed `commit=False`.

## A recent sign-in

Linking a provider account and setting a first password give the account a
new way in, so they ask for a sign-in made less than `AUTH_REAUTH_SECONDS`
(900) ago. Every login stamps the session (`reauth.SESSION_KEY`); a session
restored from a "Remember me" cookie has no stamp. The gate is
`decorators.recent_login_required`, or `reauthenticate_first()` inside a
view, which send the browser to `/account/reauthenticate` (a JSON caller
gets a 401 with `{"code": "reauthentication_required"}`) and back. That page
asks for the password, on the login's own throttle, and other features add
other ways in its `auth.reauthenticate.methods` slot (identities offers the
providers the account signs in with). `reauth.recently_authenticated()`
answers the question, and `reauth.mark_authenticated()` records a proof.

An account without a usable password sees "Set a password" on My account,
with no current password to type: a recent sign-in stands for it there, for
a new password and for a new address.

## Pages that hold a token

`tokens.keep_token_private(response)` is for a page whose address carries a
token (a reset or confirmation link) or whose body shows one (a new API
token). It sends `Referrer-Policy: no-referrer`, `Cache-Control: no-store`
and a `Content-Security-Policy` of `script-src 'self' 'unsafe-inline'`, so
no script of another site runs there, beside the product's own policy when
it sets one. A feature calls it from an `after_request` of its blueprint.

## A new address

My account asks for the current password to set a new address, as it does
for a new password (a recent sign-in, for an account without a password of
its own). Where the product confirms addresses the new one waits
in `pending_email`: auth calls the confirmation feature's
`send_email_change(user, old_email)`, which mails a link to the new address
and a notice to the old one, and the account keeps signing in with its
address until `confirm_email_change_with_token` applies it (confirmed, every
session ended, the links mailed to the old address spent). Where nothing
confirms addresses the new one applies at once, unverified, and ends the
other sessions. An address that only differs in case or width is the same
address and is just stored as typed.

## Sessions

Every session and "Remember me" cookie carries the account's
`session_version` (`User.get_id()` is `"<id>:<version>"`), and the user
loader refuses one whose version is not the account's. A new password (a
reset link, My account, the accounts screen, `set-password`), switching the
account off and "Sign out everywhere else" on My account raise it. Whoever
changed the password on My account stays signed in in that browser. A bare
id, as auth 1.21 kept and as tests that write `_user_id` keep, is honoured
only while the version is still 1; auth 2.0 drops it.

The "Remember me" cookie lasts `AUTH_REMEMBER_DAYS` (30) and is only set
when the box is ticked; 0 takes the box away. Every login gets a new session
id.

`/logout` signs out by POST, with its CSRF token, and empties the session
but for the reader's language. While `AUTH_LOGOUT_GET` is on (the default
for this minor) a GET still signs out, for the links that are not forms
yet. Off, a GET shows a page whose button signs out by POST.

## The login throttle

Every place that takes a password is a place to guess one, so the login
form and `/account/reauthenticate` count failed logins and refuse a caller
who has made too many: the form comes back with status `429`, a
`Retry-After` header and "Too many failed attempts". The failures are
counted in the product's database, table `auth_throttle_failure`, so every
worker of every container shares one count, with no Redis and no
configuration (`throttle.py`). Three budgets are kept over one window:

| Setting | Default | Counts |
|---|---|---|
| `AUTH_LOGIN_MAX_ATTEMPTS` | 10 | Failed logins from one address against one account, checked before the password is |
| `AUTH_LOGIN_MAX_ACCOUNT_ATTEMPTS` | 50 | Failed logins against one account from every address together, for a guesser who rotates addresses |
| `AUTH_LOGIN_MAX_ADDRESS_ATTEMPTS` | 200 | Failed logins from one address against every account together, for one address spraying a password across many accounts. Not cleared by a success |
| `AUTH_LOGIN_WINDOW_SECONDS` | 900 | The window all three are counted over, in seconds |

- **Never a lockout.** The account budget is checked only after a wrong
  password, so whoever types the right one gets in however much noise a
  stranger has made, and a stranger cannot lock the owner out.
- **One account, however it is spelled.** A bucket counts against the
  account the typed address reaches (its canonical address), so every
  spelling of it shares one budget.
- **Nothing the caller sent is stored.** A bucket is an HMAC-SHA256 of what
  it counts, keyed by the product's `SECRET_KEY`. Each row carries the
  moment it leaves its window, and every write removes a batch of expired
  rows, so no cron is needed.
- **`0` turns a budget off.** The address budget refuses everyone behind
  that address once spent, so a product where a whole campus shares one
  public address may want a higher `AUTH_LOGIN_MAX_ADDRESS_ATTEMPTS`. The
  four are of `deploy` bind: change one in the product's
  `[tool.splent.config]`, then build and deploy, so every host gets it.

The address is `request.remote_addr`, never `X-Forwarded-For`, which any
caller can write. Behind a reverse proxy that is the proxy itself unless
the product sets `SPLENT_TRUSTED_PROXIES` (the number of proxies in front,
`1` behind the nginx feature), which makes the framework install
Werkzeug's `ProxyFix`. Without it every visitor shares the proxy's address,
and the address budget counts them together.

The table comes with the migration `auth0003_throttle` (auth 1.19.0), which
the production container applies when it starts; in development run
`splent db:upgrade`. Until the table exists, or while the database does not
answer, each worker counts on its own, as auth did before, and the log says
so once (the warning ends `Run splent db:upgrade.` when the table is
missing). It is never a 500.

apikeys throttles its token endpoint, `POST /api/v1/auth/tokens`, on the
same machinery and the same table, with budgets of its own.

## My account

`/account` is every signed-in person's: the address (a new one needs the
current password, and waits for its link where addresses are confirmed),
the password, "Sign out everywhere else", and two rights.

- **Your data** (`/account/export`) hands its owner one JSON file: what
  auth keeps of the account (never its password, tokens or session
  version) under `account`, and under its own key the part of every feature
  that registered an exporter (`account_data.py`). profile adds `profile`.
  A part that fails is logged and named in `incomplete`; the rest is
  exported all the same. A feature adds its part from `init_feature`:

  ```python
  from splent_io.splent_feature_auth.account_data import register_account_exporter

  register_account_exporter("reports", lambda user: [r.to_dict() for r in reports_of(user)])
  ```

- **Delete your account** (`/account/delete`) asks for the address typed
  again (the word DELETE for an account without one), and goes through the
  `user-deleting` signal like every deletion, so each feature removes what
  is its own or keeps it without the account. The only administrator left
  switched on cannot delete itself (`LastAdministrator`), and rows the
  database still will not let go of (a published dataset, whose DOI is
  permanent) are said, with nothing deleted. `AUTH_SELF_DELETION = false`
  takes the page away, for accounts an institution hands out and takes
  back.

Both ask for a recent sign-in (`AUTH_REAUTH_SECONDS`), so a session left
open on a shared machine neither deletes the account nor hands its data
over. When the product has legal, both stay open while a legal text waits
to be accepted, as the rights they are.

## Refusals

What the service refuses is an exception of `errors.py`: `EmailTaken`,
`WrongPassword`, `RecentSignInRequired`, `EmailConfirmationRequired`,
`LastAdministrator`, `AccountInUseError`. Each is a `ValueError` with a
`code` and a message marked for the catalog, so `str(exc)` reads in the
request's language. Two requests that take the same address at once meet
the unique key, which is told apart by the name the database gives it
(`errors.violated_key`), never by the driver's wording, and becomes
`EmailTaken`. A sign-up that races another for one address still gets the
`IntegrityError` it always did, which signup reads.

`AccountInUseError` is what is left when rows still point at an account
after every receiver of `user-deleting` ran, and the database refuses the
deletion: its `table` names the table in the way when the database says.
The accounts screen shows it as a message ("... was not deleted because
records in <table> still belong to it. Deactivate the account instead, or
remove those records first."), and the admin panel's generic delete gives
the same advice. Switching an account off keeps its records readable, which
is what most cases want. An owner deleting their own account is told,
without the table, that part of what they published has to stay.

## Signals

Other features take part in an account's life through five signals, all
with `user`, without importing auth's modules. Every one but
`user-registered` is sent from the database session, just before it
flushes, with the `session` too, so it covers every way the change is made:
a reset link, My account, the accounts screen, the admin panel's generic
CRUD, the terminal. A bulk `query.update()` or `query.delete()` bypasses the
session and is not announced.

| Signal | Sent when |
|---|---|
| `user-registered` | Public sign-up or a provider has created an account, with what the form posted (signals.py) |
| `user-deleting` | An account is about to be deleted |
| `user-password-changed` | An account that had a password of its own gets a new hash. Not a first password on an account whose password nobody knew (a provider opened it), which is no sign an old one leaked, and not the same hash written back. Taking the password away (`set_unusable_password`) is |
| `user-disabled` | `disabled_at` goes from nothing to a time. Not for an account created switched off |
| `user-enabled` | `disabled_at` goes from a time back to nothing |

A receiver writes through `session` and never commits, so its rows go in the
same transaction as the change and a rollback undoes both. Something outside
the database (a mail) waits for the commit, for instance as a task
(`splent_framework.tasks.enqueue`), which waits for it and is dropped on a
rollback.

```python
from splent_framework.signals.signal_utils import connect_signal


@connect_signal("user-disabled", "splent_feature_apikeys")
def revoke_tokens(sender, user, session, **kwargs):
    if user.is_service_account:
        return
    for token in session.query(ApiToken).filter_by(user_id=user.id):
        token.revoked_at = utcnow()
```

## Service accounts

`User.is_service_account` marks an account a program signs in with (a
marketplace's client, a CI job) rather than a person. auth treats it like
any other account: it signs in, a new password ends its sessions, and the
signals announce what happens to it. Other features may treat it specially
and say so in their own documentation. A feature that would cut a program
off when its password is reset or it is switched off (revoking its API
tokens) should spare it, since the reset or the switch off was meant for
whoever runs the program.

Only an administrator and the terminal set it: the switch on the accounts
screen (new and edit), which lists such accounts with a badge, the admin
panel's generic CRUD, and

```bash
splent feature:auth set-service-account marketplace@example.com
splent feature:auth set-service-account marketplace@example.com --off
```

Sign-up, the providers and My account never set it. The migration
`auth0007_service_account` adds the column, off for every account that
exists.

## A new password before the next sign-in

`User.must_reset_password` keeps an account out until its owner chooses a
new password with a reset link. For an account whose password someone else
may know: an administrator's say-so, or a migration that carries over a
password it cannot trust.

- Signing in with the right password opens nothing (`data-reason` is
  `reset_required` on the login page). It asks the feature that resets
  passwords for a link to the account's address, through the service it
  registers as `ResetService` (`services.PASSWORD_RESET_SERVICE`, its
  `send_reset_password_mail(email)`; auth imports no such feature), at most
  once every five minutes (`RESET_LINK_RESEND_SECONDS`), and says so. A
  wrong password is told it is wrong, as for any account, and mails nothing.
  Without a feature that resets passwords, the page says to ask an
  administrator.
- The account is not active (`User.active`, in Python and in SQL), so its
  sessions end, a provider does not sign it in, and its API tokens are
  refused by apikeys until then. Asking for it ends the sessions too, so
  taking it back without a new password does not bring them back.
- A new password clears it: the one chosen through the link, My account,
  the accounts screen, `splent feature:auth set-password`. The password an
  account is created with does not.

Only an administrator, the terminal and a migration set it: the switch on
the accounts screen (new and edit; an administrator cannot ask it of their
own account), which lists such accounts as `Must choose a new password`,
the admin panel's generic CRUD, and

```bash
splent feature:auth require-password-reset someone@example.com
splent feature:auth require-password-reset someone@example.com --off
```

A migration writes the column with a plain `UPDATE`
(`must_reset_password = 1`), no model needed. A password set on the accounts
screen with the switch still on is one to replace at the next sign-in.
Sign-up, the providers and My account never set it. The migration
`auth0008_must_reset_password` adds the column, off for every account that
exists; its downgrade drops it.

## One-time links

`tokens.OneTimeTokenService` (also registered as `OneTimeTokenService`)
issues the links other features mail: reset, confirmemail. A token is 32
random bytes; the table `auth_token` keeps its SHA-256, the account, its
purpose (`password-reset`, `email-confirm`, `email-change`), the address it
was mailed to, its expiry and when it was used.

- `issue(user, purpose, max_age, target=None)` returns the token for the
  link.
- `lookup(token, purpose)` returns the stored row, used or expired or not,
  so a page can say which.
- `redeem(token, purpose)` spends it with one `UPDATE ... WHERE used_at IS
  NULL AND expires_at > now`, and spends the account's other tokens for the
  same purpose. It does not commit: the caller makes the change the link
  stands for in the same transaction, so two requests with the same link
  change one thing once.
- Every `issue` removes a batch of expired tokens, and `splent
  feature:auth purge-tokens` removes them all.

`AuthenticationService.reset_password_with_token(token, password)` is the
whole of a reset in one transaction. A link mailed to an address the account
no longer has opens nothing.

## The first account

`splent feature:auth create-user EMAIL --role admin` opens an account that
signs in at once, the way to put the first one in an empty production
database. It asks for the password without echo; a script sets
`AUTH_NEW_USER_PASSWORD` in the environment instead, so the password stays
out of the shell history and the process list.

## The migrations that ask

Two of auth's migrations meet accounts that predate a column and ask the
operator how to read them, through a setting brought into the environment
the migration runs in (see [Settings](#settings)). Both read it once:
changing it afterwards changes nothing.

`auth0002_role` adds the `role` column to `user`. It runs on a database that
holds accounts when a product upgrades auth from a release older than
1.7.0. New accounts get `user`; the existing ones get
`AUTH_MIGRATION_LEGACY_ROLE`, since only the operator knows the right one:

| Existing accounts | `AUTH_MIGRATION_LEGACY_ROLE` empty | Set to `admin`, `staff` or `user` |
|---|---|---|
| None or one | One gets `admin`, the old one-administrator setup | That role |
| Several | The migration stops before touching the table, and says what to set | That role, for all of them |

It stops before any DDL, since MariaDB cannot roll back an added column, so
the table is never left half migrated. Choose `user` where strangers can
create accounts (a product with signup), so none of them becomes an
administrator, and promote the staff once the column exists:

```bash
splent feature:auth set-role admin@example.com admin
```

Choose `admin` to keep every existing account an administrator. The
migration prints how many accounts it backfilled
(`auth0002_role: 3 existing user(s) backfilled to role 'user'.`), and
`splent feature:auth list-users` shows them.

`auth0004_account_state` reads `AUTH_MIGRATION_INACTIVE_ACCOUNTS` (see
[Account state](#account-state)).

## Settings

Every setting is a typed parameter in `[tool.splent.config_schema]`, so
`product:validate` checks a product's value and the framework refuses a
wrong one at boot. `derivation` ones are the product's decision, `deploy`
ones each deployment's, and `runtime` ones an administrator may change in
the panel.

| Setting | Type | Bind | Default | |
|---|---|---|---|---|
| `AUTH_NAV_LOGIN` | bool | runtime | `true` | "Log in" at the far right of the public header. Off suits a site where staff reach the panel by typing the URL |
| `AUTH_FOOTER_LOGIN` | bool | runtime | `false` | A quiet login link in the footer, for sites that keep it out of the nav |
| `AUTH_LOGIN_LAYOUT` | template | derivation | empty | The shell the sign-in page renders in. Empty means the application shell; a product with a public shell names it, so its front door does not look like another site |
| `AUTH_LANDING_ENDPOINT` | endpoint | derivation | `public.index` | Where logging in and out sends a reader who asked for no page (see [The login page](#the-login-page)) |
| `AUTH_LOGIN_MAX_ATTEMPTS`, `AUTH_LOGIN_MAX_ACCOUNT_ATTEMPTS`, `AUTH_LOGIN_MAX_ADDRESS_ATTEMPTS` | int, at least `0` | deploy | `10`, `50`, `200` | [The login throttle](#the-login-throttle) |
| `AUTH_LOGIN_WINDOW_SECONDS` | int, at least `1` | deploy | `900` | [The login throttle](#the-login-throttle) |
| `AUTH_PASSWORD_MIN_LENGTH` | int, `8` to `128` | derivation | `8` | [Password policy](#password-policy) |
| `AUTH_PASSWORD_CONFIRM`, `AUTH_PASSWORD_BREACH_CHECK` | bool | derivation | `false` | [Password policy](#password-policy) |
| `AUTH_PASSWORD_BREACH_URL` | url, `https?://.+` | deploy | Have I Been Pwned's | [Password policy](#password-policy) |
| `AUTH_PASSWORD_BREACH_TIMEOUT` | float, at least `0.1` | deploy | `3` | [Password policy](#password-policy) |
| `AUTH_REAUTH_SECONDS` | int, at least `1` | derivation | `900` | [A recent sign-in](#a-recent-sign-in) |
| `AUTH_REMEMBER_DAYS` | int, at least `0` | derivation | `30` | [Sessions](#sessions) |
| `AUTH_LOGOUT_GET` | bool | derivation | `true` | [Sessions](#sessions). auth 2.0 turns it off |
| `AUTH_SELF_DELETION` | bool | derivation | `true` | [My account](#my-account) |
| `AUTH_REQUIRE_VERIFIED_EMAIL` | bool | derivation | unset | [Account state](#account-state). Declared in the schema alone, since a bool has no word for "unset" |
| `AUTH_MIGRATION_LEGACY_ROLE` | str, `admin`, `staff` or `user` | deploy | empty | Read once, by the migration that introduced roles (`auth0002_role`), as the role every existing account gets. Empty lets it decide only when there is nothing to decide (no account, or one, which stays admin); with several it fails and says what to set. Changing it afterwards changes nothing |
| `AUTH_MIGRATION_INACTIVE_ACCOUNTS` | str, `pending` or `disabled` | deploy | empty | Read once, by `auth0004_account_state` (see [Account state](#account-state)) |
| `AUTH_NEW_USER_PASSWORD` | secret | | unset | [The first account](#the-first-account). Declared in the schema alone, so it lands in no product's `.env` |

A migration reads its setting from the environment it runs in: in
development `splent product:env --merge --dev` and then `splent db:upgrade`;
in production `splent product:build` writes it to the deploy bundle's
example, `splent product:deploy` brings it in, and the container applies the
migration when it starts.

## Commands

| Command | What it does |
|---|---|
| `splent feature:auth create-user EMAIL [--role ROLE] [--password ...]` | Create an account that signs in right away (see [The first account](#the-first-account)) |
| `splent feature:auth list-users` | List the accounts of the active product |
| `splent feature:auth set-password EMAIL` | Change any account's password. The policy applies, and the account's sessions end |
| `splent feature:auth set-role EMAIL ROLE` | Change any account's role: `admin`, `staff` or `user` |
| `splent feature:auth set-service-account EMAIL [--off]` | See [Service accounts](#service-accounts) |
| `splent feature:auth require-password-reset EMAIL [--off]` | See [A new password before the next sign-in](#a-new-password-before-the-next-sign-in) |
| `splent feature:auth purge-tokens` | Remove the expired one-time links (reset, confirmation) |

While a product is deployed they run against the deployment from the CLI,
or in its web container.

## Use it in a product

```bash
splent feature:add splent_feature_auth
```

It requires no other feature; with legal, My account's rights stay open
while a legal text waits to be accepted. In the OpenHubs line it is the
mandatory feature of `identidad`, and every feature with a screen for
administrators or a signed-in account requires it.

## Develop

```bash
splent feature:test splent_feature_auth
splent feature:contract splent_feature_auth
```

## Release

```bash
splent feature:release splent_feature_auth --dry-run --bump patch
splent feature:release splent_feature_auth
```

The first command rehearses the release and changes nothing. The second
publishes it to the git forge and to PyPI, and a PyPI version is permanent.
