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
+556
-48
No files matched your search
+112
-23
@@ -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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user