From 64efb68ea11437b97242fef53fbf98722edb0aa5 Mon Sep 17 00:00:00 2001 From: ManikandanC Date: Tue, 27 Oct 2026 17:55:50 +0530 Subject: [PATCH] Portal sign-in with the Ecare office login Ecare staff sign in with their office username and password, checked by the Ecare sign-in service from the backend (AUTH_TOKEN_API_URL); a good Ecare login still needs a Portal User. Service down = "try again shortly", never a wrong password. /api/auth/login, me, logout; sign-in tries limited per address. Users page: Ecare office login or Portal password per user. Context.md: 27/10 runs, the login retry bug. 71 test files, 1239 tests. Co-Authored-By: Claude Opus 5.5 --- .env.example | 4 + Context.md | 39 ++++++++- src/ecw_bot/settings.py | 4 + tests/test_portal_signin.py | 122 +++++++++++++++++++++++++- tests/test_portal_users.py | 165 ++++++++++++++++++++++++++++++++++++ web/app.py | 110 +++++++++++++++++++++--- web/templates/password.html | 6 ++ web/templates/signin.html | 4 +- web/templates/users.html | 15 +++- web/users.py | 135 ++++++++++++++++++++++++----- 10 files changed, 556 insertions(+), 48 deletions(-) diff --git a/.env.example b/.env.example index f393466..aabd52e 100644 --- a/.env.example +++ b/.env.example @@ -307,3 +307,7 @@ CAPTCHA_ATTEMPTS=2 # RESULTS_DB_CONN=DRIVER={ODBC Driver 18 for SQL Server};SERVER=;DATABASE=Charge_Validation;UID=;PWD=;TrustServerCertificate=yes; # PORTAL_URLS=https:// # PORTAL_SERVER=1 +# Ecare staff sign in with their Ecare office login (asked from the server, +# never the browser); a user still has to be added on the Users page. Empty: +# Portal passwords only. +# AUTH_TOKEN_API_URL=https://auth-wfm.ecareindia.com/api/login/token diff --git a/Context.md b/Context.md index cd1a2f0..cd18bbb 100644 --- a/Context.md +++ b/Context.md @@ -94,6 +94,15 @@ the one change is eCW data, not code: a patient's sliding fee was renewed (new period from 10/23/2026), so a claim with an earlier DOS is now Fail "Sliding fee NOT STARTED" (§6 Waiting 6). The #25 and Portal changes of 23–26/10 changed no other result. +**2026-10-27 terminal runs:** 16:40 — 2FA mail never came in 10 min (eCW +timed the approval out), then attempt 2 crashed: the run closed the Chrome +it opened and re-attached to 9222 while that Chrome was still closing +(`connect_over_cdp: Target page ... has been closed`; §10). 16:53 (after a +120 s guard cooldown) — login 220 s, 2FA mail 42 s, **50/50, 38 Pass / 12 +Fail / 0 Review** (the 50-claim sheet; same as 26/10 17:40). 17:30 with +`Mecor Ready 2 - 10262026.xlsx` (all claims, QUIET_BOXES, ECW_KEEP_OPEN=0) +— login 98 s, **50/50, 30 Pass / 15 Fail / 5 Review**, the same as that +file on 26/10. No bot error in either. **Runs page (2026-10-22, Stage 1 of EEWA-31..33, `751a177`):** http://127.0.0.1:8000/runs (header link "Runs"). Every Run writes @@ -129,7 +138,24 @@ Start / Stop, Sign-ins page; adds / changes Viewers only), **Viewer** (Dashboard, Claim status, Runs; changes nothing, no Users or Sign-ins). Operator is gone (folded into Admin). The live table was moved 2026-10-27 by the operator in SSMS (the bot's SQL login cannot ALTER the table): the -one user is Super Admin; the CHECK allows only the three roles. Passwords PBKDF2-SHA256 (600k, own salt), 10+ +one user is Super Admin; the CHECK allows only the three roles. +**Ecare office sign-in (`1da9e7c`, 2026-10-27, operator: the office login +API, "only who we want"):** with `AUTH_TOKEN_API_URL` set +(`https://auth-wfm.ecareindia.com/api/login/token`; public TLS cert, empty +body = 401), a user added with sign-in "Ecare office login" (the Users +page default) keeps no Portal password (`pw_iterations` 0): the backend +posts their username + password there (15 s); 4xx = wrong password, +5xx / timeout / 404 / 405 = 503 "try again shortly", a good login with no +Portal User = 403 `no_access`. The Ecare token is not kept; same session +cookie. `/api/auth/login` / `me` / `logout`; 20 tries per address per 15 +min = 429. "Portal password" users (the Super Admin's backup, clients) +never reach Ecare. NOT live yet: `AUTH_TOKEN_API_URL` is in neither the +PC's `.env` nor Dokploy, and no zip with it is deployed. Waiting: which +fields the Ecare reply carries (name, emp id?) so the Add form can be +username + role only and fill the name at first Sign-in — +`D:\ecw-portal-deploy\ecare-fields.ps1` (prints field names, never the +token or password) or AutoPilot's `backend/app/api/auth.py`. +Passwords PBKDF2-SHA256 (600k, own salt), 10+ characters; a user an Admin adds or resets must choose their own at the next Sign-in. 5 wrong passwords = locked 15 minutes; 30 minutes idle = signed out; sessions live in the server's memory (a restart signs everyone @@ -187,8 +213,7 @@ Get-ChildItem tests\test_*.py | ForEach-Object { "$($_.Name): $((python $_.FullN - Tests run offline against fake HTML pages in `tests/fixtures/` and fake DB connections; they never touch eCW or SQL Server (`test_cdp_browser.py` starts a local Chromium on a debug port). As of 2026-10-27: 71 test - files, **1224 tests** (full run 2026-10-27: 1222/1222; the 2 Dockerfile - tests since, with test_portal_signin.py 24/24) (earlier flaky, each + files, **1239 tests** (full run 2026-10-27: 1239/1239) (earlier flaky, each failed once and passed on rerun: test_verify_no_live_writes, depression_dx_pointer, progress_note_wait — watch them) (about 5 minutes for all of them). @@ -624,7 +649,8 @@ the Portal (restarted 2026-10-26, [portal] terminal tab). History: `git log --oneline`. -Last code commits: `cc3a01a` (2026-10-27, Portal container on India time — +Last code commits: `1da9e7c` (2026-10-27, Portal sign-in with the Ecare +office login — §2), `cc3a01a` (2026-10-27, Portal container on India time — §10), `69c51d8` (2026-10-27, Dockerfile: libgssapi-krb5-2 for ODBC Driver 18 — §10), `70ba709` (2026-10-27, Portal server mode for Dokploy — §10), `f99dc17` (2026-10-27, Portal roles Super Admin / Admin / Viewer — §2), `61338b4` (2026-10-26, Sign-in page redesign + tab @@ -761,6 +787,11 @@ test claim ids — new posts must not (§7). watchdog came after them and has not fired live yet. Suggested, not ticketed: a landing-page timeout with one retry, a 2FA retry, a week of daily runs to measure login times on the Runs page. +- **Login retry bug (seen 2026-10-27 16:50, not fixed).** After a 2FA + timeout the run closes the Chrome it opened and attempt 2 attaches to + 9222 at once, while that Chrome is still closing → `connect_over_cdp: + Target page, context or browser has been closed`, run over. Fix: wait + until 9222 stops answering, then open a fresh Chrome (test-first). - **Gitea:** one overall snapshot update at the end of a day, when the operator asks (from the clone `D:\ecw-charge-validation`; operator pushes; 2026-10-26: `ba15a57` → `120fd68`). diff --git a/src/ecw_bot/settings.py b/src/ecw_bot/settings.py index b7cbf01..b65aa4d 100644 --- a/src/ecw_bot/settings.py +++ b/src/ecw_bot/settings.py @@ -694,6 +694,10 @@ RESULTS_DB_CONN = _env( # only on the Windows PC. PORTAL_URLS = _env("PORTAL_URLS") PORTAL_SERVER = _env_bool("PORTAL_SERVER", False) +# The Ecare office sign-in service (2026-10-27), e.g. +# https://auth-wfm.ecareindia.com/api/login/token. Empty: Portal passwords +# only. +AUTH_TOKEN_API_URL = _env("AUTH_TOKEN_API_URL") # eCW builds the file server-side over a wide date range, so this is generous. DOWNLOAD_TIMEOUT = _env_int("DOWNLOAD_TIMEOUT_MS", 300_000) # 5 min diff --git a/tests/test_portal_signin.py b/tests/test_portal_signin.py index 53902ce..c489421 100644 --- a/tests/test_portal_signin.py +++ b/tests/test_portal_signin.py @@ -234,17 +234,18 @@ def an_admin_adds_a_user_who_then_must_change(): c, _ = _sign_in(app, "asha") r = c.post("/admin/users", headers=ORIGIN, data=_form( app, username="Ravi", display_name="Ravi K", role="Viewer", - password="temp-password-1")) + password="temp-password-1", signin="portal")) assert r.status_code == 200 and "ravi added" in r.text, r.text[:300] r = c.post("/admin/users", headers=ORIGIN, data=_form( app, username="kim", display_name="", role="Admin", - password="temp-password-1")) - assert r.status_code == 400 and "An Admin can add or change Viewer only" in r.text and "kim" not in store.users + password="temp-password-1", signin="portal")) + assert r.status_code == 400 and "kim" not in store.users + assert "An Admin can add or change Viewer only" in r.text assert store.users["ravi"]["must_change"] is True assert ("ravi", "user_created", "asha") in store.events r = c.post("/admin/users", headers=ORIGIN, data=_form( app, username="x", display_name="", role="Viewer", - password="temp-password-1")) + password="temp-password-1", signin="portal")) assert r.status_code == 400 and "Username" in r.text @@ -402,6 +403,119 @@ def the_container_keeps_india_time(): assert "tzdata" in line, line +# ── Ecare office sign-in (2026-10-27) ──────────────────────────────────────── + +def _ecare_portal(verdict="ok"): + """(app, store, ecare): sam (Super Admin, Portal password), ravi + (Viewer, Ecare login); the Ecare service a fake giving `verdict`.""" + from test_portal_users import FakeEcare + store, ecare = FakeUserStore(), FakeEcare(verdict) + acc = portal_users.Accounts(store) + acc.create_user("sam", "Sam", "Super Admin", PW) + acc.create_user("ravi", "Ravi", "Viewer", None, by="sam", ecare=True) + app = web_app.create_app(FakeRepo(), runner=FakeRunner(), + users_store=store, ecare=ecare, + logs_dir=tempfile.mkdtemp()) + return app, store, ecare + + +def _api_login(app, client, username, password): + return client.post("/api/auth/login", json={ + "username": username, "password": password}, + headers={**ORIGIN, "X-Run-Token": app.state.run_token}) + + +@case +def the_sign_in_page_answers_each_ecare_outcome(): + for verdict, name, code, words in ( + ("bad", "ravi", 401, "Wrong username or password"), + ("down", "ravi", 503, "try again shortly"), + ("ok", "stranger", 403, "no access to this Portal")): + app, _, _ = _ecare_portal(verdict) + _, r = _sign_in(app, name, "office-pass") + assert r.status_code == code and words in r.text, (verdict, code, + r.status_code) + app, store, ecare = _ecare_portal("ok") + c, r = _sign_in(app, "ravi", "office-pass", next_url="/days") + assert r.status_code == 303 and r.headers["location"] == "/days" + assert "office-pass" not in str(store.events) + assert c.get("/days").status_code == 200 + + +@case +def the_auth_api_signs_in_says_who_and_signs_out(): + app, _, _ = _ecare_portal("ok") + c = TestClient(app, base_url=BASE) + assert c.get("/api/auth/me").status_code == 401 + r = _api_login(app, c, " ravi ", "office-pass") + assert r.status_code == 200, r.text + assert r.json() == {"username": "ravi", "display_name": "Ravi", + "role": "Viewer", "must_change": False}, r.json() + assert "httponly" in r.headers["set-cookie"].lower() + assert "token" not in r.text.lower(), "the Ecare token stays out" + assert c.get("/api/auth/me").json()["role"] == "Viewer" + r = c.post("/api/auth/logout", + headers={**ORIGIN, "X-Run-Token": app.state.run_token}) + assert r.status_code == 200 + assert c.get("/api/auth/me").status_code == 401 + + +@case +def the_auth_api_gives_400_401_403_503_and_needs_the_token(): + for verdict, name, password, code in (("ok", " ", "x", 400), + ("ok", "ravi", " ", 400), + ("bad", "ravi", "nope", 401), + ("ok", "stranger", "pw", 403), + ("down", "ravi", "pw", 503)): + app, _, _ = _ecare_portal(verdict) + r = _api_login(app, TestClient(app, base_url=BASE), name, password) + assert r.status_code == code, (verdict, name, r.status_code) + app, _, ecare = _ecare_portal("ok") + r = TestClient(app, base_url=BASE).post( + "/api/auth/login", json={"username": "ravi", "password": "pw"}, + headers=ORIGIN) + assert r.status_code == 403 and ecare.calls == [], "no X-Run-Token" + + +@case +def sign_in_attempts_from_one_address_are_limited(): + app, _, _ = _ecare_portal("bad") + c = TestClient(app, base_url=BASE) + for i in range(web_app.SIGN_IN_LIMIT): + assert _api_login(app, c, f"user{i}", "nope").status_code == 401, i + assert _api_login(app, c, "ravi", "nope").status_code == 429 + _, r = _sign_in(app, "ravi", "nope") + assert r.status_code == 429, "the form shares the limit" + + +@case +def the_users_page_adds_an_ecare_user_without_a_password(): + app, store, _ = _ecare_portal("ok") + c, _ = _sign_in(app, "sam") + page = c.get("/admin/users").text + assert 'name="signin"' in page and "Ecare office login" in page + assert 'action="/admin/users/ravi/reset"' not in page, \ + "an Ecare user's password is not reset here" + r = c.post("/admin/users", headers=ORIGIN, data=_form( + app, username="mani", display_name="Mani", role="Admin", + signin="ecare", password="")) + assert r.status_code == 200 and "mani added" in r.text, r.text[:300] + assert portal_users.is_ecare(store.users["mani"]) + assert store.users["mani"]["must_change"] is False + r = c.post("/admin/users", headers=ORIGIN, data=_form( + app, username="client1", display_name="Client", role="Viewer", + signin="portal", password="short")) + assert r.status_code == 400, "a Portal password still has the rules" + + +@case +def an_ecare_user_is_told_to_change_the_password_in_ecare(): + app, _, _ = _ecare_portal("ok") + c, _ = _sign_in(app, "ravi", "office-pass") + page = c.get("/account/password").text + assert "Ecare office password" in page and 'name="current"' not in page + + @case def no_signed_in_page_carries_an_inline_style(): app, _, _ = _portal() diff --git a/tests/test_portal_users.py b/tests/test_portal_users.py index 564fba5..f5921d3 100644 --- a/tests/test_portal_users.py +++ b/tests/test_portal_users.py @@ -273,6 +273,171 @@ def the_sql_table_allows_the_same_roles_and_moves_old_ones(): assert first < sql.index("SET role = 'Admin' WHERE role = 'Operator'"), "Admins move up before Operators take their place" +# ── Ecare office sign-in (2026-10-27) ──────────────────────────────────────── + +class FakeEcare: + """EcareSignIn.check, canned: "ok", "bad" or "down".""" + + def __init__(self, verdict="ok"): + self.verdict = verdict + self.calls = [] + + def check(self, username, password): + self.calls.append(username) + return self.verdict + + +def _ecare_accounts(verdict="ok"): + store, clock, ecare = FakeUserStore(), Clock(), FakeEcare(verdict) + acc = users.Accounts(store, now=clock, ecare=ecare) + acc.create_user("Asha", "Asha R", "Super Admin", "first-password-1") + acc.create_user("ravi", "Ravi", "Viewer", None, by="asha", ecare=True) + return acc, store, clock, ecare + + +class _Reply: + def __init__(self, body: bytes, status=200): + self._body, self.status = body, status + + def read(self): + return self._body + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + +def _service(answer): + """(EcareSignIn, the requests it sent) with urlopen giving `answer`: + bytes for a 200 body, an int for an HTTP error, or an exception.""" + import urllib.error + sent = [] + + def urlopen(request, timeout=None): + sent.append((request, timeout)) + if isinstance(answer, BaseException): + raise answer + if isinstance(answer, int): + raise urllib.error.HTTPError(request.full_url, answer, "x", {}, + None) + return _Reply(answer) + return users.EcareSignIn("https://auth.example/api/login/token", + urlopen=urlopen), sent + + +@case +def the_ecare_service_is_asked_from_the_backend_with_a_15_second_limit(): + import json + svc, sent = _service(b'{"success": true, "token": "abc"}') + assert svc.check(" Ravi ", "pw-123") == "ok" + request, timeout = sent[0] + assert timeout == 15 and request.get_method() == "POST" + assert request.full_url == "https://auth.example/api/login/token" + assert request.get_header("Content-type") == "application/json" + assert json.loads(request.data) == {"username": "Ravi", + "password": "pw-123"} + + +@case +def the_ecare_answer_reads_as_ok_bad_or_down(): + import socket + import urllib.error + for answer, want in ((b'{"token": "abc"}', "ok"), + (b'{"token": ""}', "bad"), + (b'{"success": false, "token": "abc"}', "bad"), + (b'{"success": true}', "bad"), + (b"not json", "down"), + (401, "bad"), (403, "bad"), (400, "bad"), + # A wrong AUTH_TOKEN_API_URL, not a wrong password: + # else every user would lock themselves out. + (404, "down"), (405, "down"), + (500, "down"), (503, "down"), + (socket.timeout("slow"), "down"), + (urllib.error.URLError("no route"), "down"), + (ConnectionResetError(), "down")): + svc, _ = _service(answer) + got = svc.check("ravi", "pw-123") + assert got == want, (answer, got, want) + + +@case +def an_ecare_user_signs_in_with_the_office_password_only(): + acc, store, _, ecare = _ecare_accounts("ok") + u = store.users["ravi"] + assert u["pw_iterations"] == 0 and u["must_change"] is False, u + user, why = acc.sign_in(" RAVI ", "office-pass") + assert why == "ok" and user["username"] == "ravi", why + assert ecare.calls == ["RAVI"], "the name as typed, trimmed" + assert ("ravi", "sign_in", "") in store.events + assert users.is_ecare(user) and not users.is_ecare(store.get("asha")) + + +@case +def a_local_user_never_reaches_the_ecare_service(): + acc, _, _, ecare = _ecare_accounts("ok") + assert acc.sign_in("asha", "first-password-1")[1] == "ok" + assert acc.sign_in("asha", "office-pass")[1] == "bad" + assert ecare.calls == [] + + +@case +def a_good_ecare_login_without_a_portal_user_is_no_access(): + acc, store, _, _ = _ecare_accounts("ok") + assert acc.sign_in("stranger", "office-pass") == (None, "no_access") + assert ("stranger", "no_access", "") in store.events + assert "stranger" not in store.users, "never added by itself" + + +@case +def a_wrong_ecare_password_counts_toward_the_lock(): + acc, store, clock, ecare = _ecare_accounts("bad") + for _ in range(4): + assert acc.sign_in("ravi", "nope")[1] == "bad" + assert acc.sign_in("ravi", "nope")[1] == "locked" + ecare.verdict = "ok" + n = len(ecare.calls) + assert acc.sign_in("ravi", "office-pass")[1] == "locked" + assert len(ecare.calls) == n, "a locked user is not sent to Ecare" + clock.t += timedelta(minutes=15, seconds=1) + assert acc.sign_in("ravi", "office-pass")[1] == "ok" + + +@case +def ecare_down_is_never_a_wrong_password(): + acc, store, _, _ = _ecare_accounts("down") + assert acc.sign_in("ravi", "office-pass") == (None, "down") + assert store.users["ravi"]["failed_count"] == 0 + assert ("ravi", "service_down", "") in store.events + acc.ecare = None # AUTH_TOKEN_API_URL not set + assert acc.sign_in("ravi", "office-pass") == (None, "down") + + +@case +def a_disabled_ecare_user_stays_out_and_empty_input_never_leaves(): + acc, _, _, ecare = _ecare_accounts("ok") + acc.set_disabled("ravi", True, by="asha") + assert acc.sign_in("ravi", "office-pass") == (None, "disabled") + n = len(ecare.calls) + assert acc.sign_in("ravi", "")[1] == "bad" + assert acc.sign_in("", "pw")[1] == "bad" + assert len(ecare.calls) == n, "nothing empty is sent to Ecare" + + +@case +def an_ecare_users_password_is_changed_in_ecare_not_here(): + acc, _, _, _ = _ecare_accounts() + for fn in (lambda: acc.reset_password("ravi", "temp-password-9", + by="asha"), + lambda: acc.change_password("ravi", "a", "new-password-1")): + try: + fn() + raise AssertionError("allowed") + except users.UserError as exc: + assert "Ecare" in str(exc), exc + + @case def sessions_time_out_after_thirty_idle_minutes(): clock = Clock() diff --git a/web/app.py b/web/app.py index ff0c87a..09ec35d 100644 --- a/web/app.py +++ b/web/app.py @@ -30,15 +30,17 @@ import re import sys import html import json +import time import pathlib from collections import Counter from datetime import date, datetime, timedelta +from functools import partial ROOT = pathlib.Path(__file__).resolve().parent.parent if str(ROOT / "src") not in sys.path: sys.path.insert(0, str(ROOT / "src")) -from fastapi import FastAPI, Form, Request # noqa: E402 +from fastapi import Body, FastAPI, Form, Request # noqa: E402 from starlette.middleware.trustedhost import TrustedHostMiddleware # noqa: E402 from fastapi.encoders import jsonable_encoder # noqa: E402 from fastapi.responses import (FileResponse, HTMLResponse, # noqa: E402 @@ -55,8 +57,8 @@ from .repo import SqlResultsRepo # noqa: E402 from . import run_status # noqa: E402 from . import runs as run_view # noqa: E402 from .users import (Accounts, CAN_ADMIN, CAN_RUN, # noqa: E402 - IDLE_MINUTES, LOCK_AFTER, LOCK_MINUTES, MANAGES, - Sessions, SqlUserStore, UserError) + EcareSignIn, IDLE_MINUTES, LOCK_AFTER, LOCK_MINUTES, + MANAGES, Sessions, SqlUserStore, UserError, is_ecare) from .bot_runner import (AlreadyRunning, BotRunner, # noqa: E402 NoRunner, NotRunning, StopFailed) @@ -68,13 +70,23 @@ ALLOWED_HOSTS = ("127.0.0.1", "localhost") # The Sign-in cookie (Stage 2, 2026-10-22). Every page but these needs a # signed-in Portal User. COOKIE = "ecw_portal" -OPEN_PATHS = ("/signin",) +OPEN_PATHS = ("/signin", "/api/auth/login") SIGN_IN_ERRORS = { "bad": "Wrong username or password.", "locked": "Too many wrong passwords — this user is locked for 15 " "minutes.", "disabled": "This user is disabled — ask an Admin.", + "down": "The Ecare sign-in service is not answering — try again " + "shortly.", + "no_access": "Your Ecare login is right, but you have no access to " + "this Portal — ask an Admin to add you.", + "many": "Too many sign-in tries from here — wait 15 minutes.", } +SIGN_IN_STATUS = {"down": 503, "no_access": 403, "many": 429} +# Sign-in tries, right or wrong, from one address per SIGN_IN_WINDOW +# minutes (2026-10-27, with the Ecare sign-in). +SIGN_IN_LIMIT = 20 +SIGN_IN_WINDOW = 15 # The only pages allowed to Start / Stop a run: this one (2026-10-14). A # page on another site in the same browser could otherwise POST here. @@ -282,7 +294,7 @@ def create_app(repo, allowed_hosts=ALLOWED_HOSTS, runner=None, allowed_origins=ALLOWED_ORIGINS, logs_dir=None, screenshots_root=None, users_store=None, sessions=None, can_start=True, - secure_cookie=False) -> FastAPI: + secure_cookie=False, ecare=None) -> FastAPI: app = FastAPI(title="eCW Charge Validation — Results", docs_url=None, redoc_url=None, openapi_url=None) if runner is None: @@ -295,9 +307,25 @@ def create_app(repo, allowed_hosts=ALLOWED_HOSTS, runner=None, app.state.run_token = secrets.token_urlsafe(32) store = (users_store if users_store is not None else SqlUserStore(cfg.RESULTS_DB_CONN)) - accounts = Accounts(store) + accounts = Accounts(store, ecare=ecare) sessions = sessions if sessions is not None else Sessions() app.state.sessions = sessions + sign_in_tries = {} # client address -> recent try times + + def _too_many(request: Request) -> bool: + """Counts this try; True past SIGN_IN_LIMIT in SIGN_IN_WINDOW.""" + who = request.client.host if request.client else "?" + now = time.monotonic() + recent = [t for t in sign_in_tries.get(who, ()) + if now - t < SIGN_IN_WINDOW * 60] + recent.append(now) + sign_in_tries[who] = recent + return len(recent) > SIGN_IN_LIMIT + + def _start_session(response, user) -> None: + response.set_cookie(COOKIE, sessions.new(user), httponly=True, + samesite="strict", path="/", + secure=secure_cookie) def _check_token(request: Request, sent: str) -> None: origin = request.headers.get("origin") @@ -570,6 +598,7 @@ def create_app(repo, allowed_hosts=ALLOWED_HOSTS, runner=None, page = _page(request, "signin.html", [["Sign in", None]], error=error, next_url=_safe_next(next_url), no_users=not _db(store.list), + ecare_on=accounts.ecare is not None, idle_minutes=IDLE_MINUTES, lock_after=LOCK_AFTER, lock_minutes=LOCK_MINUTES) page.status_code = status_code @@ -587,17 +616,62 @@ def create_app(repo, allowed_hosts=ALLOWED_HOSTS, runner=None, password: str = Form(""), token: str = Form(""), next: str = Form("/")): _check_token(request, token) - user, why = _db(accounts.sign_in, username, password) + why = "many" if _too_many(request) else None + user = None + if why is None: + user, why = _db(accounts.sign_in, username, password) if user is None: - return _signin_page(request, SIGN_IN_ERRORS[why], next, 401) + return _signin_page(request, SIGN_IN_ERRORS[why], next, + SIGN_IN_STATUS.get(why, 401)) # A new token every Sign-in: an old cookie never comes back to life. sessions.drop(request.cookies.get(COOKIE)) target = ("/account/password" if user["must_change"] else _safe_next(next)) response = RedirectResponse(target, status_code=303) - response.set_cookie(COOKIE, sessions.new(user), httponly=True, - samesite="strict", path="/", - secure=secure_cookie) + _start_session(response, user) + return response + + # The same Sign-in as JSON (2026-10-27): this page's token and Origin, + # like every other write; the Ecare token never reaches the browser. + + def _who(user) -> dict: + return {"username": user["username"], + "display_name": user.get("display_name") or user["username"], + "role": user["role"], + "must_change": bool(user.get("must_change"))} + + @app.post("/api/auth/login") + def auth_login(request: Request, payload: dict = Body(None)): + _check_writer(request) + if _too_many(request): + return JSONResponse({"detail": SIGN_IN_ERRORS["many"]}, + status_code=429) + payload = payload if isinstance(payload, dict) else {} + username = str(payload.get("username") or "").strip() + password = str(payload.get("password") or "") + if not username or not password.strip(): + return JSONResponse({"detail": "Username and password are both " + "needed"}, status_code=400) + user, why = _db(accounts.sign_in, username, password) + if user is None: + return JSONResponse({"detail": SIGN_IN_ERRORS[why]}, + status_code=SIGN_IN_STATUS.get(why, 401)) + sessions.drop(request.cookies.get(COOKIE)) + response = JSONResponse(_who(user)) + _start_session(response, user) + return response + + @app.get("/api/auth/me") + def auth_me(request: Request): + return _who(request.state.user) + + @app.post("/api/auth/logout") + def auth_logout(request: Request): + _check_writer(request) + _db(store.record, request.state.user["username"], "sign_out") + sessions.drop(request.cookies.get(COOKIE)) + response = JSONResponse({"signed_out": True}) + response.delete_cookie(COOKIE, path="/") return response @app.post("/signout") @@ -640,7 +714,8 @@ def create_app(repo, allowed_hosts=ALLOWED_HOSTS, runner=None, def _users_page(request, error="", message="", status_code=200): page = _page(request, "users.html", [["Dashboard", "/"], ["Portal Users", None]], - users=_db(store.list), + users=[{**u, "ecare": is_ecare(u)} + for u in _db(store.list)], roles=MANAGES.get(request.state.user["role"], ()), error=error, message=message) @@ -668,8 +743,15 @@ def create_app(repo, allowed_hosts=ALLOWED_HOSTS, runner=None, @app.post("/admin/users", response_class=HTMLResponse) def users_create(request: Request, username: str = Form(""), display_name: str = Form(""), role: str = Form(""), - password: str = Form(""), token: str = Form("")): + password: str = Form(""), signin: str = Form("ecare"), + token: str = Form("")): name = username.strip().lower() + if signin != "portal": + return _admin_change( + request, token, f"{name} added — they sign in with their " + f"Ecare office login", partial(accounts.create_user, + ecare=True), + username, display_name, role, None)[1] return _admin_change( request, token, f"{name} added — they choose their own " f"password at their first Sign-in", accounts.create_user, @@ -731,4 +813,6 @@ def _safe_next(target) -> str: app = create_app(SqlResultsRepo(cfg.RESULTS_DB_CONN), users_store=SqlUserStore(cfg.RESULTS_DB_CONN), + ecare=(EcareSignIn(cfg.AUTH_TOKEN_API_URL) + if cfg.AUTH_TOKEN_API_URL else None), **portal_settings(cfg.PORTAL_URLS, cfg.PORTAL_SERVER)) diff --git a/web/templates/password.html b/web/templates/password.html index 20f1112..5ce14ed 100644 --- a/web/templates/password.html +++ b/web/templates/password.html @@ -8,6 +8,11 @@ {% block content %}

