- Demo accounts: super_admin-only provisioning into isolated DEMO_ORG_ID tenant, 30-day UTC trial on first login, revocable, one-time credential delivery via optional SES/webhook (never persisted). Adds boto3 dependency. - Analytics/report/export/privacy: shared bounded scan budget across users/groups/ sessions, tenant-consistent session/user/group joins, scalar-only CSV export (no nested persisted-value stringification). - Ownership/tenant isolation: canonical owner-tenant predicate for list/read/chat; client sees is_owned only, never owner_user_id. - Lifecycle/races: status transition validation, analyzing is an in-progress gate (no duplicate reanalysis), structured-ready publication, stale-variant revalidation. - Auth/setup/consent/JWT/OAuth/config: fail-closed consent, bounded JWT lifetime, provider-subject atomic OAuth identity, repeated-secret rejection, strict Persona trait validation. - Chat/session/privacy: pre-seller opener redaction, corrupt-session recovery, role-aware completed-chat dashboard routing. - Frontend: Training→product→personas→practice flow, demo/role/demo guards, is_owned-based ownership display, 320×568 and 500×768 responsive E2E. - 8 independent exact-five-key review scopes passed; backend 509, frontend 26, production build 1775 modules, isolated E2E 15.
864 lines
35 KiB
Python
864 lines
35 KiB
Python
"""User + organization store and auth logic (JWT, password hashing, roles).
|
|
|
|
Login identity is the user's `username` (stable id). `email` is an optional
|
|
separate field that admins/users can set; the default admin must set an email
|
|
+before first real use (enforced via `must_setup`).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import datetime
|
|
import math
|
|
import re
|
|
from contextlib import nullcontext
|
|
from pathlib import Path
|
|
from typing import Any, TypeGuard
|
|
|
|
import jwt
|
|
from werkzeug.security import check_password_hash, generate_password_hash
|
|
|
|
from ..config import Config
|
|
from ..storage.store import (
|
|
JsonStore,
|
|
StoreError,
|
|
contains_control_characters,
|
|
new_id,
|
|
)
|
|
|
|
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
|
USERNAME_RE = re.compile(r"^[a-zA-Z0-9_.-]{2,64}$")
|
|
MAX_LOGIN_IDENTIFIER_LENGTH = 254
|
|
_UNSET = object()
|
|
PUBLIC_USER_FIELDS = (
|
|
"id",
|
|
"username",
|
|
"email",
|
|
"org_id",
|
|
"org_name",
|
|
"name",
|
|
"role",
|
|
"must_setup",
|
|
"created_at",
|
|
"active",
|
|
"accepted_terms",
|
|
"accepted_terms_at",
|
|
"is_demo",
|
|
"trial_started_at",
|
|
"trial_expires_at",
|
|
)
|
|
|
|
|
|
def utc_now() -> datetime.datetime:
|
|
return datetime.datetime.now(datetime.timezone.utc)
|
|
|
|
|
|
def is_valid_terms_timestamp(value: object) -> bool:
|
|
"""Require a timezone-aware ISO timestamp for accepted consent."""
|
|
if not isinstance(value, str) or not value.strip():
|
|
return False
|
|
try:
|
|
parsed = datetime.datetime.fromisoformat(value)
|
|
except (TypeError, ValueError):
|
|
return False
|
|
return parsed.tzinfo is not None and parsed.utcoffset() is not None
|
|
|
|
|
|
def _parse_utc_timestamp(value: object) -> datetime.datetime | None:
|
|
"""Parse persisted ISO timestamps without accepting naive/invalid values."""
|
|
if not isinstance(value, str) or not value.strip():
|
|
return None
|
|
try:
|
|
parsed = datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
except (TypeError, ValueError):
|
|
return None
|
|
if parsed.tzinfo is None:
|
|
return None
|
|
return parsed.astimezone(datetime.timezone.utc)
|
|
|
|
|
|
def is_demo_account(user: dict[str, Any]) -> bool:
|
|
"""Treat the role as authoritative for legacy records missing demo fields."""
|
|
return isinstance(user, dict) and user.get("role") == "demo"
|
|
|
|
|
|
def _demo_trial_state(
|
|
user: dict[str, Any],
|
|
) -> tuple[datetime.datetime | None, datetime.datetime | None, bool]:
|
|
"""Return ``(started, expires, valid)`` for a persisted demo trial.
|
|
|
|
Before first login both timestamps are absent. Once either timestamp is
|
|
present, both must be timezone-aware, ordered, and exactly the fixed trial
|
|
duration. Invalid legacy state fails closed instead of granting access.
|
|
"""
|
|
if not is_demo_account(user):
|
|
return None, None, True
|
|
started_raw = user.get("trial_started_at")
|
|
expires_raw = user.get("trial_expires_at")
|
|
started_present = started_raw not in (None, "")
|
|
expires_present = expires_raw not in (None, "")
|
|
if not started_present and not expires_present:
|
|
return None, None, True
|
|
if started_present != expires_present:
|
|
return None, None, False
|
|
started = _parse_utc_timestamp(started_raw)
|
|
expires = _parse_utc_timestamp(expires_raw)
|
|
if started is None or expires is None:
|
|
return None, None, False
|
|
if expires <= started or expires - started != datetime.timedelta(days=Config.DEMO_TRIAL_DAYS):
|
|
return None, None, False
|
|
return started, expires, True
|
|
|
|
|
|
def demo_trial_is_active(user: dict[str, Any], *, now: datetime.datetime | None = None) -> bool:
|
|
"""Return whether a demo account is inside its fixed UTC trial window."""
|
|
if not is_demo_account(user):
|
|
return True
|
|
_started, expires, valid = _demo_trial_state(user)
|
|
if not valid:
|
|
return False
|
|
if expires is None:
|
|
# A demo account before its first successful login has no expiry yet.
|
|
return True
|
|
current = now or utc_now()
|
|
if current.tzinfo is None:
|
|
current = current.replace(tzinfo=datetime.timezone.utc)
|
|
current = current.astimezone(datetime.timezone.utc)
|
|
return current < expires
|
|
|
|
|
|
def demo_trial_expiry(user: dict[str, Any]) -> datetime.datetime | None:
|
|
if not is_demo_account(user):
|
|
return None
|
|
_started, expires, valid = _demo_trial_state(user)
|
|
return expires if valid else None
|
|
|
|
|
|
class AuthError(Exception):
|
|
pass
|
|
|
|
|
|
class SetupAlreadyCompletedError(AuthError):
|
|
"""Raised when setup is attempted after the one-time transition."""
|
|
pass
|
|
|
|
|
|
def is_valid_tenant_id(value: object) -> TypeGuard[str]:
|
|
"""Accept only canonical, nonblank tenant identifiers."""
|
|
if (
|
|
not isinstance(value, str)
|
|
or not value
|
|
or len(value) > 200
|
|
or value != value.strip()
|
|
or "/" in value
|
|
or "\\" in value
|
|
or value == "."
|
|
or ".." in value
|
|
or contains_control_characters(value)
|
|
):
|
|
return False
|
|
try:
|
|
if len(value.encode("utf-8")) > 200:
|
|
return False
|
|
except UnicodeError:
|
|
return False
|
|
return True
|
|
|
|
|
|
def normalize_identifier(value: object) -> str:
|
|
"""Normalize a login/storage identifier without allowing unsafe path input."""
|
|
if not isinstance(value, str):
|
|
raise AuthError("invalid identifier")
|
|
normalized = value.strip().lower()
|
|
if (
|
|
not normalized
|
|
or len(normalized) > MAX_LOGIN_IDENTIFIER_LENGTH
|
|
or "/" in normalized
|
|
or "\\" in normalized
|
|
or ".." in normalized
|
|
or contains_control_characters(normalized)
|
|
):
|
|
raise AuthError("invalid identifier")
|
|
try:
|
|
if len(normalized.encode("utf-8")) > 200:
|
|
raise AuthError("invalid identifier")
|
|
except UnicodeError as exc:
|
|
raise AuthError("invalid identifier") from exc
|
|
return normalized
|
|
|
|
|
|
class UserStore:
|
|
def __init__(self, data_dir: Path) -> None:
|
|
self.users = JsonStore(data_dir / "users")
|
|
self.orgs = JsonStore(data_dir / "orgs")
|
|
|
|
# ── org ────────────────────────────────────────────────────────────
|
|
def create_org(
|
|
self,
|
|
name: str,
|
|
*,
|
|
org_id: str | None = None,
|
|
seats: int = 5,
|
|
plan: str = "trial",
|
|
) -> dict[str, Any]:
|
|
oid = new_id("org") if org_id is None else org_id
|
|
if not is_valid_tenant_id(oid):
|
|
raise AuthError("invalid organization id")
|
|
if isinstance(seats, bool) or not isinstance(seats, int) or seats < 1:
|
|
raise AuthError("invalid organization seats")
|
|
if plan not in ("trial", "paid", "enterprise"):
|
|
raise AuthError("invalid organization plan")
|
|
return self.orgs.create(
|
|
{
|
|
"name": name,
|
|
"id": oid,
|
|
"plan": plan,
|
|
"seats": seats,
|
|
"active": True,
|
|
"created_at": __import__("time").strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
},
|
|
key=oid,
|
|
)
|
|
|
|
def get_org(self, org_id: str) -> dict[str, Any]:
|
|
return self.orgs.get(org_id)
|
|
|
|
def get_org_or_none(self, org_id: str | None) -> dict[str, Any] | None:
|
|
"""Return an organization without turning a missing tenant into a default."""
|
|
if not is_valid_tenant_id(org_id):
|
|
return None
|
|
return self.orgs.get_or_none(org_id)
|
|
|
|
def ensure_demo_org(self) -> dict[str, Any]:
|
|
"""Create the configured demo tenant once, then fail closed if inactive."""
|
|
org_id = Config.DEMO_ORG_ID
|
|
if not is_valid_tenant_id(org_id):
|
|
raise AuthError("demo organization is invalid")
|
|
with self.orgs.record_lock(org_id):
|
|
org = self.orgs.get_or_none(org_id)
|
|
if org is None:
|
|
org = self.create_org(
|
|
Config.DEMO_ORG_NAME,
|
|
org_id=org_id,
|
|
seats=Config.DEMO_ORG_SEATS,
|
|
plan="trial",
|
|
)
|
|
if org.get("active") is not True:
|
|
raise AuthError("demo organization is inactive")
|
|
return org
|
|
|
|
# ── users ──────────────────────────────────────────────────────────
|
|
@staticmethod
|
|
def _norm(username: str) -> str:
|
|
try:
|
|
return normalize_identifier(username)
|
|
except AuthError as exc:
|
|
raise AuthError("invalid username") from exc
|
|
|
|
@staticmethod
|
|
def validate_password(password: object) -> str:
|
|
"""Validate credentials before any caller performs a mutation."""
|
|
if not isinstance(password, str) or not password:
|
|
raise AuthError("password is required")
|
|
if len(password) < Config.MIN_PASSWORD_LENGTH:
|
|
raise AuthError(
|
|
f"password must be at least {Config.MIN_PASSWORD_LENGTH} characters"
|
|
)
|
|
return password
|
|
|
|
@staticmethod
|
|
def _next_auth_version(user: dict[str, Any]) -> int:
|
|
"""Return the next session version for password-bearing mutations."""
|
|
return UserStore._auth_version(user) + 1
|
|
|
|
@staticmethod
|
|
def _auth_version(user: dict[str, Any]) -> int:
|
|
if "auth_version" not in user:
|
|
return 0 # Legacy records without the field use the initial version.
|
|
current = user.get("auth_version")
|
|
if isinstance(current, bool) or not isinstance(current, int) or current < 0:
|
|
raise AuthError("invalid account state")
|
|
return current
|
|
|
|
@staticmethod
|
|
def _password_matches(password_hash: object, password: str) -> bool:
|
|
"""Treat malformed persisted hashes as invalid credentials, never 500."""
|
|
if not isinstance(password_hash, str) or not password_hash:
|
|
return False
|
|
try:
|
|
return check_password_hash(password_hash, password)
|
|
except (TypeError, ValueError):
|
|
return False
|
|
|
|
@staticmethod
|
|
def _token_identity(user: dict[str, Any]) -> tuple[str, str, str]:
|
|
username = user.get("username")
|
|
user_id = user.get("id")
|
|
org_id = user.get("org_id")
|
|
role = user.get("role")
|
|
try:
|
|
normalized_username = normalize_identifier(username)
|
|
normalized_user_id = normalize_identifier(user_id)
|
|
except AuthError as exc:
|
|
raise AuthError("invalid account state") from exc
|
|
if (
|
|
not isinstance(username, str)
|
|
or not username.strip()
|
|
or USERNAME_RE.fullmatch(username) is None
|
|
or normalized_username != username
|
|
or not isinstance(user_id, str)
|
|
or not user_id.strip()
|
|
or normalized_user_id != user_id
|
|
or user_id != username
|
|
or not is_valid_tenant_id(org_id)
|
|
or not isinstance(role, str)
|
|
or role not in Config.ROLES
|
|
or (role == "demo" and org_id != Config.DEMO_ORG_ID)
|
|
):
|
|
raise AuthError("invalid account state")
|
|
persisted_email = user.get("email")
|
|
if persisted_email is not None:
|
|
try:
|
|
canonical_email = UserStore.validate_email(
|
|
persisted_email, allow_empty=False
|
|
)
|
|
except AuthError as exc:
|
|
raise AuthError("invalid account state") from exc
|
|
if canonical_email != persisted_email:
|
|
raise AuthError("invalid account state")
|
|
return username, org_id, role
|
|
|
|
def validate_persisted_identity(self, user: dict[str, Any]) -> None:
|
|
"""Fail closed when an existing account record loses canonical identity."""
|
|
self._token_identity(user)
|
|
|
|
@staticmethod
|
|
def validate_email(email: object, *, allow_empty: bool = True) -> str | None:
|
|
"""Normalize an email and reject malformed/non-string values safely."""
|
|
if email is None or (isinstance(email, str) and not email.strip()):
|
|
if allow_empty:
|
|
return None
|
|
raise AuthError("invalid email")
|
|
if not isinstance(email, str):
|
|
raise AuthError("invalid email")
|
|
try:
|
|
normalized = normalize_identifier(email)
|
|
except AuthError as exc:
|
|
raise AuthError("invalid email") from exc
|
|
if not EMAIL_RE.fullmatch(normalized):
|
|
raise AuthError("invalid email")
|
|
return normalized
|
|
|
|
def create_user(
|
|
self,
|
|
*,
|
|
org_id: str,
|
|
username: str,
|
|
password: str,
|
|
name: str,
|
|
role: str = "user",
|
|
email: str | None = None,
|
|
must_setup: bool = False,
|
|
allow_demo: bool = False,
|
|
accepted_terms: bool = False,
|
|
oauth_identity: tuple[str, str] | None = None,
|
|
_users_lock_held: bool = False,
|
|
) -> dict[str, Any]:
|
|
if not is_valid_tenant_id(org_id):
|
|
raise AuthError("invalid organization id")
|
|
if not isinstance(role, str) or role not in Config.ROLES:
|
|
raise AuthError(f"invalid role: {role}")
|
|
if role == "demo":
|
|
if not allow_demo:
|
|
raise AuthError("demo accounts require dedicated provisioning")
|
|
if org_id != Config.DEMO_ORG_ID:
|
|
raise AuthError("demo accounts must use the demo organization")
|
|
if not isinstance(must_setup, bool):
|
|
raise AuthError("must_setup must be boolean")
|
|
if not isinstance(accepted_terms, bool):
|
|
raise AuthError("accepted_terms must be boolean")
|
|
username = self._norm(username)
|
|
if not username or not password:
|
|
raise AuthError("username and password are required")
|
|
password = self.validate_password(password)
|
|
if USERNAME_RE.fullmatch(username) is None:
|
|
raise AuthError("invalid username (letters/numbers/._- only, 2-64 chars)")
|
|
if not isinstance(name, str):
|
|
raise AuthError("name must be a string")
|
|
email = self.validate_email(email)
|
|
if oauth_identity is not None:
|
|
if (
|
|
not isinstance(oauth_identity, tuple)
|
|
or len(oauth_identity) != 2
|
|
or oauth_identity[0] not in ("google", "facebook")
|
|
or not isinstance(oauth_identity[1], str)
|
|
or not oauth_identity[1].strip()
|
|
or len(oauth_identity[1]) > 255
|
|
):
|
|
raise AuthError("invalid oauth identity")
|
|
oauth_identity = (oauth_identity[0], oauth_identity[1].strip())
|
|
# Username/email uniqueness and seat capacity are collection-wide
|
|
# invariants. Keep their checks with the create so setup/profile writes
|
|
# cannot reserve the same email concurrently in another worker.
|
|
# Seat capacity and org activation must share a lock with org updates;
|
|
# otherwise an admin can deactivate/shrink a tenant between this check
|
|
# and the user write.
|
|
try:
|
|
# Keep the lock order users -> organization for every account
|
|
# creation path. Registration relies on this outer collection lock
|
|
# to make the first-user role decision atomic with the write.
|
|
users_lock = nullcontext() if _users_lock_held else self.users.collection_lock()
|
|
with users_lock:
|
|
with self.orgs.record_lock(org_id):
|
|
org = self.orgs.get_or_none(org_id)
|
|
if org is None:
|
|
raise AuthError("organization not found")
|
|
if org.get("active") is not True:
|
|
raise AuthError("organization is inactive")
|
|
raw_seats = org.get("seats", 5)
|
|
if type(raw_seats) is not int or raw_seats < 0:
|
|
raise AuthError("invalid organization state")
|
|
seats = raw_seats
|
|
if seats <= 0:
|
|
raise AuthError("organization has no available seats")
|
|
existing = [u for u in self.users.all() if u.get("org_id") == org_id]
|
|
if len(existing) >= seats:
|
|
raise AuthError("organization seat limit reached")
|
|
if self.users.get_or_none(username) is not None:
|
|
raise AuthError("a user with this username already exists")
|
|
if email and self.email_exists(email):
|
|
raise AuthError("a user with this email already exists")
|
|
user = {
|
|
"id": username,
|
|
"username": username,
|
|
"email": email,
|
|
"org_id": org_id,
|
|
"org_name": org.get("name", ""),
|
|
"name": name.strip() or username,
|
|
"password_hash": generate_password_hash(password),
|
|
"role": role,
|
|
"must_setup": must_setup,
|
|
"accepted_terms": accepted_terms,
|
|
"accepted_terms_at": utc_now().isoformat() if accepted_terms else None,
|
|
"auth_version": 0,
|
|
"created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
|
"active": True,
|
|
}
|
|
if oauth_identity is not None:
|
|
user["oauth_identities"] = [
|
|
{"provider": oauth_identity[0], "subject": oauth_identity[1]}
|
|
]
|
|
if role == "demo":
|
|
user.update(
|
|
is_demo=True,
|
|
trial_started_at=None,
|
|
trial_expires_at=None,
|
|
)
|
|
return self.users.create(user, key=username)
|
|
except AuthError:
|
|
raise
|
|
except (OSError, StoreError, TypeError, UnicodeError, ValueError, OverflowError) as exc:
|
|
raise AuthError("account storage unavailable") from exc
|
|
|
|
def create_demo_user(
|
|
self,
|
|
*,
|
|
email: str,
|
|
username: str,
|
|
password: str,
|
|
name: str = "Demo User",
|
|
) -> dict[str, Any]:
|
|
"""Create the only supported kind of demo user."""
|
|
return self.create_user(
|
|
org_id=Config.DEMO_ORG_ID,
|
|
username=username,
|
|
password=password,
|
|
name=name,
|
|
role="demo",
|
|
email=email,
|
|
must_setup=True,
|
|
accepted_terms=False,
|
|
allow_demo=True,
|
|
)
|
|
|
|
def get_user(self, username: str) -> dict[str, Any]:
|
|
return self.users.get(self._norm(username))
|
|
|
|
def get_user_or_none(self, username: str) -> dict[str, Any] | None:
|
|
return self.users.get_or_none(self._norm(username))
|
|
|
|
def by_email(self, email: object) -> dict[str, Any] | None:
|
|
email = self.validate_email(email)
|
|
if email is None:
|
|
return None
|
|
for u in self.users.all():
|
|
stored_email = u.get("email")
|
|
if stored_email is None:
|
|
continue
|
|
try:
|
|
canonical_email = self.validate_email(stored_email, allow_empty=False)
|
|
except AuthError as exc:
|
|
raise AuthError("invalid account state") from exc
|
|
if canonical_email != stored_email:
|
|
raise AuthError("invalid account state")
|
|
if canonical_email == email:
|
|
return u
|
|
return None
|
|
|
|
def email_exists(self, email: str) -> bool:
|
|
return self.by_email(email) is not None
|
|
|
|
def list_users(self, *, org_id: str | None = None) -> list[dict[str, Any]]:
|
|
if org_id is not None and not is_valid_tenant_id(org_id):
|
|
return []
|
|
users = self.users.all()
|
|
if org_id is None:
|
|
return [self.public_user(user) for user in users]
|
|
users = [u for u in users if u.get("org_id") == org_id]
|
|
return [self.public_user(user) for user in users]
|
|
|
|
def set_active(self, username: str, active: bool) -> dict[str, Any]:
|
|
return self.update_user_fields(username, active=active)
|
|
|
|
def set_role(self, username: str, role: str) -> dict[str, Any]:
|
|
return self.update_user_fields(username, role=role)
|
|
|
|
def set_password(self, username: str, new_password: str) -> dict[str, Any]:
|
|
return self.update_user_fields(username, password=new_password)
|
|
|
|
def change_password(
|
|
self,
|
|
username: str,
|
|
current_password: object,
|
|
new_password: object,
|
|
) -> dict[str, Any]:
|
|
"""Change one user's password after verifying the current password."""
|
|
username = self._norm(username)
|
|
if not isinstance(current_password, str) or not current_password:
|
|
raise AuthError("current password is invalid")
|
|
new_password = self.validate_password(new_password)
|
|
with self.users.record_lock(username):
|
|
current = self.users.get_or_none(username)
|
|
if current is None or current.get("active") is not True:
|
|
raise AuthError("current password is invalid")
|
|
if not self._password_matches(current.get("password_hash"), current_password):
|
|
raise AuthError("current password is invalid")
|
|
return self.users.update(
|
|
username,
|
|
password_hash=generate_password_hash(new_password),
|
|
auth_version=self._next_auth_version(current),
|
|
)
|
|
|
|
def set_email(self, username: str, email: str) -> dict[str, Any]:
|
|
return self.update_user_fields(username, email=email)
|
|
|
|
def set_name(self, username: str, name: str) -> dict[str, Any]:
|
|
return self.update_user_fields(username, name=name)
|
|
|
|
def update_user_fields(
|
|
self,
|
|
username: str,
|
|
*,
|
|
name: object = _UNSET,
|
|
email: object = _UNSET,
|
|
password: object = _UNSET,
|
|
role: object = _UNSET,
|
|
active: object = _UNSET,
|
|
) -> dict[str, Any]:
|
|
"""Validate and apply related user fields as one locked mutation."""
|
|
username = self._norm(username)
|
|
fields: dict[str, Any] = {}
|
|
if name is not _UNSET:
|
|
if not isinstance(name, str) or not name.strip():
|
|
raise AuthError("name is required")
|
|
fields["name"] = name.strip()
|
|
normalized_email: str | None = None
|
|
if email is not _UNSET:
|
|
normalized_email = self.validate_email(email, allow_empty=False)
|
|
if normalized_email is None: # defensive: required email validation above
|
|
raise AuthError("invalid email")
|
|
fields["email"] = normalized_email
|
|
if password is not _UNSET:
|
|
password = self.validate_password(password)
|
|
fields["password_hash"] = generate_password_hash(password)
|
|
if role is not _UNSET:
|
|
if not isinstance(role, str) or role not in Config.ROLES:
|
|
raise AuthError("invalid role")
|
|
if role == "demo":
|
|
raise AuthError("demo accounts require dedicated provisioning")
|
|
fields["role"] = role
|
|
if active is not _UNSET:
|
|
if not isinstance(active, bool):
|
|
raise AuthError("active must be a boolean")
|
|
fields["active"] = active
|
|
|
|
with self.users.collection_lock():
|
|
with self.users.record_lock(username):
|
|
current = self.users.get_or_none(username)
|
|
if current is None:
|
|
raise AuthError("user not found")
|
|
if current.get("role") == "demo" and (
|
|
role is not _UNSET or active is not _UNSET
|
|
):
|
|
raise AuthError("manage demo accounts through the dedicated endpoint")
|
|
if normalized_email is not None:
|
|
existing = self.by_email(normalized_email)
|
|
if existing and existing["id"] != username:
|
|
raise AuthError("a user with this email already exists")
|
|
if password is not _UNSET or role is not _UNSET or active is not _UNSET:
|
|
fields["auth_version"] = self._next_auth_version(current)
|
|
if not fields:
|
|
return current
|
|
return self.users.update(username, **fields)
|
|
|
|
def complete_setup(
|
|
self,
|
|
username: str,
|
|
email: str,
|
|
new_password: str,
|
|
*,
|
|
accepted_terms: object,
|
|
accepted_terms_at: str | None = None,
|
|
server_generated_at: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Complete setup for a user that is still marked ``must_setup``.
|
|
|
|
Keep the setup fields in one store update so a caller cannot leave an
|
|
account half-configured after changing only its email or password.
|
|
"""
|
|
username = self._norm(username)
|
|
if accepted_terms is not True:
|
|
raise AuthError("terms acceptance is required")
|
|
new_password = self.validate_password(new_password)
|
|
email = self.validate_email(email, allow_empty=False)
|
|
if email is None: # defensive: required email validation above
|
|
raise AuthError("invalid email")
|
|
password_hash = generate_password_hash(new_password)
|
|
consent_timestamp = (
|
|
server_generated_at
|
|
if is_valid_terms_timestamp(server_generated_at)
|
|
else utc_now().isoformat()
|
|
)
|
|
with self.users.collection_lock():
|
|
with self.users.record_lock(username):
|
|
current = self.users.get_or_none(username)
|
|
if current is None:
|
|
raise AuthError("user not found")
|
|
if current.get("must_setup") is False and current.get("accepted_terms") is True:
|
|
raise SetupAlreadyCompletedError("setup already completed")
|
|
existing = self.by_email(email)
|
|
if existing and existing["id"] != username:
|
|
raise AuthError("a user with this email already exists")
|
|
updated = self.users.update_if(
|
|
username,
|
|
lambda latest: (
|
|
latest.get("must_setup") is not False
|
|
or latest.get("accepted_terms") is not True
|
|
),
|
|
email=email,
|
|
password_hash=password_hash,
|
|
must_setup=False,
|
|
accepted_terms=True,
|
|
accepted_terms_at=consent_timestamp,
|
|
auth_version=self._next_auth_version(current),
|
|
)
|
|
if updated is None:
|
|
raise SetupAlreadyCompletedError("setup already completed")
|
|
return updated
|
|
|
|
def start_demo_trial(
|
|
self,
|
|
username: str,
|
|
*,
|
|
now: datetime.datetime | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Atomically start a demo trial on the first successful login only."""
|
|
username = self._norm(username)
|
|
current_time = (now or utc_now()).astimezone(datetime.timezone.utc)
|
|
with self.users.record_lock(username):
|
|
current = self.users.get_or_none(username)
|
|
if (
|
|
current is None
|
|
or not is_demo_account(current)
|
|
or current.get("org_id") != Config.DEMO_ORG_ID
|
|
or current.get("active") is not True
|
|
):
|
|
raise AuthError("demo account not found")
|
|
started, expires, valid = _demo_trial_state(current)
|
|
if not valid:
|
|
raise AuthError("invalid demo trial state")
|
|
if expires is not None:
|
|
if current_time >= expires:
|
|
raise AuthError("demo trial expired")
|
|
return current
|
|
if started is not None:
|
|
# A started trial without an expiry is malformed and must not be
|
|
# silently repaired into a longer trial.
|
|
raise AuthError("invalid demo trial state")
|
|
expiry = current_time + datetime.timedelta(days=Config.DEMO_TRIAL_DAYS)
|
|
return self.users.update(
|
|
username,
|
|
is_demo=True,
|
|
trial_started_at=current_time.isoformat(),
|
|
trial_expires_at=expiry.isoformat(),
|
|
)
|
|
|
|
def revoke_demo(self, username: str) -> dict[str, Any]:
|
|
"""Deactivate a demo account and invalidate every issued auth token."""
|
|
username = self._norm(username)
|
|
with self.users.record_lock(username):
|
|
current = self.users.get_or_none(username)
|
|
if (
|
|
current is None
|
|
or not is_demo_account(current)
|
|
or current.get("org_id") != Config.DEMO_ORG_ID
|
|
):
|
|
raise AuthError("demo account not found")
|
|
return self.users.update(
|
|
username,
|
|
active=False,
|
|
auth_version=self._next_auth_version(current),
|
|
)
|
|
|
|
# ── auth ───────────────────────────────────────────────────────────
|
|
def verify(self, ident: str, password: str) -> dict[str, Any]:
|
|
# Resolve by EITHER username OR email (the login form doesn't distinguish,
|
|
# and users naturally type their email after setup). Fall back to username.
|
|
if not isinstance(ident, str) or not isinstance(password, str):
|
|
raise AuthError("invalid credentials")
|
|
try:
|
|
normalized_ident = normalize_identifier(ident)
|
|
except AuthError as exc:
|
|
raise AuthError("invalid credentials") from exc
|
|
try:
|
|
user = self.get_user_or_none(normalized_ident) or self.by_email(normalized_ident)
|
|
except (AuthError, OSError, StoreError, TypeError, UnicodeError, ValueError) as exc:
|
|
raise AuthError("invalid credentials") from exc
|
|
if not user or user.get("active") is not True:
|
|
raise AuthError("invalid credentials")
|
|
if not isinstance(user.get("must_setup"), bool):
|
|
raise AuthError("invalid credentials")
|
|
if user.get("accepted_terms") is True and not is_valid_terms_timestamp(
|
|
user.get("accepted_terms_at")
|
|
):
|
|
raise AuthError("invalid credentials")
|
|
# SaaS gate: orphaned or inactive organizations cannot sign in.
|
|
if not isinstance(user.get("role"), str) or user.get("role") not in Config.ROLES:
|
|
raise AuthError("invalid credentials")
|
|
oid = user.get("org_id")
|
|
try:
|
|
org = self.get_org_or_none(oid)
|
|
except (OSError, StoreError, TypeError, UnicodeError, ValueError) as exc:
|
|
raise AuthError("invalid credentials") from exc
|
|
if not org or org.get("active") is not True:
|
|
raise AuthError("invalid credentials")
|
|
try:
|
|
self._auth_version(user)
|
|
except AuthError as exc:
|
|
raise AuthError("invalid credentials") from exc
|
|
try:
|
|
self._token_identity(user)
|
|
except AuthError as exc:
|
|
raise AuthError("invalid credentials") from exc
|
|
if is_demo_account(user):
|
|
_started, expires, valid = _demo_trial_state(user)
|
|
if not valid:
|
|
raise AuthError("invalid credentials")
|
|
if expires is not None and utc_now() >= expires:
|
|
raise AuthError("invalid credentials")
|
|
if not self._password_matches(user.get("password_hash"), password):
|
|
raise AuthError("invalid credentials")
|
|
return user
|
|
|
|
def issue_token(self, user: dict[str, Any]) -> str:
|
|
now = utc_now()
|
|
subject, org_id, role = self._token_identity(user)
|
|
if user.get("active") is not True:
|
|
raise AuthError("invalid account state")
|
|
expires_hours = Config.JWT_EXPIRES_HOURS
|
|
if (
|
|
isinstance(expires_hours, bool)
|
|
or not isinstance(expires_hours, int)
|
|
or expires_hours < Config.JWT_MIN_EXPIRES_HOURS
|
|
or expires_hours > Config.JWT_MAX_EXPIRES_HOURS
|
|
):
|
|
raise AuthError("invalid account state")
|
|
expires_at = now + datetime.timedelta(hours=expires_hours)
|
|
if is_demo_account(user):
|
|
trial_expires = demo_trial_expiry(user)
|
|
if trial_expires is None or now >= trial_expires:
|
|
raise AuthError("demo trial expired")
|
|
# A demo JWT may never outlive the fixed UTC trial window.
|
|
expires_at = min(expires_at, trial_expires)
|
|
if expires_at <= now:
|
|
raise AuthError("invalid account state")
|
|
payload = {
|
|
"sub": subject,
|
|
"org_id": org_id,
|
|
"role": role,
|
|
"auth_version": self._auth_version(user),
|
|
"iat": now,
|
|
"exp": expires_at,
|
|
}
|
|
return jwt.encode(payload, Config.SECRET_KEY, algorithm=Config.JWT_ALGO)
|
|
|
|
def decode_token(self, token: str) -> dict[str, Any]:
|
|
try:
|
|
payload = jwt.decode(
|
|
token,
|
|
Config.SECRET_KEY,
|
|
algorithms=[Config.JWT_ALGO],
|
|
options={"require": ["sub", "org_id", "role", "auth_version", "iat", "exp"]},
|
|
)
|
|
if not isinstance(payload, dict):
|
|
raise AuthError("invalid or expired token")
|
|
subject = payload.get("sub")
|
|
try:
|
|
normalized_subject = normalize_identifier(subject)
|
|
except AuthError as exc:
|
|
raise AuthError("invalid or expired token") from exc
|
|
if (
|
|
not isinstance(subject, str)
|
|
or normalized_subject != subject
|
|
or USERNAME_RE.fullmatch(subject) is None
|
|
or not is_valid_tenant_id(payload.get("org_id"))
|
|
or not isinstance(payload.get("role"), str)
|
|
or payload["role"] not in Config.ROLES
|
|
or isinstance(payload.get("auth_version"), bool)
|
|
or not isinstance(payload.get("auth_version"), int)
|
|
or payload["auth_version"] < 0
|
|
or isinstance(payload.get("iat"), bool)
|
|
or not isinstance(payload.get("iat"), (int, float))
|
|
or not math.isfinite(payload["iat"])
|
|
or isinstance(payload.get("exp"), bool)
|
|
or not isinstance(payload.get("exp"), (int, float))
|
|
or not math.isfinite(payload["exp"])
|
|
or payload["exp"] <= 0
|
|
or payload["iat"] <= 0
|
|
or payload["iat"] >= payload["exp"]
|
|
or payload["exp"] - payload["iat"]
|
|
> Config.JWT_EXPIRES_HOURS * 60 * 60
|
|
):
|
|
raise AuthError("invalid or expired token")
|
|
return payload
|
|
except AuthError:
|
|
raise
|
|
except (jwt.PyJWTError, OverflowError, TypeError, ValueError) as exc:
|
|
raise AuthError("invalid or expired token") from exc
|
|
|
|
def public_user(self, user: dict[str, Any]) -> dict[str, Any]:
|
|
public = {
|
|
field: user[field]
|
|
for field in PUBLIC_USER_FIELDS
|
|
if field in user
|
|
}
|
|
# Legacy records may omit either consent/setup marker. Normalize those
|
|
# omissions to the safe public state so clients also redirect to setup.
|
|
public["must_setup"] = user.get("must_setup") is not False
|
|
public["accepted_terms"] = user.get("accepted_terms") is True
|
|
if is_demo_account(user):
|
|
# Legacy demo records may predate these fields; expose the role-safe
|
|
# state without ever exposing a password or a recovery mechanism.
|
|
public["is_demo"] = True
|
|
public.setdefault("trial_started_at", user.get("trial_started_at"))
|
|
public.setdefault("trial_expires_at", user.get("trial_expires_at"))
|
|
return public
|