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 <noreply@anthropic.com>
This commit is contained in:
ManikandanandClaude Opus 5.5 committed 2026-10-27 17:55:50 +05:30
1 parent 83a0cf65ae
commit 64efb68ea1
10 files changed
+556 -48

No files matched your search

+4
View File
@@ -307,3 +307,7 @@ CAPTCHA_ATTEMPTS=2
# RESULTS_DB_CONN=DRIVER={ODBC Driver 18 for SQL Server};SERVER=<host>;DATABASE=Charge_Validation;UID=<sql login>;PWD=<password>;TrustServerCertificate=yes; # RESULTS_DB_CONN=DRIVER={ODBC Driver 18 for SQL Server};SERVER=<host>;DATABASE=Charge_Validation;UID=<sql login>;PWD=<password>;TrustServerCertificate=yes;
# PORTAL_URLS=https://<the address people type> # PORTAL_URLS=https://<the address people type>
# PORTAL_SERVER=1 # 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
+35 -4
View File
@@ -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 (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 "Sliding fee NOT STARTED" (§6 Waiting 6). The #25 and Portal changes
of 23–26/10 changed no other result. 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`):** **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 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). (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 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 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 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 = 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 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 - 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` 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 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 files, **1239 tests** (full run 2026-10-27: 1239/1239) (earlier flaky, each
tests since, with test_portal_signin.py 24/24) (earlier flaky, each
failed once and passed on rerun: test_verify_no_live_writes, 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). 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`. 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 §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 / 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 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 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 ticketed: a landing-page timeout with one retry, a 2FA retry, a week of
daily runs to measure login times on the Runs page. 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 - **Gitea:** one overall snapshot update at the end of a day, when the
operator asks (from the clone `D:\ecw-charge-validation`; operator operator asks (from the clone `D:\ecw-charge-validation`; operator
pushes; 2026-10-26: `ba15a57` → `120fd68`). pushes; 2026-10-26: `ba15a57` → `120fd68`).
+4
View File
@@ -694,6 +694,10 @@ RESULTS_DB_CONN = _env(
# only on the Windows PC. # only on the Windows PC.
PORTAL_URLS = _env("PORTAL_URLS") PORTAL_URLS = _env("PORTAL_URLS")
PORTAL_SERVER = _env_bool("PORTAL_SERVER", False) 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. # 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 DOWNLOAD_TIMEOUT = _env_int("DOWNLOAD_TIMEOUT_MS", 300_000) # 5 min
+118 -4
View File
@@ -234,17 +234,18 @@ def an_admin_adds_a_user_who_then_must_change():
c, _ = _sign_in(app, "asha") c, _ = _sign_in(app, "asha")
r = c.post("/admin/users", headers=ORIGIN, data=_form( r = c.post("/admin/users", headers=ORIGIN, data=_form(
app, username="Ravi", display_name="Ravi K", role="Viewer", 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] assert r.status_code == 200 and "ravi added" in r.text, r.text[:300]
r = c.post("/admin/users", headers=ORIGIN, data=_form( r = c.post("/admin/users", headers=ORIGIN, data=_form(
app, username="kim", display_name="", role="Admin", app, username="kim", display_name="", role="Admin",
password="temp-password-1")) password="temp-password-1", signin="portal"))
assert r.status_code == 400 and "An Admin can add or change Viewer only" in r.text and "kim" not in store.users 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 store.users["ravi"]["must_change"] is True
assert ("ravi", "user_created", "asha") in store.events assert ("ravi", "user_created", "asha") in store.events
r = c.post("/admin/users", headers=ORIGIN, data=_form( r = c.post("/admin/users", headers=ORIGIN, data=_form(
app, username="x", display_name="", role="Viewer", 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 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 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 @case
def no_signed_in_page_carries_an_inline_style(): def no_signed_in_page_carries_an_inline_style():
app, _, _ = _portal() app, _, _ = _portal()
+165
View File
@@ -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" 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 @case
def sessions_time_out_after_thirty_idle_minutes(): def sessions_time_out_after_thirty_idle_minutes():
clock = Clock() clock = Clock()
+97 -13
View File
@@ -30,15 +30,17 @@ import re
import sys import sys
import html import html
import json import json
import time
import pathlib import pathlib
from collections import Counter from collections import Counter
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from functools import partial
ROOT = pathlib.Path(__file__).resolve().parent.parent ROOT = pathlib.Path(__file__).resolve().parent.parent
if str(ROOT / "src") not in sys.path: if str(ROOT / "src") not in sys.path:
sys.path.insert(0, str(ROOT / "src")) 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 starlette.middleware.trustedhost import TrustedHostMiddleware # noqa: E402
from fastapi.encoders import jsonable_encoder # noqa: E402 from fastapi.encoders import jsonable_encoder # noqa: E402
from fastapi.responses import (FileResponse, HTMLResponse, # 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 run_status # noqa: E402
from . import runs as run_view # noqa: E402 from . import runs as run_view # noqa: E402
from .users import (Accounts, CAN_ADMIN, CAN_RUN, # noqa: E402 from .users import (Accounts, CAN_ADMIN, CAN_RUN, # noqa: E402
IDLE_MINUTES, LOCK_AFTER, LOCK_MINUTES, MANAGES, EcareSignIn, IDLE_MINUTES, LOCK_AFTER, LOCK_MINUTES,
Sessions, SqlUserStore, UserError) MANAGES, Sessions, SqlUserStore, UserError, is_ecare)
from .bot_runner import (AlreadyRunning, BotRunner, # noqa: E402 from .bot_runner import (AlreadyRunning, BotRunner, # noqa: E402
NoRunner, NotRunning, StopFailed) 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 # The Sign-in cookie (Stage 2, 2026-10-22). Every page but these needs a
# signed-in Portal User. # signed-in Portal User.
COOKIE = "ecw_portal" COOKIE = "ecw_portal"
OPEN_PATHS = ("/signin",) OPEN_PATHS = ("/signin", "/api/auth/login")
SIGN_IN_ERRORS = { SIGN_IN_ERRORS = {
"bad": "Wrong username or password.", "bad": "Wrong username or password.",
"locked": "Too many wrong passwords — this user is locked for 15 " "locked": "Too many wrong passwords — this user is locked for 15 "
"minutes.", "minutes.",
"disabled": "This user is disabled — ask an Admin.", "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 # 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. # 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, allowed_origins=ALLOWED_ORIGINS, logs_dir=None,
screenshots_root=None, users_store=None, screenshots_root=None, users_store=None,
sessions=None, can_start=True, sessions=None, can_start=True,
secure_cookie=False) -> FastAPI: secure_cookie=False, ecare=None) -> FastAPI:
app = FastAPI(title="eCW Charge Validation — Results", app = FastAPI(title="eCW Charge Validation — Results",
docs_url=None, redoc_url=None, openapi_url=None) docs_url=None, redoc_url=None, openapi_url=None)
if runner is 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) app.state.run_token = secrets.token_urlsafe(32)
store = (users_store if users_store is not None store = (users_store if users_store is not None
else SqlUserStore(cfg.RESULTS_DB_CONN)) else SqlUserStore(cfg.RESULTS_DB_CONN))
accounts = Accounts(store) accounts = Accounts(store, ecare=ecare)
sessions = sessions if sessions is not None else Sessions() sessions = sessions if sessions is not None else Sessions()
app.state.sessions = 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: def _check_token(request: Request, sent: str) -> None:
origin = request.headers.get("origin") 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]], page = _page(request, "signin.html", [["Sign in", None]],
error=error, next_url=_safe_next(next_url), error=error, next_url=_safe_next(next_url),
no_users=not _db(store.list), no_users=not _db(store.list),
ecare_on=accounts.ecare is not None,
idle_minutes=IDLE_MINUTES, lock_after=LOCK_AFTER, idle_minutes=IDLE_MINUTES, lock_after=LOCK_AFTER,
lock_minutes=LOCK_MINUTES) lock_minutes=LOCK_MINUTES)
page.status_code = status_code 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(""), password: str = Form(""), token: str = Form(""),
next: str = Form("/")): next: str = Form("/")):
_check_token(request, token) _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: 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. # A new token every Sign-in: an old cookie never comes back to life.
sessions.drop(request.cookies.get(COOKIE)) sessions.drop(request.cookies.get(COOKIE))
target = ("/account/password" if user["must_change"] target = ("/account/password" if user["must_change"]
else _safe_next(next)) else _safe_next(next))
response = RedirectResponse(target, status_code=303) response = RedirectResponse(target, status_code=303)
response.set_cookie(COOKIE, sessions.new(user), httponly=True, _start_session(response, user)
samesite="strict", path="/", return response
secure=secure_cookie)
# 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 return response
@app.post("/signout") @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): def _users_page(request, error="", message="", status_code=200):
page = _page(request, "users.html", page = _page(request, "users.html",
[["Dashboard", "/"], ["Portal Users", None]], [["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"], ()), roles=MANAGES.get(request.state.user["role"], ()),
error=error, error=error,
message=message) message=message)
@@ -668,8 +743,15 @@ def create_app(repo, allowed_hosts=ALLOWED_HOSTS, runner=None,
@app.post("/admin/users", response_class=HTMLResponse) @app.post("/admin/users", response_class=HTMLResponse)
def users_create(request: Request, username: str = Form(""), def users_create(request: Request, username: str = Form(""),
display_name: str = Form(""), role: 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() 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( return _admin_change(
request, token, f"{name} added — they choose their own " request, token, f"{name} added — they choose their own "
f"password at their first Sign-in", accounts.create_user, 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), app = create_app(SqlResultsRepo(cfg.RESULTS_DB_CONN),
users_store=SqlUserStore(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)) **portal_settings(cfg.PORTAL_URLS, cfg.PORTAL_SERVER))
+6
View File
@@ -8,6 +8,11 @@
{% block content %} {% block content %}
<div class="pw"> <div class="pw">
<h1>Change password</h1> <h1>Change password</h1>
{% if user.ecare %}
<p class="sub">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.</p>
{% else %}
{% if user.must_change %}<p class="sub">Choose your own password before {% if user.must_change %}<p class="sub">Choose your own password before
going on (an Admin set the one you signed in with).</p> going on (an Admin set the one you signed in with).</p>
{% else %}<p class="sub">At least 10 characters, not your username.</p>{% endif %} {% else %}<p class="sub">At least 10 characters, not your username.</p>{% endif %}
@@ -19,5 +24,6 @@
<label>New password again <input name="confirm" type="password" autocomplete="new-password" minlength="10" required></label> <label>New password again <input name="confirm" type="password" autocomplete="new-password" minlength="10" required></label>
<button type="submit">Change password</button> <button type="submit">Change password</button>
</form> </form>
{% endif %}
</div> </div>
{% endblock %} {% endblock %}
+3 -1
View File
@@ -105,6 +105,7 @@
</section> </section>
<section class="auth-form"> <section class="auth-form">
<h2>Sign in</h2> <h2>Sign in</h2>
{% if ecare_on %}<p class="note">Ecare staff: your Ecare office username and password.</p>{% endif %}
{% if error %}<div class="msg bad" role="alert">{{ error }}</div>{% endif %} {% if error %}<div class="msg bad" role="alert">{{ error }}</div>{% endif %}
{% if no_users %}<div class="msg bad">No Portal Users yet. On this PC run {% if no_users %}<div class="msg bad">No Portal Users yet. On this PC run
<code>python tools\add_portal_user.py</code> to add the first Admin.</div>{% endif %} <code>python tools\add_portal_user.py</code> to add the first Admin.</div>{% endif %}
@@ -124,7 +125,8 @@
</form> </form>
<p class="note rules">Signed out after {{ idle_minutes }} minutes idle. <p class="note rules">Signed out after {{ idle_minutes }} minutes idle.
{{ lock_after }} wrong passwords lock the account for {{ lock_after }} wrong passwords lock the account for
{{ lock_minutes }} minutes. Forgot it? An Admin can reset it.</p> {{ lock_minutes }} minutes. Forgot a Portal password? An Admin can
reset it.</p>
</section> </section>
</div> </div>
{% endblock %} {% endblock %}
+12 -3
View File
@@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Portal Users{% endblock %} {% block title %}Portal Users{% endblock %}
{% block style %} {% 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; } gap: 10px; align-items: end; }
form.add label { font-weight: 600; font-size: 13px; } form.add label { font-weight: 600; font-size: 13px; }
form.add input, form.add select { display: block; width: 100%; margin-top: 4px; } form.add input, form.add select { display: block; width: 100%; margin-top: 4px; }
@@ -38,10 +38,16 @@
<label>Role <label>Role
<select name="role">{% for r in roles %}<option{% if r == "Viewer" %} selected{% endif %}>{{ r }}</option>{% endfor %}</select> <select name="role">{% for r in roles %}<option{% if r == "Viewer" %} selected{% endif %}>{{ r }}</option>{% endfor %}</select>
</label> </label>
<label>First password <input name="password" type="password" autocomplete="new-password" minlength="10" required></label> <label>Sign-in
<select name="signin"><option value="ecare" selected>Ecare office login</option><option value="portal">Portal password</option></select>
</label>
<label>First password <input name="password" type="password" autocomplete="new-password" placeholder="Portal password only"></label>
<button type="submit">Add</button> <button type="submit">Add</button>
</form> </form>
<p class="note">They change the first password at their first Sign-in.</p> <p class="note">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.</p>
</div> </div>
<div class="card"> <div class="card">
@@ -55,6 +61,7 @@
<td> <td>
{% if u.disabled %}<span class="tag off">Disabled</span> {% if u.disabled %}<span class="tag off">Disabled</span>
{% else %}<span class="tag on">Active</span>{% endif %} {% else %}<span class="tag on">Active</span>{% endif %}
<span class="tag">{% if u.ecare %}Ecare login{% else %}Portal password{% endif %}</span>
{% if u.must_change %}<span class="tag change">Must change password</span>{% endif %} {% if u.must_change %}<span class="tag change">Must change password</span>{% endif %}
{% if u.locked_until %}<span class="tag lock">Locked until {{ u.locked_until | usdate }}</span>{% endif %} {% if u.locked_until %}<span class="tag lock">Locked until {{ u.locked_until | usdate }}</span>{% endif %}
</td> </td>
@@ -68,11 +75,13 @@
<button type="submit" class="plain">Set role</button> <button type="submit" class="plain">Set role</button>
</form> </form>
{% endif %} {% endif %}
{% if not u.ecare %}
<form method="post" action="/admin/users/{{ u.username }}/reset" autocomplete="off"> <form method="post" action="/admin/users/{{ u.username }}/reset" autocomplete="off">
<input type="hidden" name="token" value="{{ run_token }}"> <input type="hidden" name="token" value="{{ run_token }}">
<input name="password" type="password" placeholder="New first password" autocomplete="new-password" minlength="10" required> <input name="password" type="password" placeholder="New first password" autocomplete="new-password" minlength="10" required>
<button type="submit" class="plain">Reset</button> <button type="submit" class="plain">Reset</button>
</form> </form>
{% endif %}
{% if u.disabled %} {% if u.disabled %}
<form method="post" action="/admin/users/{{ u.username }}/enable"> <form method="post" action="/admin/users/{{ u.username }}/enable">
<input type="hidden" name="token" value="{{ run_token }}"> <input type="hidden" name="token" value="{{ run_token }}">
+112 -23
View File
@@ -19,6 +19,13 @@ GLOSSARY.md).
* **Sessions** live in the Portal's memory: a server restart signs * **Sessions** live in the Portal's memory: a server restart signs
everyone out. 30 minutes idle ends one. Disabling a user, resetting everyone out. 30 minutes idle ends one. Disabling a user, resetting
their password or changing their role ends theirs at once. 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. * Users are disabled, never deleted; their history stays.
* Every Sign-in, Sign-out, wrong password, lock, timeout and Admin change * Every Sign-in, Sign-out, wrong password, lock, timeout and Admin change
is a row in `CV_PortalSignIn`. is a row in `CV_PortalSignIn`.
@@ -29,8 +36,11 @@ methods (tests use an in-memory one).
import re import re
import hmac import hmac
import json
import secrets import secrets
import hashlib import hashlib
import urllib.error
import urllib.request
from datetime import datetime, timedelta from datetime import datetime, timedelta
ROLES = ("Super Admin", "Admin", "Viewer") ROLES = ("Super Admin", "Admin", "Viewer")
@@ -56,6 +66,47 @@ class UserError(ValueError):
"""A request an Admin or user can fix: the message says how.""" """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 ───────────────────────────────────────────────────────────────── # ── Passwords ─────────────────────────────────────────────────────────────────
def hash_password(password: str, salt: str = None, def hash_password(password: str, salt: str = None,
@@ -203,42 +254,57 @@ def _public(user: dict) -> dict:
"""What a session keeps: never the hash.""" """What a session keeps: never the hash."""
return {"username": user["username"], return {"username": user["username"],
"display_name": user.get("display_name") or 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 ───────────────────────────────────────────────────────────────── # ── The rules ─────────────────────────────────────────────────────────────────
class Accounts: class Accounts:
def __init__(self, store, now=datetime.now): def __init__(self, store, now=datetime.now, ecare=None):
self.store = store self.store = store
self._now = now 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. # Spent on an unknown username so it takes as long as a known one.
self._dummy = hash_password(secrets.token_hex(8)) self._dummy = hash_password(secrets.token_hex(8))
def sign_in(self, username: str, password: str): 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) name = clean_username(username)
user = self.store.get(name) if USERNAME.match(name) else None 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) verify_password(password or "", *self._dummy)
if name: if name:
self.store.record(name, "wrong_password") self.store.record(name, "wrong_password")
return None, "bad" return None, "bad"
now = self._now() else:
if user.get("locked_until") and user["locked_until"] > now: verdict = self.ecare.check(username.strip(), password)
self.store.record(name, "locked") if verdict == "down":
return None, "locked" self.store.record(name, "service_down")
if not verify_password(password or "", user["pw_hash"], return None, "down"
user["pw_salt"], user["pw_iterations"]): if verdict != "ok":
failed = int(user.get("failed_count") or 0) + 1 if user is not None:
if failed >= LOCK_AFTER: return self._wrong(user, now)
self.store.update(name, failed_count=0, locked_until=now self.store.record(name, "wrong_password")
+ timedelta(minutes=LOCK_MINUTES)) return None, "bad"
self.store.record(name, "locked") if user is None:
return None, "locked" self.store.record(name, "no_access")
self.store.update(name, failed_count=failed) return None, "no_access"
self.store.record(name, "wrong_password")
return None, "bad"
if user.get("disabled"): if user.get("disabled"):
self.store.record(name, "disabled_attempt") self.store.record(name, "disabled_attempt")
return None, "disabled" return None, "disabled"
@@ -246,21 +312,37 @@ class Accounts:
self.store.record(name, "sign_in") self.store.record(name, "sign_in")
return user, "ok" 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) name = clean_username(username)
if not USERNAME.match(name): if not USERNAME.match(name):
raise UserError("Username: 2-50 letters, digits, dot, dash or " raise UserError("Username: 2-50 letters, digits, dot, dash or "
"underscore") "underscore")
if role not in ROLES: if role not in ROLES:
raise UserError(f"Role must be one of {', '.join(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: if problem:
raise UserError(problem) raise UserError(problem)
if by: if by:
self._check_manages(by, role) self._check_manages(by, role)
if self.store.get(name): if self.store.get(name):
raise UserError(f"{name} already exists") 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) now = self._now().replace(microsecond=0)
self.store.create({ self.store.create({
"username": name, "username": name,
@@ -268,7 +350,8 @@ class Accounts:
"role": role, "pw_hash": pw_hash, "pw_salt": salt, "role": role, "pw_hash": pw_hash, "pw_salt": salt,
"pw_iterations": iterations, "pw_iterations": iterations,
# Made by an Admin: the user picks their own at first Sign-in. # 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, "locked_until": None, "created_at": now,
"created_by": by or "add_portal_user", "changed_at": now}) "created_by": by or "add_portal_user", "changed_at": now})
self.store.record(name, "user_created", by) self.store.record(name, "user_created", by)
@@ -276,6 +359,9 @@ class Accounts:
def change_password(self, username, current, new): def change_password(self, username, current, new):
user = self.store.get(username) 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"], if not user or not verify_password(current or "", user["pw_hash"],
user["pw_salt"], user["pw_salt"],
user["pw_iterations"]): user["pw_iterations"]):
@@ -289,6 +375,9 @@ class Accounts:
def reset_password(self, username, new, by): def reset_password(self, username, new, by):
user = self._existing(username) user = self._existing(username)
self._check_manages(by, user["role"]) 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._set_password(user, new, must_change=True)
self.store.record(user["username"], "password_reset", by) self.store.record(user["username"], "password_reset", by)