Skip to main content

splent_feature_auth

The auth feature of a SPLENT 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.

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

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

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

[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].

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

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.

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

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": ...}.
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.

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.

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

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

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.

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.

Use it in a product

splent feature:add splent_feature_auth

Develop

splent feature:test splent_feature_auth
splent feature:contract splent_feature_auth

Release

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.

Metadata

Release files for splent-feature-auth 1.26.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for splent-feature-auth 1.26.0
File Size Uploaded Provenance
splent_feature_auth-1.26.0.tar.gz 117.9 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for splent-feature-auth 1.26.0
File Interpreter ABI Platform
splent_feature_auth-1.26.0-py3-none-any.whl Python 3 none any Details

Total release size: 253.0 kB

Release files / splent_feature_auth-1.26.0.tar.gz

Download URL splent_feature_auth-1.26.0.tar.gz
Size 117.9 kB
Tags Source
SHA-256 checksum
How to use checksums
70d7c932f26a699a2e5e903e4e60408f01a63302d0bae4a9b3ad5bf210067fe7
BLAKE2b-256 checksum
How to use checksums
e46cf2981473d117f4a5e1c5334882280cdf45ed3e2fa38feae2e0647b91e037
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.1.0 CPython/3.13.16

Release files / splent_feature_auth-1.26.0-py3-none-any.whl

Download URL splent_feature_auth-1.26.0-py3-none-any.whl
Size 135.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
520c6a22a28a2aa7803dc3cee97a5b1a45a5452c114da3c40d208253ccce3c13
BLAKE2b-256 checksum
How to use checksums
ce7269159442ad78a8ea975698427bfaa1de2fef750b2f2a00d9409d45765207
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.1.0 CPython/3.13.16

Security for splent-feature-auth 1.26.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

1.26.0 This release

2 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page