Metadata-Version: 2.4
Name: splent_feature_auth
Version: 1.20.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.15.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.

## 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")
```

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