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:
1 parent
83a0cf65ae
commit
64efb68ea1
10 files changed
+552
-44
No files matched your search
@@ -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;
|
||||
# PORTAL_URLS=https://<the address people type>
|
||||
# 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
@@ -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`).
|
||||
|
||||
@@ -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
|
||||
|
||||
+118
-4
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
+96
-12
@@ -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)
|
||||
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))
|
||||
@@ -8,6 +8,11 @@
|
||||
{% block content %}
|
||||
<div class="pw">
|
||||
<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
|
||||
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 %}
|
||||
@@ -19,5 +24,6 @@
|
||||
<label>New password again <input name="confirm" type="password" autocomplete="new-password" minlength="10" required></label>
|
||||
<button type="submit">Change password</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -105,6 +105,7 @@
|
||||
</section>
|
||||
<section class="auth-form">
|
||||
<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 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 %}
|
||||
@@ -124,7 +125,8 @@
|
||||
</form>
|
||||
<p class="note rules">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.</p>
|
||||
{{ lock_minutes }} minutes. Forgot a Portal password? An Admin can
|
||||
reset it.</p>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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 @@
|
||||
<label>Role
|
||||
<select name="role">{% for r in roles %}<option{% if r == "Viewer" %} selected{% endif %}>{{ r }}</option>{% endfor %}</select>
|
||||
</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>
|
||||
</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 class="card">
|
||||
@@ -55,6 +61,7 @@
|
||||
<td>
|
||||
{% if u.disabled %}<span class="tag off">Disabled</span>
|
||||
{% 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.locked_until %}<span class="tag lock">Locked until {{ u.locked_until | usdate }}</span>{% endif %}
|
||||
</td>
|
||||
@@ -68,11 +75,13 @@
|
||||
<button type="submit" class="plain">Set role</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if not u.ecare %}
|
||||
<form method="post" action="/admin/users/{{ u.username }}/reset" autocomplete="off">
|
||||
<input type="hidden" name="token" value="{{ run_token }}">
|
||||
<input name="password" type="password" placeholder="New first password" autocomplete="new-password" minlength="10" required>
|
||||
<button type="submit" class="plain">Reset</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if u.disabled %}
|
||||
<form method="post" action="/admin/users/{{ u.username }}/enable">
|
||||
<input type="hidden" name="token" value="{{ run_token }}">
|
||||
|
||||
+109
-20
@@ -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,33 +254,67 @@ 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"]):
|
||||
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"
|
||||
self.store.update(name, failed_count=0, locked_until=None)
|
||||
self.store.record(name, "sign_in")
|
||||
return user, "ok"
|
||||
|
||||
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
|
||||
@@ -239,28 +324,25 @@ class Accounts:
|
||||
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=""):
|
||||
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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user