r""" Portal Users, Sign-in and sessions (spec docs/superpowers/specs/2026-10-22- run-tracking-and-portal-sign-in-design.md, Stage 2, EEWA-32; terms in GLOSSARY.md). * **Passwords** are stored only as PBKDF2-SHA256 hashes with their own salt and iteration count (stdlib, no new package on this locked-down PC); never stored, logged or shown in plain. At least 10 characters, not the username. * **Roles**: Admin (everything, and managing Portal Users), Operator (see everything, Start / Stop a Run), Viewer (see only). * **Lockout**: 5 wrong passwords in a row lock the user for 15 minutes. An unknown username is told the same as a wrong password, and costs the same time (a hash is still computed), so the page does not reveal which usernames exist. * **Sessions** live in the Portal's memory: a server restart signs everyone out. 30 minutes idle ends one. Disabling a user, resetting their password or changing their role ends theirs at once. * Users are disabled, never deleted; their history stays. * Every Sign-in, Sign-out, wrong password, lock, timeout and Admin change is a row in `CV_PortalSignIn`. `Accounts` is the logic, against any store with the `SqlUserStore` methods (tests use an in-memory one). """ import re import hmac import secrets import hashlib from datetime import datetime, timedelta ROLES = ("Admin", "Operator", "Viewer") CAN_RUN = ("Admin", "Operator") MIN_PASSWORD = 10 ITERATIONS = 600_000 LOCK_AFTER = 5 LOCK_MINUTES = 15 IDLE_MINUTES = 30 USERNAME = re.compile(r"^[A-Za-z0-9._-]{2,50}$") USER_TABLE = "dbo.CV_PortalUser" SIGNIN_TABLE = "dbo.CV_PortalSignIn" _USER_COLUMNS = ("username", "display_name", "role", "pw_hash", "pw_salt", "pw_iterations", "must_change", "disabled", "failed_count", "locked_until", "created_at", "created_by", "changed_at") class UserError(ValueError): """A request an Admin or user can fix: the message says how.""" # ── Passwords ───────────────────────────────────────────────────────────────── def hash_password(password: str, salt: str = None, iterations: int = None) -> tuple: """(hash hex, salt hex, iterations).""" salt = salt or secrets.token_hex(16) iterations = iterations or ITERATIONS digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), bytes.fromhex(salt), iterations) return digest.hex(), salt, iterations def verify_password(password: str, pw_hash: str, salt: str, iterations: int) -> bool: try: got, _, _ = hash_password(password, salt, int(iterations)) except (ValueError, TypeError): return False return hmac.compare_digest(got, pw_hash or "") def password_problem(password: str, username: str = ""): """Why `password` is not allowed, or None.""" if len(password or "") < MIN_PASSWORD: return f"The password needs at least {MIN_PASSWORD} characters" if username and password.strip().lower() == username.lower(): return "The password cannot be the username" return None def clean_username(username: str) -> str: return (username or "").strip().lower() # ── The store (SQL Server) ──────────────────────────────────────────────────── class SqlUserStore: """`CV_PortalUser` and `CV_PortalSignIn` in Charge_Validation. Every statement parameterized; one short connection per call.""" def __init__(self, conn_str: str, connect=None): self.conn_str = conn_str self._connect = connect def _run(self, sql: str, params=(), fetch=False): connect = self._connect if connect is None: import pyodbc connect = pyodbc.connect conn = connect(self.conn_str, timeout=10) try: cur = conn.cursor().execute(sql, list(params)) if fetch: cols = [d[0] for d in cur.description] return [dict(zip(cols, row)) for row in cur.fetchall()] conn.commit() return None finally: conn.close() def get(self, username): rows = self._run(f"SELECT {', '.join(_USER_COLUMNS)} FROM " f"{USER_TABLE} WHERE username = ?", [username], fetch=True) return _bools(rows[0]) if rows else None def list(self) -> list: return [_bools(r) for r in self._run( f"SELECT {', '.join(_USER_COLUMNS)} FROM {USER_TABLE} " f"ORDER BY username", fetch=True)] def create(self, user: dict) -> None: cols = [c for c in _USER_COLUMNS if c in user] self._run(f"INSERT INTO {USER_TABLE} ({', '.join(cols)}) VALUES " f"({', '.join('?' for _ in cols)})", [user[c] for c in cols]) def update(self, username, **fields) -> None: cols = [c for c in fields if c in _USER_COLUMNS and c != "username"] if not cols: return self._run(f"UPDATE {USER_TABLE} SET " f"{', '.join(c + ' = ?' for c in cols)} WHERE username = ?", [fields[c] for c in cols] + [username]) def record(self, username, event, by="") -> None: self._run(f"INSERT INTO {SIGNIN_TABLE} (at, username, event, by_user)" f" VALUES (?, ?, ?, ?)", [datetime.now().replace(microsecond=0), username[:50], event, by or None]) def history(self, limit: int = 300) -> list: return self._run(f"SELECT TOP {int(limit)} at, username, event, " f"by_user FROM {SIGNIN_TABLE} ORDER BY id DESC", fetch=True) def _bools(row: dict) -> dict: for k in ("must_change", "disabled"): row[k] = bool(row.get(k)) return row # ── Sessions ────────────────────────────────────────────────────────────────── class Sessions: """Signed-in users by cookie token, in memory.""" def __init__(self, now=datetime.now): self._now = now self._by_token = {} def new(self, user: dict) -> str: token = secrets.token_urlsafe(32) self._by_token[token] = {"user": _public(user), "last": self._now()} return token def get(self, token): """(user dict, None) for a live session; (None, username) for one that just timed out; (None, None) otherwise.""" s = self._by_token.get(token or "") if not s: return None, None if self._now() - s["last"] > timedelta(minutes=IDLE_MINUTES): del self._by_token[token] return None, s["user"]["username"] s["last"] = self._now() return s["user"], None def drop(self, token) -> None: self._by_token.pop(token or "", None) def drop_user(self, username) -> None: for t in [t for t, s in self._by_token.items() if s["user"]["username"] == username]: del self._by_token[t] def refresh_user(self, user: dict) -> None: for s in self._by_token.values(): if s["user"]["username"] == user["username"]: s["user"] = _public(user) def _public(user: dict) -> dict: """What a session keeps: never the hash.""" return {"username": user["username"], "display_name": user.get("display_name") or user["username"], "role": user["role"], "must_change": bool(user.get("must_change"))} # ── The rules ───────────────────────────────────────────────────────────────── class Accounts: def __init__(self, store, now=datetime.now): self.store = store self._now = now # Spent on an unknown username so it takes as long as a known one. self._dummy = hash_password(secrets.token_hex(8)) def sign_in(self, username: str, password: str): """(user, "ok") or (None, "bad" | "locked" | "disabled").""" name = clean_username(username) user = self.store.get(name) if USERNAME.match(name) else None if user is None: verify_password(password or "", *self._dummy) if name: self.store.record(name, "wrong_password") return None, "bad" now = self._now() if user.get("locked_until") and user["locked_until"] > now: self.store.record(name, "locked") return None, "locked" if not verify_password(password or "", user["pw_hash"], user["pw_salt"], user["pw_iterations"]): failed = int(user.get("failed_count") or 0) + 1 if failed >= LOCK_AFTER: self.store.update(name, failed_count=0, locked_until=now + timedelta(minutes=LOCK_MINUTES)) self.store.record(name, "locked") return None, "locked" self.store.update(name, failed_count=failed) self.store.record(name, "wrong_password") return None, "bad" if user.get("disabled"): self.store.record(name, "disabled_attempt") return None, "disabled" self.store.update(name, failed_count=0, locked_until=None) self.store.record(name, "sign_in") return user, "ok" def create_user(self, username, display_name, role, password, by=""): name = clean_username(username) if not USERNAME.match(name): raise UserError("Username: 2-50 letters, digits, dot, dash or " "underscore") if role not in ROLES: raise UserError(f"Role must be one of {', '.join(ROLES)}") problem = password_problem(password, name) if problem: raise UserError(problem) if self.store.get(name): raise UserError(f"{name} already exists") pw_hash, salt, iterations = hash_password(password) now = self._now().replace(microsecond=0) self.store.create({ "username": name, "display_name": (display_name or "").strip()[:100] or name, "role": role, "pw_hash": pw_hash, "pw_salt": salt, "pw_iterations": iterations, # Made by an Admin: the user picks their own at first Sign-in. "must_change": bool(by), "disabled": False, "failed_count": 0, "locked_until": None, "created_at": now, "created_by": by or "add_portal_user", "changed_at": now}) self.store.record(name, "user_created", by) return self.store.get(name) def change_password(self, username, current, new): user = self.store.get(username) if not user or not verify_password(current or "", user["pw_hash"], user["pw_salt"], user["pw_iterations"]): raise UserError("The current password is not right") if current == new: raise UserError("The new password must be different") self._set_password(user, new, must_change=False) self.store.record(username, "password_changed") return self.store.get(username) def reset_password(self, username, new, by): user = self._existing(username) self._set_password(user, new, must_change=True) self.store.record(user["username"], "password_reset", by) def set_role(self, username, role, by): user = self._existing(username) if role not in ROLES: raise UserError(f"Role must be one of {', '.join(ROLES)}") if user["role"] == "Admin" and role != "Admin": self._keep_an_admin(user["username"]) self.store.update(user["username"], role=role, changed_at=self._now().replace(microsecond=0)) self.store.record(user["username"], f"role_{role.lower()}", by) def set_disabled(self, username, disabled: bool, by): user = self._existing(username) if disabled and user["username"] == by: raise UserError("You cannot disable yourself") if disabled and user["role"] == "Admin": self._keep_an_admin(user["username"]) self.store.update(user["username"], disabled=disabled, failed_count=0, locked_until=None, changed_at=self._now().replace(microsecond=0)) self.store.record(user["username"], "user_disabled" if disabled else "user_enabled", by) def _set_password(self, user, new, must_change): problem = password_problem(new, user["username"]) if problem: raise UserError(problem) pw_hash, salt, iterations = hash_password(new) self.store.update(user["username"], pw_hash=pw_hash, pw_salt=salt, pw_iterations=iterations, must_change=must_change, failed_count=0, locked_until=None, changed_at=self._now().replace(microsecond=0)) def _existing(self, username): user = self.store.get(clean_username(username)) if not user: raise UserError(f"No user {username}") return user def _keep_an_admin(self, leaving): others = [u for u in self.store.list() if u["role"] == "Admin" and not u["disabled"] and u["username"] != leaving] if not others: raise UserError("The Portal needs at least one active Admin")