Change password

+ {% if user.ecare %} +

You sign in with your Ecare office login, so this Portal + keeps no password for you. Your password is your Ecare office password — + change it in Ecare.

+ {% else %} {% if user.must_change %}

Choose your own password before going on (an Admin set the one you signed in with).

{% else %}

At least 10 characters, not your username.

{% endif %} @@ -19,5 +24,6 @@ + {% endif %}
{% endblock %} diff --git a/web/templates/signin.html b/web/templates/signin.html index 91b6413..8877531 100644 --- a/web/templates/signin.html +++ b/web/templates/signin.html @@ -105,6 +105,7 @@

Sign in

+ {% if ecare_on %}

Ecare staff: your Ecare office username and password.

{% endif %} {% if error %}{% endif %} {% if no_users %}
No Portal Users yet. On this PC run python tools\add_portal_user.py to add the first Admin.
{% endif %} @@ -124,7 +125,8 @@

Signed out after {{ idle_minutes }} minutes idle. {{ lock_after }} wrong passwords lock the account for - {{ lock_minutes }} minutes. Forgot it? An Admin can reset it.

+ {{ lock_minutes }} minutes. Forgot a Portal password? An Admin can + reset it.

{% endblock %} diff --git a/web/templates/users.html b/web/templates/users.html index 318d41f..cf39a73 100644 --- a/web/templates/users.html +++ b/web/templates/users.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% block title %}Portal Users{% endblock %} {% block style %} - form.add { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)) auto; + form.add { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)) auto; gap: 10px; align-items: end; } form.add label { font-weight: 600; font-size: 13px; } form.add input, form.add select { display: block; width: 100%; margin-top: 4px; } @@ -38,10 +38,16 @@ - + + -

