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

+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)