Metadata-Version: 2.4
Name: splent_feature_auth
Version: 1.22.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.20.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.

## 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_logout` | User menu | `account` | 900 | `auth.logout` | Every signed-in account |

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 |

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, 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`.

`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.

## A new address

My account asks for the current password to set a new address, as it does
for a new password. 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.

## 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.

## Use it in a product

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

## 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.
