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

+97 -13
View File
@@ -30,15 +30,17 @@ import re
import sys
import html
import json
import time
import pathlib
from collections import Counter
from datetime import date, datetime, timedelta
from functools import partial
ROOT = pathlib.Path(__file__).resolve().parent.parent
if str(ROOT / "src") not in sys.path:
sys.path.insert(0, str(ROOT / "src"))
from fastapi import FastAPI, Form, Request # noqa: E402
from fastapi import Body, FastAPI, Form, Request # noqa: E402
from starlette.middleware.trustedhost import TrustedHostMiddleware # noqa: E402
from fastapi.encoders import jsonable_encoder # noqa: E402
from fastapi.responses import (FileResponse, HTMLResponse, # noqa: E402
@@ -55,8 +57,8 @@ from .repo import SqlResultsRepo # noqa: E402
from . import run_status # noqa: E402
from . import runs as run_view # noqa: E402
from .users import (Accounts, CAN_ADMIN, CAN_RUN, # noqa: E402
IDLE_MINUTES, LOCK_AFTER, LOCK_MINUTES, MANAGES,
Sessions, SqlUserStore, UserError)
EcareSignIn, IDLE_MINUTES, LOCK_AFTER, LOCK_MINUTES,
MANAGES, Sessions, SqlUserStore, UserError, is_ecare)
from .bot_runner import (AlreadyRunning, BotRunner, # noqa: E402
NoRunner, NotRunning, StopFailed)
@@ -68,13 +70,23 @@ ALLOWED_HOSTS = ("127.0.0.1", "localhost")
# The Sign-in cookie (Stage 2, 2026-10-22). Every page but these needs a
# signed-in Portal User.
COOKIE = "ecw_portal"
OPEN_PATHS = ("/signin",)
OPEN_PATHS = ("/signin", "/api/auth/login")
SIGN_IN_ERRORS = {
"bad": "Wrong username or password.",
"locked": "Too many wrong passwords — this user is locked for 15 "
"minutes.",
"disabled": "This user is disabled — ask an Admin.",
"down": "The Ecare sign-in service is not answering — try again "
"shortly.",
"no_access": "Your Ecare login is right, but you have no access to "
"this Portal — ask an Admin to add you.",
"many": "Too many sign-in tries from here — wait 15 minutes.",
}
SIGN_IN_STATUS = {"down": 503, "no_access": 403, "many": 429}
# Sign-in tries, right or wrong, from one address per SIGN_IN_WINDOW
# minutes (2026-10-27, with the Ecare sign-in).
SIGN_IN_LIMIT = 20
SIGN_IN_WINDOW = 15
# The only pages allowed to Start / Stop a run: this one (2026-10-14). A
# page on another site in the same browser could otherwise POST here.
@@ -282,7 +294,7 @@ def create_app(repo, allowed_hosts=ALLOWED_HOSTS, runner=None,
allowed_origins=ALLOWED_ORIGINS, logs_dir=None,
screenshots_root=None, users_store=None,
sessions=None, can_start=True,
secure_cookie=False) -> FastAPI:
secure_cookie=False, ecare=None) -> FastAPI:
app = FastAPI(title="eCW Charge Validation — Results",
docs_url=None, redoc_url=None, openapi_url=None)
if runner is None:
@@ -295,9 +307,25 @@ def create_app(repo, allowed_hosts=ALLOWED_HOSTS, runner=None,
app.state.run_token = secrets.token_urlsafe(32)
store = (users_store if users_store is not None
else SqlUserStore(cfg.RESULTS_DB_CONN))
accounts = Accounts(store)
accounts = Accounts(store, ecare=ecare)
sessions = sessions if sessions is not None else Sessions()
app.state.sessions = sessions
sign_in_tries = {} # client address -> recent try times
def _too_many(request: Request) -> bool:
"""Counts this try; True past SIGN_IN_LIMIT in SIGN_IN_WINDOW."""
who = request.client.host if request.client else "?"
now = time.monotonic()
recent = [t for t in sign_in_tries.get(who, ())
if now - t < SIGN_IN_WINDOW * 60]
recent.append(now)
sign_in_tries[who] = recent
return len(recent) > SIGN_IN_LIMIT
def _start_session(response, user) -> None:
response.set_cookie(COOKIE, sessions.new(user), httponly=True,
samesite="strict", path="/",
secure=secure_cookie)
def _check_token(request: Request, sent: str) -> None:
origin = request.headers.get("origin")
@@ -570,6 +598,7 @@ def create_app(repo, allowed_hosts=ALLOWED_HOSTS, runner=None,
page = _page(request, "signin.html", [["Sign in", None]],
error=error, next_url=_safe_next(next_url),
no_users=not _db(store.list),
ecare_on=accounts.ecare is not None,
idle_minutes=IDLE_MINUTES, lock_after=LOCK_AFTER,
lock_minutes=LOCK_MINUTES)
page.status_code = status_code
@@ -587,17 +616,62 @@ def create_app(repo, allowed_hosts=ALLOWED_HOSTS, runner=None,
password: str = Form(""), token: str = Form(""),
next: str = Form("/")):
_check_token(request, token)
user, why = _db(accounts.sign_in, username, password)
why = "many" if _too_many(request) else None
user = None
if why is None:
user, why = _db(accounts.sign_in, username, password)
if user is None:
return _signin_page(request, SIGN_IN_ERRORS[why], next, 401)
return _signin_page(request, SIGN_IN_ERRORS[why], next,
SIGN_IN_STATUS.get(why, 401))
# A new token every Sign-in: an old cookie never comes back to life.
sessions.drop(request.cookies.get(COOKIE))
target = ("/account/password" if user["must_change"]
else _safe_next(next))
response = RedirectResponse(target, status_code=303)
response.set_cookie(COOKIE, sessions.new(user), httponly=True,
samesite="strict", path="/",
secure=secure_cookie)
_start_session(response, user)
return response
# The same Sign-in as JSON (2026-10-27): this page's token and Origin,
# like every other write; the Ecare token never reaches the browser.
def _who(user) -> dict:
return {"username": user["username"],
"display_name": user.get("display_name") or user["username"],
"role": user["role"],
"must_change": bool(user.get("must_change"))}
@app.post("/api/auth/login")
def auth_login(request: Request, payload: dict = Body(None)):
_check_writer(request)
if _too_many(request):
return JSONResponse({"detail": SIGN_IN_ERRORS["many"]},
status_code=429)
payload = payload if isinstance(payload, dict) else {}
username = str(payload.get("username") or "").strip()
password = str(payload.get("password") or "")
if not username or not password.strip():
return JSONResponse({"detail": "Username and password are both "
"needed"}, status_code=400)
user, why = _db(accounts.sign_in, username, password)
if user is None:
return JSONResponse({"detail": SIGN_IN_ERRORS[why]},
status_code=SIGN_IN_STATUS.get(why, 401))
sessions.drop(request.cookies.get(COOKIE))
response = JSONResponse(_who(user))
_start_session(response, user)
return response
@app.get("/api/auth/me")
def auth_me(request: Request):
return _who(request.state.user)
@app.post("/api/auth/logout")
def auth_logout(request: Request):
_check_writer(request)
_db(store.record, request.state.user["username"], "sign_out")
sessions.drop(request.cookies.get(COOKIE))
response = JSONResponse({"signed_out": True})
response.delete_cookie(COOKIE, path="/")
return response
@app.post("/signout")
@@ -640,7 +714,8 @@ def create_app(repo, allowed_hosts=ALLOWED_HOSTS, runner=None,
def _users_page(request, error="", message="", status_code=200):
page = _page(request, "users.html",
[["Dashboard", "/"], ["Portal Users", None]],
users=_db(store.list),
users=[{**u, "ecare": is_ecare(u)}
for u in _db(store.list)],
roles=MANAGES.get(request.state.user["role"], ()),
error=error,
message=message)
@@ -668,8 +743,15 @@ def create_app(repo, allowed_hosts=ALLOWED_HOSTS, runner=None,
@app.post("/admin/users", response_class=HTMLResponse)
def users_create(request: Request, username: str = Form(""),
display_name: str = Form(""), role: str = Form(""),
password: str = Form(""), token: str = Form("")):
password: str = Form(""), signin: str = Form("ecare"),
token: str = Form("")):
name = username.strip().lower()
if signin != "portal":
return _admin_change(
request, token, f"{name} added — they sign in with their "
f"Ecare office login", partial(accounts.create_user,
ecare=True),
username, display_name, role, None)[1]
return _admin_change(
request, token, f"{name} added — they choose their own "
f"password at their first Sign-in", accounts.create_user,
@@ -731,4 +813,6 @@ def _safe_next(target) -> str:
app = create_app(SqlResultsRepo(cfg.RESULTS_DB_CONN),
users_store=SqlUserStore(cfg.RESULTS_DB_CONN),
ecare=(EcareSignIn(cfg.AUTH_TOKEN_API_URL)
if cfg.AUTH_TOKEN_API_URL else None),
**portal_settings(cfg.PORTAL_URLS, cfg.PORTAL_SERVER))
+6
View File
@@ -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 %}
+3 -1
View File
@@ -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 %}
+12 -3
View File
@@ -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 }}">
+112 -23
View File
@@ -19,6 +19,13 @@ GLOSSARY.md).
* **Sessions** live in the Portal's memory: a server restart signs
everyone out. 30 minutes idle ends one. Disabling a user, resetting
their password or changing their role ends theirs at once.
* **Ecare office sign-in** (2026-10-27): a user made "Ecare" has no
Portal password; their username and password go from this backend to
the Ecare sign-in service (AUTH_TOKEN_API_URL), never from the browser.
A good Ecare login still needs a Portal User (else "no_access"); the
service down is "down", never a wrong password; its token is not kept.
Portal-password users (the Super Admin's backup, client users) never
reach the service.
* Users are disabled, never deleted; their history stays.
* Every Sign-in, Sign-out, wrong password, lock, timeout and Admin change
is a row in `CV_PortalSignIn`.
@@ -29,8 +36,11 @@ methods (tests use an in-memory one).
import re
import hmac
import json
import secrets
import hashlib
import urllib.error
import urllib.request
from datetime import datetime, timedelta
ROLES = ("Super Admin", "Admin", "Viewer")
@@ -56,6 +66,47 @@ class UserError(ValueError):
"""A request an Admin or user can fix: the message says how."""
def is_ecare(user: dict) -> bool:
"""Signs in with the Ecare office login: no Portal password is kept
(pw_iterations 0), or a session marked so."""
if "ecare" in user:
return bool(user["ecare"])
return not int(user.get("pw_iterations") or 0)
class EcareSignIn:
"""The Ecare office sign-in service, asked from this backend only.
check() is "ok", "bad" (4xx, no token, success false) or "down" (5xx,
404 / 405, timeout, network, an unreadable answer)."""
TIMEOUT = 15
def __init__(self, url: str, urlopen=None):
self.url = url
self._urlopen = urlopen or urllib.request.urlopen
def check(self, username: str, password: str) -> str:
body = json.dumps({"username": (username or "").strip(),
"password": (password or "").strip()})
request = urllib.request.Request(
self.url, data=body.encode("utf-8"), method="POST",
headers={"Content-Type": "application/json"})
try:
with self._urlopen(request, timeout=self.TIMEOUT) as reply:
data = json.loads(reply.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
# 404 / 405: a wrong AUTH_TOKEN_API_URL, never a wrong password
# (else everyone would lock themselves out).
return ("bad" if 400 <= exc.code < 500
and exc.code not in (404, 405) else "down")
except (urllib.error.URLError, OSError, ValueError):
return "down"
if (not isinstance(data, dict) or data.get("success") is False
or not data.get("token")):
return "bad"
return "ok"
# ── Passwords ─────────────────────────────────────────────────────────────────
def hash_password(password: str, salt: str = None,
@@ -203,42 +254,57 @@ def _public(user: dict) -> dict:
"""What a session keeps: never the hash."""
return {"username": user["username"],
"display_name": user.get("display_name") or user["username"],
"role": user["role"], "must_change": bool(user.get("must_change"))}
"role": user["role"], "must_change": bool(user.get("must_change")),
"ecare": is_ecare(user)}
# ── The rules ─────────────────────────────────────────────────────────────────
class Accounts:
def __init__(self, store, now=datetime.now):
def __init__(self, store, now=datetime.now, ecare=None):
self.store = store
self._now = now
self.ecare = ecare # EcareSignIn, or None: Ecare sign-in off
# Spent on an unknown username so it takes as long as a known one.
self._dummy = hash_password(secrets.token_hex(8))
def sign_in(self, username: str, password: str):
"""(user, "ok") or (None, "bad" | "locked" | "disabled")."""
"""(user, "ok") or (None, "bad" | "locked" | "disabled" | "down" |
"no_access"). A Portal-password user is checked here; anyone else
is asked of the Ecare service, then must be a Portal User."""
name = clean_username(username)
user = self.store.get(name) if USERNAME.match(name) else None
if user is None:
now = self._now()
if user and user.get("locked_until") and user["locked_until"] > now:
self.store.record(name, "locked")
return None, "locked"
if user is not None and not is_ecare(user):
if not verify_password(password or "", user["pw_hash"],
user["pw_salt"], user["pw_iterations"]):
return self._wrong(user, now)
elif user is not None and self.ecare is None:
self.store.record(name, "service_down")
return None, "down"
elif (self.ecare is None or not USERNAME.match(name)
or not (password or "").strip()):
# Nothing to ask Ecare: costs the same time as a real check.
verify_password(password or "", *self._dummy)
if name:
self.store.record(name, "wrong_password")
return None, "bad"
now = self._now()
if user.get("locked_until") and user["locked_until"] > now:
self.store.record(name, "locked")
return None, "locked"
if not verify_password(password or "", user["pw_hash"],
user["pw_salt"], user["pw_iterations"]):
failed = int(user.get("failed_count") or 0) + 1
if failed >= LOCK_AFTER:
self.store.update(name, failed_count=0, locked_until=now
+ timedelta(minutes=LOCK_MINUTES))
self.store.record(name, "locked")
return None, "locked"
self.store.update(name, failed_count=failed)
self.store.record(name, "wrong_password")
return None, "bad"
else:
verdict = self.ecare.check(username.strip(), password)
if verdict == "down":
self.store.record(name, "service_down")
return None, "down"
if verdict != "ok":
if user is not None:
return self._wrong(user, now)
self.store.record(name, "wrong_password")
return None, "bad"
if user is None:
self.store.record(name, "no_access")
return None, "no_access"
if user.get("disabled"):
self.store.record(name, "disabled_attempt")
return None, "disabled"
@@ -246,21 +312,37 @@ class Accounts:
self.store.record(name, "sign_in")
return user, "ok"
def create_user(self, username, display_name, role, password, by=""):
def _wrong(self, user, now):
"""One more wrong password: the 5th in a row locks for 15 minutes."""
name = user["username"]
failed = int(user.get("failed_count") or 0) + 1
if failed >= LOCK_AFTER:
self.store.update(name, failed_count=0, locked_until=now
+ timedelta(minutes=LOCK_MINUTES))
self.store.record(name, "locked")
return None, "locked"
self.store.update(name, failed_count=failed)
self.store.record(name, "wrong_password")
return None, "bad"
def create_user(self, username, display_name, role, password, by="",
ecare=False):
"""`ecare`: signs in with the Ecare office login, no password kept."""
name = clean_username(username)
if not USERNAME.match(name):
raise UserError("Username: 2-50 letters, digits, dot, dash or "
"underscore")
if role not in ROLES:
raise UserError(f"Role must be one of {', '.join(ROLES)}")
problem = password_problem(password, name)
problem = None if ecare else password_problem(password, name)
if problem:
raise UserError(problem)
if by:
self._check_manages(by, role)
if self.store.get(name):
raise UserError(f"{name} already exists")
pw_hash, salt, iterations = hash_password(password)
pw_hash, salt, iterations = (("", "", 0) if ecare
else hash_password(password))
now = self._now().replace(microsecond=0)
self.store.create({
"username": name,
@@ -268,7 +350,8 @@ class Accounts:
"role": role, "pw_hash": pw_hash, "pw_salt": salt,
"pw_iterations": iterations,
# Made by an Admin: the user picks their own at first Sign-in.
"must_change": bool(by), "disabled": False, "failed_count": 0,
"must_change": bool(by) and not ecare, "disabled": False,
"failed_count": 0,
"locked_until": None, "created_at": now,
"created_by": by or "add_portal_user", "changed_at": now})
self.store.record(name, "user_created", by)
@@ -276,6 +359,9 @@ class Accounts:
def change_password(self, username, current, new):
user = self.store.get(username)
if user and is_ecare(user):
raise UserError("Your password is your Ecare office password — "
"change it in Ecare")
if not user or not verify_password(current or "", user["pw_hash"],
user["pw_salt"],
user["pw_iterations"]):
@@ -289,6 +375,9 @@ class Accounts:
def reset_password(self, username, new, by):
user = self._existing(username)
self._check_manages(by, user["role"])
if is_ecare(user):
raise UserError(f"{user['username']} signs in with the Ecare "
f"office login — the password is changed in Ecare")
self._set_password(user, new, must_change=True)
self.store.record(user["username"], "password_reset", by)