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
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:
+30
-17
@@ -10,7 +10,7 @@ from quart import Blueprint, current_app, g, jsonify, request, session
|
||||
from sqlalchemy import delete, func, select
|
||||
|
||||
from . import audit
|
||||
from .common import iso
|
||||
from .common import iso, normalize_email
|
||||
from .db import session_scope
|
||||
from .invites import INVALID as INVALID_INVITE, record_redeemer, redeem
|
||||
from .mailer import send_later
|
||||
@@ -78,6 +78,19 @@ def _sign_in(user: User) -> None:
|
||||
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:
|
||||
session.pop(SESSION_KEY, None)
|
||||
session.pop(EPOCH_KEY, None)
|
||||
@@ -146,7 +159,7 @@ def login_required(fn):
|
||||
if uid is None:
|
||||
uid = await _user_id_from_bearer()
|
||||
if uid is None:
|
||||
return json_error("authentication required", 401)
|
||||
return _unauthenticated()
|
||||
g.user_id = uid
|
||||
return await fn(*args, **kwargs)
|
||||
|
||||
@@ -161,11 +174,11 @@ def require_admin(fn):
|
||||
async def wrapper(*args, **kwargs):
|
||||
uid = _session_user_id()
|
||||
if uid is None:
|
||||
return json_error("authentication required", 401)
|
||||
return _unauthenticated()
|
||||
async with session_scope() as db:
|
||||
user = await db.get(User, uid)
|
||||
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:
|
||||
return json_error("admin access required", 403)
|
||||
g.user_id = uid
|
||||
@@ -220,15 +233,15 @@ def _sign_in_succeeded(email: str) -> None:
|
||||
@bp.post("/register")
|
||||
async def register():
|
||||
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 ""
|
||||
display_name = (data.get("display_name") or "").strip()
|
||||
invite = (data.get("invite") or "").strip()
|
||||
|
||||
if not email or "@" not in email:
|
||||
return json_error("a valid email is required", 400)
|
||||
if len(password) < MIN_PASSWORD_LEN:
|
||||
return json_error(f"password must be at least {MIN_PASSWORD_LEN} characters", 400)
|
||||
if (refusal := _password_refusal(password)) is not None:
|
||||
return refusal
|
||||
if not display_name:
|
||||
display_name = email.split("@", 1)[0]
|
||||
|
||||
@@ -335,7 +348,7 @@ def _bad_credentials():
|
||||
@bp.post("/login")
|
||||
async def login():
|
||||
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 ""
|
||||
|
||||
wait = _sign_in_block(email)
|
||||
@@ -365,7 +378,7 @@ async def me():
|
||||
user = await db.get(User, g.user_id)
|
||||
if user is None:
|
||||
_sign_out()
|
||||
return json_error("authentication required", 401)
|
||||
return _unauthenticated()
|
||||
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.
|
||||
"""
|
||||
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:
|
||||
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 {}
|
||||
token = (data.get("token") or "").strip()
|
||||
password = data.get("password") or ""
|
||||
if len(password) < MIN_PASSWORD_LEN:
|
||||
return json_error(f"password must be at least {MIN_PASSWORD_LEN} characters", 400)
|
||||
if (refusal := _password_refusal(password)) is not None:
|
||||
return refusal
|
||||
|
||||
# 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.
|
||||
@@ -502,13 +515,13 @@ async def change_password():
|
||||
data = await request.get_json(silent=True) or {}
|
||||
current = data.get("current_password") or ""
|
||||
password = data.get("new_password") or ""
|
||||
if len(password) < MIN_PASSWORD_LEN:
|
||||
return json_error(f"password must be at least {MIN_PASSWORD_LEN} characters", 400)
|
||||
if (refusal := _password_refusal(password)) is not None:
|
||||
return refusal
|
||||
|
||||
async with session_scope() as db:
|
||||
user = await db.get(User, g.user_id)
|
||||
if user is None:
|
||||
return json_error("authentication required", 401)
|
||||
return _unauthenticated()
|
||||
wait = _sign_in_block(user.email)
|
||||
if wait is not None:
|
||||
return _throttled(wait)
|
||||
@@ -536,7 +549,7 @@ async def sign_out_elsewhere():
|
||||
async with session_scope() as db:
|
||||
user = await db.get(User, g.user_id)
|
||||
if user is None:
|
||||
return json_error("authentication required", 401)
|
||||
return _unauthenticated()
|
||||
unlinked = await _sign_out_elsewhere(db, user, keep_token=_bearer_token())
|
||||
_stay_signed_in(user)
|
||||
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
|
||||
(no existing session) — this is how a fresh native install authenticates."""
|
||||
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 ""
|
||||
if not email or not password:
|
||||
return json_error("email and password are required", 400)
|
||||
|
||||
@@ -40,3 +40,10 @@ def coerce_bool(raw: object) -> bool:
|
||||
if isinstance(raw, str):
|
||||
return raw.strip().lower() in ("true", "1", "yes", "on")
|
||||
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()
|
||||
|
||||
@@ -13,6 +13,7 @@ from sqlalchemy.orm import aliased
|
||||
|
||||
from . import audit
|
||||
from .auth import require_admin
|
||||
from .common import normalize_email
|
||||
from .db import session_scope
|
||||
from .invites import MAX_DAYS, lifetime_days, serialize
|
||||
from .models.invite import Invite
|
||||
@@ -31,7 +32,7 @@ logger = logging.getLogger(__name__)
|
||||
@require_admin
|
||||
async def create_invite():
|
||||
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:
|
||||
return json_error("that doesn't look like an email address", 400)
|
||||
days = lifetime_days(data.get("days"))
|
||||
|
||||
Reference in New Issue
Block a user