Files
ecw-charge-validation/web/users.py
T
ManikandanandClaude Opus 5.5 ce9bdbd974 Sign-in page redesign and Portal roles Super Admin / Admin / Viewer
Sign-in: brand panel, show / hide password, Caps Lock warning, moving
background, tab icon. Roles: Super Admin manages Admins and Viewers,
Admin manages Viewers only (and can Start / Stop, see Sign-ins), Viewer
sees claims and Runs only. create_tables.sql moves an old table.
71 test files, 1220 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:45:44 +05:30

343 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** (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.
* 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 = ("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."""
# ── 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 by:
self._check_manages(by, role)
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._check_manages(by, user["role"])
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")