They change the first password at their first Sign-in.

+

Ecare office login: their own Ecare username and password, + nothing to hand over — use their Ecare username here. Portal password + (clients, a backup): give a first password; they change it at their + first Sign-in.

@@ -55,6 +61,7 @@ {% if u.disabled %}Disabled {% else %}Active{% endif %} + {% if u.ecare %}Ecare login{% else %}Portal password{% endif %} {% if u.must_change %}Must change password{% endif %} {% if u.locked_until %}Locked until {{ u.locked_until | usdate }}{% endif %} @@ -68,11 +75,13 @@ {% endif %} + {% if not u.ecare %}
+ {% endif %} {% if u.disabled %}
diff --git a/web/users.py b/web/users.py index b225921..8c32700 100644 --- a/web/users.py +++ b/web/users.py @@ -19,6 +19,13 @@ GLOSSARY.md). * **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`. @@ -29,8 +36,11 @@ 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") @@ -56,6 +66,47 @@ 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, @@ -203,42 +254,57 @@ 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"))} + "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): + 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").""" + """(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 - if user is 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" - 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" + 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" @@ -246,21 +312,37 @@ class Accounts: self.store.record(name, "sign_in") return user, "ok" - def create_user(self, username, display_name, role, password, by=""): + 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 = password_problem(password, name) + 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 = hash_password(password) + pw_hash, salt, iterations = (("", "", 0) if ecare + else hash_password(password)) now = self._now().replace(microsecond=0) self.store.create({ "username": name, @@ -268,7 +350,8 @@ class Accounts: "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, + "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) @@ -276,6 +359,9 @@ class Accounts: 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"]): @@ -289,6 +375,9 @@ class Accounts: 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)