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
+97
-13
@@ -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))
|
||||
Reference in new issue
Block a user