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** (2026-10-27): Super Admin (everything; manages Admins and Viewers), Admin (everything; manages Viewers only), Viewer (sees the claims and Runs; changes nothing, no Portal Users or Sign-in history). Nobody changes a Super Admin from the Portal: one is made only with `toolsdd_portal_user.py` on this PC. * **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. * **Ecare office sign-in** (2026-10-27): a user made "Ecare" has no Portal password; their username and password go from this backend to the Ecare sign-in service (AUTH_TOKEN_API_URL), never from the browser. A good Ecare login still needs a Portal User (else "no_access"); the service down is "down", never a wrong password; its token is not kept. Portal-password users (the Super Admin's backup, client users) never reach the service. * 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 json import secrets import hashlib import urllib.error import urllib.request from datetime import datetime, timedelta ROLES = ("Super Admin", "Admin", "Viewer") CAN_RUN = ("Super Admin", "Admin") CAN_ADMIN = ("Super Admin", "Admin") # Portal Users, Sign-in history # The roles each role may add, give, reset, disable or enable. MANAGES = {"Super Admin": ("Admin", "Viewer"), "Admin": ("Viewer",)} 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.""" def is_ecare(user: dict) -> bool: """Signs in with the Ecare office login: no Portal password is kept (pw_iterations 0), or a session marked so.""" if "ecare" in user: return bool(user["ecare"]) return not int(user.get("pw_iterations") or 0) class EcareSignIn: """The Ecare office sign-in service, asked from this backend only. check() is "ok", "bad" (4xx, no token, success false) or "down" (5xx, 404 / 405, timeout, network, an unreadable answer).""" TIMEOUT = 15 def __init__(self, url: str, urlopen=None): self.url = url self._urlopen = urlopen or urllib.request.urlopen def check(self, username: str, password: str) -> str: body = json.dumps({"username": (username or "").strip(), "password": (password or "").strip()}) request = urllib.request.Request( self.url, data=body.encode("utf-8"), method="POST", headers={"Content-Type": "application/json"}) try: with self._urlopen(request, timeout=self.TIMEOUT) as reply: data = json.loads(reply.read().decode("utf-8")) except urllib.error.HTTPError as exc: # 404 / 405: a wrong AUTH_TOKEN_API_URL, never a wrong password # (else everyone would lock themselves out). return ("bad" if 400 <= exc.code < 500 and exc.code not in (404, 405) else "down") except (urllib.error.URLError, OSError, ValueError): return "down" if (not isinstance(data, dict) or data.get("success") is False or not data.get("token")): return "bad" return "ok" # ── 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")), "ecare": is_ecare(user)} # ── The rules ───────────────────────────────────────────────────────────────── class Accounts: def __init__(self, store, now=datetime.now, ecare=None): self.store = store self._now = now self.ecare = ecare # EcareSignIn, or None: Ecare sign-in off # 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" | "down" | "no_access"). A Portal-password user is checked here; anyone else is asked of the Ecare service, then must be a Portal User.""" name = clean_username(username) user = self.store.get(name) if USERNAME.match(name) else None now = self._now() if user and user.get("locked_until") and user["locked_until"] > now: self.store.record(name, "locked") return None, "locked" if user is not None and not is_ecare(user): if not verify_password(password or "", user["pw_hash"], user["pw_salt"], user["pw_iterations"]): return self._wrong(user, now) elif user is not None and self.ecare is None: self.store.record(name, "service_down") return None, "down" elif (self.ecare is None or not USERNAME.match(name) or not (password or "").strip()): # Nothing to ask Ecare: costs the same time as a real check. verify_password(password or "", *self._dummy) if name: self.store.record(name, "wrong_password") return None, "bad" else: verdict = self.ecare.check(username.strip(), password) if verdict == "down": self.store.record(name, "service_down") return None, "down" if verdict != "ok": if user is not None: return self._wrong(user, now) self.store.record(name, "wrong_password") return None, "bad" if user is None: self.store.record(name, "no_access") return None, "no_access" 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 _wrong(self, user, now): """One more wrong password: the 5th in a row locks for 15 minutes.""" name = user["username"] 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" def create_user(self, username, display_name, role, password, by="", ecare=False): """`ecare`: signs in with the Ecare office login, no password kept.""" 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 = None if ecare else password_problem(password, name) if problem: raise UserError(problem) if by: self._check_manages(by, role) if self.store.get(name): raise UserError(f"{name} already exists") pw_hash, salt, iterations = (("", "", 0) if ecare else 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) and not ecare, "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 user and is_ecare(user): raise UserError("Your password is your Ecare office password — " "change it in Ecare") 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._check_manages(by, user["role"]) if is_ecare(user): raise UserError(f"{user['username']} signs in with the Ecare " f"office login — the password is changed in Ecare") 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)}") self._check_manages(by, user["role"]) self._check_manages(by, role) self.store.update(user["username"], role=role, changed_at=self._now().replace(microsecond=0)) self.store.record(user["username"], "role_" + role.lower().replace(" ", "_"), 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") self._check_manages(by, user["role"]) 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 _check_manages(self, by, role): """`by` (a signed-in user) may add or change a `role` user — read from the store, not the session, so a role just taken away counts.""" actor = self.store.get(clean_username(by)) allowed = MANAGES.get(actor["role"], ()) if actor else () if role not in allowed: who = actor["role"] if actor else "Viewer" can = " and ".join(allowed) or "nobody" article = "An" if who[0] in "AEIOU" else "A" raise UserError(f"{article} {who} can add or change {can} only")