Initial import: eCW Charge Validation bot and results Portal
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>
This commit is contained in:
commit
ba15a574ab
205 files changed
+56772
No files matched your search
+328
@@ -0,0 +1,328 @@
|
||||
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")
|
||||
Reference in new issue
Block a user