Snapshot of the working project as of 2026-10-23 (local 2d4d4fc), one commit with no earlier history: the tracked files were de-identified first (synthetic claim ids and patient data only; see Context.md section 7). Credentials, run logs, screenshots, claim reports and client workbooks are gitignored and not included. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
329 lines
14 KiB
Python
329 lines
14 KiB
Python
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")
|