DRY pass #2, batch 4, F11: one email normaliser, one 401, one password-length refusal (#5372)
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 12s
CI & Build / Python tests (push) Successful in 14s
Android / Core and FFI clippy and tests (push) Successful in 57s
CI & Build / integration (push) Successful in 1m38s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m13s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m4s
Android / Kotlin + Rust (APK) (push) Failing after 4m49s
Android / Build the server image (push) Successful in 1s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m50s
Desktop (Tauri) / Update manifest (push) Successful in 3s

common.normalize_email is the trim-and-lowercase that sign-up, sign-in,
reset and invite each wrote inline. auth._unauthenticated is the 401 the
six signed-out paths returned, and auth._password_refusal is the
minimum-length check that register, reset and change repeated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 14:34:47 -04:00
co-authored by Claude Opus 5.5
parent a34d469e1e
commit 16ab4a13f5
3 changed files with 39 additions and 18 deletions
+30 -17
View File
@@ -10,7 +10,7 @@ from quart import Blueprint, current_app, g, jsonify, request, session
from sqlalchemy import delete, func, select from sqlalchemy import delete, func, select
from . import audit from . import audit
from .common import iso from .common import iso, normalize_email
from .db import session_scope from .db import session_scope
from .invites import INVALID as INVALID_INVITE, record_redeemer, redeem from .invites import INVALID as INVALID_INVITE, record_redeemer, redeem
from .mailer import send_later from .mailer import send_later
@@ -78,6 +78,19 @@ def _sign_in(user: User) -> None:
session.permanent = True session.permanent = True
def _unauthenticated():
"""401 for a caller who isn't signed in, or no longer is."""
return json_error("authentication required", 401)
def _password_refusal(password: str):
"""The 400 for a password too short to keep, or None. Sign-up, reset and change
all ask this one question."""
if len(password) < MIN_PASSWORD_LEN:
return json_error(f"password must be at least {MIN_PASSWORD_LEN} characters", 400)
return None
def _sign_out() -> None: def _sign_out() -> None:
session.pop(SESSION_KEY, None) session.pop(SESSION_KEY, None)
session.pop(EPOCH_KEY, None) session.pop(EPOCH_KEY, None)
@@ -146,7 +159,7 @@ def login_required(fn):
if uid is None: if uid is None:
uid = await _user_id_from_bearer() uid = await _user_id_from_bearer()
if uid is None: if uid is None:
return json_error("authentication required", 401) return _unauthenticated()
g.user_id = uid g.user_id = uid
return await fn(*args, **kwargs) return await fn(*args, **kwargs)
@@ -161,11 +174,11 @@ def require_admin(fn):
async def wrapper(*args, **kwargs): async def wrapper(*args, **kwargs):
uid = _session_user_id() uid = _session_user_id()
if uid is None: if uid is None:
return json_error("authentication required", 401) return _unauthenticated()
async with session_scope() as db: async with session_scope() as db:
user = await db.get(User, uid) user = await db.get(User, uid)
if not _session_is_current(user.session_epoch if user else None): if not _session_is_current(user.session_epoch if user else None):
return json_error("authentication required", 401) return _unauthenticated()
if not user.is_admin: if not user.is_admin:
return json_error("admin access required", 403) return json_error("admin access required", 403)
g.user_id = uid g.user_id = uid
@@ -220,15 +233,15 @@ def _sign_in_succeeded(email: str) -> None:
@bp.post("/register") @bp.post("/register")
async def register(): async def register():
data = await request.get_json(silent=True) or {} data = await request.get_json(silent=True) or {}
email = (data.get("email") or "").strip().lower() email = normalize_email(data.get("email"))
password = data.get("password") or "" password = data.get("password") or ""
display_name = (data.get("display_name") or "").strip() display_name = (data.get("display_name") or "").strip()
invite = (data.get("invite") or "").strip() invite = (data.get("invite") or "").strip()
if not email or "@" not in email: if not email or "@" not in email:
return json_error("a valid email is required", 400) return json_error("a valid email is required", 400)
if len(password) < MIN_PASSWORD_LEN: if (refusal := _password_refusal(password)) is not None:
return json_error(f"password must be at least {MIN_PASSWORD_LEN} characters", 400) return refusal
if not display_name: if not display_name:
display_name = email.split("@", 1)[0] display_name = email.split("@", 1)[0]
@@ -335,7 +348,7 @@ def _bad_credentials():
@bp.post("/login") @bp.post("/login")
async def login(): async def login():
data = await request.get_json(silent=True) or {} data = await request.get_json(silent=True) or {}
email = (data.get("email") or "").strip().lower() email = normalize_email(data.get("email"))
password = data.get("password") or "" password = data.get("password") or ""
wait = _sign_in_block(email) wait = _sign_in_block(email)
@@ -365,7 +378,7 @@ async def me():
user = await db.get(User, g.user_id) user = await db.get(User, g.user_id)
if user is None: if user is None:
_sign_out() _sign_out()
return json_error("authentication required", 401) return _unauthenticated()
return jsonify(_serialize_user(user)) return jsonify(_serialize_user(user))
@@ -397,7 +410,7 @@ async def forgot_password():
the answer is unchanged and nothing is sent, so the cap is no oracle either. the answer is unchanged and nothing is sent, so the cap is no oracle either.
""" """
data = await request.get_json(silent=True) or {} data = await request.get_json(silent=True) or {}
email = (data.get("email") or "").strip().lower() email = normalize_email(data.get("email"))
if not email or "@" not in email: if not email or "@" not in email:
return json_error("a valid email is required", 400) return json_error("a valid email is required", 400)
@@ -432,8 +445,8 @@ async def reset_password():
data = await request.get_json(silent=True) or {} data = await request.get_json(silent=True) or {}
token = (data.get("token") or "").strip() token = (data.get("token") or "").strip()
password = data.get("password") or "" password = data.get("password") or ""
if len(password) < MIN_PASSWORD_LEN: if (refusal := _password_refusal(password)) is not None:
return json_error(f"password must be at least {MIN_PASSWORD_LEN} characters", 400) return refusal
# The sign-in budget for this address. A token can't be guessed, but each attempt # The sign-in budget for this address. A token can't be guessed, but each attempt
# is still a credential attempt, and the log should show a run of them. # is still a credential attempt, and the log should show a run of them.
@@ -502,13 +515,13 @@ async def change_password():
data = await request.get_json(silent=True) or {} data = await request.get_json(silent=True) or {}
current = data.get("current_password") or "" current = data.get("current_password") or ""
password = data.get("new_password") or "" password = data.get("new_password") or ""
if len(password) < MIN_PASSWORD_LEN: if (refusal := _password_refusal(password)) is not None:
return json_error(f"password must be at least {MIN_PASSWORD_LEN} characters", 400) return refusal
async with session_scope() as db: async with session_scope() as db:
user = await db.get(User, g.user_id) user = await db.get(User, g.user_id)
if user is None: if user is None:
return json_error("authentication required", 401) return _unauthenticated()
wait = _sign_in_block(user.email) wait = _sign_in_block(user.email)
if wait is not None: if wait is not None:
return _throttled(wait) return _throttled(wait)
@@ -536,7 +549,7 @@ async def sign_out_elsewhere():
async with session_scope() as db: async with session_scope() as db:
user = await db.get(User, g.user_id) user = await db.get(User, g.user_id)
if user is None: if user is None:
return json_error("authentication required", 401) return _unauthenticated()
unlinked = await _sign_out_elsewhere(db, user, keep_token=_bearer_token()) unlinked = await _sign_out_elsewhere(db, user, keep_token=_bearer_token())
_stay_signed_in(user) _stay_signed_in(user)
logger.info("signed out elsewhere email=%s devices_unlinked=%s from=%s", user.email, unlinked, client_address()) logger.info("signed out elsewhere email=%s devices_unlinked=%s from=%s", user.email, unlinked, client_address())
@@ -582,7 +595,7 @@ async def device_login():
"""Native first-link: exchange email+password for a device bearer token. Public """Native first-link: exchange email+password for a device bearer token. Public
(no existing session) — this is how a fresh native install authenticates.""" (no existing session) — this is how a fresh native install authenticates."""
data = await request.get_json(silent=True) or {} data = await request.get_json(silent=True) or {}
email = (data.get("email") or "").strip().lower() email = normalize_email(data.get("email"))
password = data.get("password") or "" password = data.get("password") or ""
if not email or not password: if not email or not password:
return json_error("email and password are required", 400) return json_error("email and password are required", 400)
+7
View File
@@ -40,3 +40,10 @@ def coerce_bool(raw: object) -> bool:
if isinstance(raw, str): if isinstance(raw, str):
return raw.strip().lower() in ("true", "1", "yes", "on") return raw.strip().lower() in ("true", "1", "yes", "on")
return bool(raw) return bool(raw)
def normalize_email(raw: str | None) -> str:
"""An address as accounts hold it: trimmed and lowercased. Every route that reads
one from a request goes through here, so "Ana@Example.com " and "ana@example.com"
are one account at sign-up, sign-in, reset and invite alike."""
return (raw or "").strip().lower()
+2 -1
View File
@@ -13,6 +13,7 @@ from sqlalchemy.orm import aliased
from . import audit from . import audit
from .auth import require_admin from .auth import require_admin
from .common import normalize_email
from .db import session_scope from .db import session_scope
from .invites import MAX_DAYS, lifetime_days, serialize from .invites import MAX_DAYS, lifetime_days, serialize
from .models.invite import Invite from .models.invite import Invite
@@ -31,7 +32,7 @@ logger = logging.getLogger(__name__)
@require_admin @require_admin
async def create_invite(): async def create_invite():
data = await request.get_json(silent=True) or {} data = await request.get_json(silent=True) or {}
email = (data.get("email") or "").strip().lower() or None email = normalize_email(data.get("email")) or None
if email is not None and "@" not in email: if email is not None and "@" not in email:
return json_error("that doesn't look like an email address", 400) return json_error("that doesn't look like an email address", 400)
days = lifetime_days(data.get("days")) days = lifetime_days(data.get("days"))