"""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 re from pathlib import Path from typing import Any import jwt from werkzeug.security import check_password_hash, generate_password_hash from ..config import Config from ..storage.store import JsonStore, StoreError, new_id EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") class AuthError(Exception): pass 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) -> dict[str, Any]: return self.orgs.create( {"name": name, "id": org_id or new_id("org")}, key=org_id or new_id("org"), ) def get_org(self, org_id: str) -> dict[str, Any]: return self.orgs.get(org_id) # ── users ────────────────────────────────────────────────────────── @staticmethod def _norm(username: str) -> str: return username.strip().lower() def create_user( self, *, org_id: str, username: str, password: str, name: str, role: str = "user", email: str | None = None, must_setup: bool = False, ) -> dict[str, Any]: if role not in Config.ROLES: raise AuthError(f"invalid role: {role}") self.orgs.get(org_id) username = self._norm(username) if not username or not password: raise AuthError("username and password are required") if not re.fullmatch(r"[a-zA-Z0-9_.-]{2,64}", username): raise AuthError("invalid username (letters/numbers/._- only, 2-64 chars)") if self.users.get_or_none(username) is not None: raise AuthError("a user with this username already exists") email = (email or "").strip().lower() or None if email: if not EMAIL_RE.fullmatch(email): raise AuthError("invalid email") if 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": self.orgs.get(org_id).get("name", ""), "name": name.strip() or username, "password_hash": generate_password_hash(password), "role": role, "must_setup": must_setup, "created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(), "active": True, } return self.users.create(user, key=username) 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: str) -> dict[str, Any] | None: email = (email or "").strip().lower() if not email: return None for u in self.users.all(): if u.get("email") and u["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]]: users = self.users.all() if org_id: users = [u for u in users if u.get("org_id") == org_id] for u in users: u.pop("password_hash", None) return users def set_active(self, username: str, active: bool) -> dict[str, Any]: return self.users.update(self._norm(username), active=active) def set_role(self, username: str, role: str) -> dict[str, Any]: if role not in Config.ROLES: raise AuthError(f"invalid role: {role}") return self.users.update(self._norm(username), role=role) def set_password(self, username: str, new_password: str) -> dict[str, Any]: if not new_password: raise AuthError("password is required") return self.users.update( self._norm(username), password_hash=generate_password_hash(new_password), ) def set_email(self, username: str, email: str) -> dict[str, Any]: email = (email or "").strip().lower() if not EMAIL_RE.fullmatch(email): raise AuthError("invalid email") existing = self.by_email(email) if existing and existing["id"] != self._norm(username): raise AuthError("a user with this email already exists") return self.users.update(self._norm(username), email=email) def set_name(self, username: str, name: str) -> dict[str, Any]: name = (name or "").strip() if not name: raise AuthError("name is required") return self.users.update(self._norm(username), name=name) def complete_setup(self, username: str, email: str, new_password: str) -> dict[str, Any]: """First-time admin setup: set email + password, clear must_setup.""" if not new_password or len(new_password) < 4: raise AuthError("password must be at least 4 characters") self.set_email(username, email) self.set_password(username, new_password) return self.users.update(self._norm(username), must_setup=False) # ── 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. user = self.get_user_or_none(ident) or self.by_email(ident) if not user or not user.get("active", True): raise AuthError("invalid credentials") if not check_password_hash(user["password_hash"], password): raise AuthError("invalid credentials") return user def issue_token(self, user: dict[str, Any]) -> str: now = datetime.datetime.now(datetime.timezone.utc) payload = { "sub": user.get("username") or user.get("id"), "org_id": user["org_id"], "role": user["role"], "iat": now, "exp": now + datetime.timedelta(hours=Config.JWT_EXPIRES_HOURS), } return jwt.encode(payload, Config.SECRET_KEY, algorithm=Config.JWT_ALGO) def decode_token(self, token: str) -> dict[str, Any]: try: return jwt.decode(token, Config.SECRET_KEY, algorithms=[Config.JWT_ALGO]) except jwt.PyJWTError as exc: raise AuthError("invalid or expired token") from exc def public_user(self, user: dict[str, Any]) -> dict[str, Any]: u = dict(user) u.pop("password_hash", None) return u