T10.4: remove the local password path entirely
Real deletion (D15's 'full replacement'), not a toggle. Okta is now the only credential this app accepts anywhere. Backend: - server/models.py: drop User.password_hash. - server/alembic/versions/1d60a608bb51_...: matching migration (op.drop_column, same plain-drop precedent as project_role/locked_until/etc.; downgrade re-adds it with server_default=''). - server/auth.py: remove hash_password/verify_password/password_problem/ MIN_PASSWORD_LEN/_COMMON_PASSWORDS, create_reset_token/decode_reset_token/ RESET_MINUTES, the bcrypt import. Roles/tokens/cookies/get_current_user untouched. - server/app.py: remove login(), the whole self-service reset-password block (forgot-password/reset-available/reset-password), and change_password() (POST /api/auth/password). Rework create_user() to drop the password field (with a docstring note: the username must exactly match the eventual Okta identity claim, or a later sign-in provisions a second account instead of matching this one). Remove admin_reset_password() outright - nothing left to reset. Fixes a bug this task's own predecessor left behind: okta_callback()'s JIT provisioning (T10.3) was still setting password_hash="", which would have raised TypeError the moment the column was actually dropped. Admin bootstrap (D16): server/manage_users.py moves from creating accounts (create/create-admin/reset-password, all password-based) to a single 'promote <username> --role <role>' command that changes the role on a row Okta's JIT provisioning already created - the documented path for naming the first admin. list/disable/enable unchanged. Frontend: html/users.js drops the password field and validation from createUser(), removes resetPw() and its button (nothing left to reset). html/users.html drops the #nu-password input, adds a tooltip on username explaining the exact-match-to-Okta requirement. html/auth-guard.js removes the wpChangePassword dialog; html/wp-sidenav.js removes the 'Password' menu item that opened it. Tests: tests/browser_check.py and tests/launcher_check.py stop hashing a password to seed fixture rows (and the --keep-server hint now prints a ready-to-use cookie-setting snippet instead of a dead username/password). tests/pipeline_check.py and tests/token_check.py drop an unused PW import. tests/console_dialogs_check.py: the admin password-reset dialog it drove no longer exists, so that scenario is removed - the prompt-with-validate() UI pattern it exercised is still covered via creator_dialogs_check.py's wp-creation-app.js call sites, noted in this file's docstring so the coverage move isn't silent. tests/url_state_check.py: the "next= survives a real sign-in via login" scenario is explicitly marked SKIPPED (not deleted, not faked) - that promise is specific to the login FORM this task removed and can't be honestly re-proven until T10.5 rebuilds it as an Okta redirect; a minted-token cookie now stands in as setup only, so scenarios 3-6 in that file still get a signed-in page to run against. server/smoketest.py and server/seed_demo.py: switched from POST /api/auth/login to minting a session the same way okta_callback() does (auth.create_token(), seeded into the cookie jar) rather than waiting on T10.7. This is a real operational change, documented in both files' own AUTHENTICATION sections: they now need to run where AUTH_SECRET_KEY and the database match the target server's (inside the api container, or local dev) - they can no longer sign in to an arbitrary remote URL from an unrelated workstation, because Okta requires a real browser and these are stdlib scripts. The account must already exist; neither script creates or promotes one. server/requirements.txt: bcrypt dropped, nothing imports it anymore. Verified: full Alembic chain (baseline through this migration) upgrades and downgrades cleanly against a throwaway SQLite DB. okta_callback() JIT provisioning re-tested against the post-migration schema (would have thrown before the password_hash="" fix above). create_user() verified via a live HTTP call with no password field. manage_users.py promote verified end to end (seed a JIT-shaped row at project_user, promote to admin, list). smoketest.py and seed_demo.py both run to completion against a live uvicorn instance using the new minted-session path - 25/25 checks, including logout actually invalidating the session (proving the cookie-jar seeding didn't just fake the sign-in, it preserved the real expiry mechanics). wave-10.md T10.4 / D15 / D16
This commit is contained in:
@@ -6,16 +6,23 @@ NGINX → FastAPI → PostgreSQL all work and that the Python logic (the AWP
|
||||
release gate, metrics, cascade delete) behaves. Stdlib only — no pip, no jq.
|
||||
|
||||
AUTHENTICATION
|
||||
Every /api/ route except /api/health requires a session (auth_gate in
|
||||
server/app.py), so the script signs in first and keeps the session cookie for
|
||||
the rest of the run. Credentials come from the environment by preference, so a
|
||||
password never has to appear in a command line or shell history:
|
||||
There is no local password anymore (D15/D16, T10.4) — identity is Okta's job,
|
||||
and Okta requires a real browser to complete, which this stdlib script cannot
|
||||
do. So instead of signing in over HTTP the way the front end does, this script
|
||||
mints a session the same way server/app.py's okta_callback() does after Okta
|
||||
hands back an identity: auth.create_token() for an existing account, seeded
|
||||
straight into the cookie jar. That means it needs to run somewhere that can
|
||||
read the SAME AUTH_SECRET_KEY and reach the SAME database as the server under
|
||||
test — inside the api container, or locally against your dev DB. It can no
|
||||
longer sign in to an arbitrary remote URL from an unrelated workstation; if
|
||||
the target is remote, run it on that host or inside that container instead.
|
||||
|
||||
export WP_SMOKE_USER=smoketest
|
||||
export WP_SMOKE_PASSWORD='…'
|
||||
python3 server/smoketest.py https://wp-suite.company.local
|
||||
|
||||
…or pass --user / --password explicitly.
|
||||
…or pass --user explicitly. The account must already exist — sign it in
|
||||
through Okta once first (or create it from the admin console) if it doesn't;
|
||||
this script promotes no one and provisions nothing.
|
||||
|
||||
Use an ADMIN account. The script creates a project and deletes it again at the
|
||||
end, and deleting one takes Project Admin on that project (require_project_admin);
|
||||
@@ -24,26 +31,29 @@ AUTHENTICATION
|
||||
discover it in the cleanup step.
|
||||
|
||||
USAGE
|
||||
# Against the deployed site (through the NGINX proxy):
|
||||
python3 server/smoketest.py https://wp-suite.company.local
|
||||
|
||||
# Self-signed / internal TLS cert? skip verification:
|
||||
python3 server/smoketest.py https://wp-suite.company.local --insecure
|
||||
|
||||
# From inside the api container (hits FastAPI directly):
|
||||
docker compose exec -e WP_SMOKE_USER -e WP_SMOKE_PASSWORD api \
|
||||
# From inside the api container (has AUTH_SECRET_KEY and DATABASE_URL; hits
|
||||
# FastAPI directly):
|
||||
docker compose exec -e WP_SMOKE_USER api \
|
||||
python /app/server/smoketest.py http://localhost:8000
|
||||
|
||||
# Local dev, against the app you're running yourself:
|
||||
export AUTH_SECRET_KEY=... DATABASE_URL=... WP_SMOKE_USER=smoketest
|
||||
python3 server/smoketest.py http://localhost:8000
|
||||
|
||||
# Self-signed / internal TLS cert on the HTTP side? skip verification:
|
||||
python3 server/smoketest.py https://wp-suite.company.local --insecure
|
||||
|
||||
# Leave the demo project in the database so you can open it in the UI:
|
||||
python3 server/smoketest.py https://wp-suite.company.local --keep
|
||||
python3 server/smoketest.py http://localhost:8000 --keep
|
||||
|
||||
The base URL is the SITE root (no /api). Default: http://localhost:8000
|
||||
|
||||
Exit codes: 0 = all checks passed · 1 = one or more checks failed · 2 = the run
|
||||
could not start (unreachable host, missing or rejected credentials). 2 is kept
|
||||
distinct on purpose: "I could not test this" is not the same answer as "this is
|
||||
broken", and conflating them is what made an unauthenticated version of this
|
||||
script report a wall of failures against a perfectly healthy stack.
|
||||
could not start (unreachable host, missing credentials, or no account by that
|
||||
username). 2 is kept distinct on purpose: "I could not test this" is not the
|
||||
same answer as "this is broken", and conflating them is what made an
|
||||
unauthenticated version of this script report a wall of failures against a
|
||||
perfectly healthy stack.
|
||||
"""
|
||||
import argparse
|
||||
import http.cookiejar
|
||||
@@ -53,6 +63,12 @@ import ssl
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# So `from server import auth, models` / `from server.db import SessionLocal` resolve
|
||||
# when this file is run directly (`python3 server/smoketest.py`) rather than as
|
||||
# `python -m server.smoketest` — same reasoning as the sys.path lines in tests/*.py.
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
# ── tiny colored reporter ─────────────────────────────────────────────────────
|
||||
_PASS, _FAIL = [], []
|
||||
@@ -66,19 +82,40 @@ def check(name, cond, detail=""):
|
||||
|
||||
BASE = ""
|
||||
CTX = None
|
||||
# One opener for the whole run, carrying the cookie jar that holds the session
|
||||
# issued by /api/auth/login. urlopen() has no cookie support, which is why the
|
||||
# session used to be dropped on the floor and every data route answered 401.
|
||||
# One opener for the whole run, carrying the cookie jar that holds the session.
|
||||
# urlopen() has no cookie support, which is why the session used to be dropped on
|
||||
# the floor and every data route answered 401.
|
||||
OPENER = None
|
||||
COOKIE_JAR = None
|
||||
|
||||
|
||||
def build_opener(ctx=None):
|
||||
handlers = [urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar())]
|
||||
global COOKIE_JAR
|
||||
COOKIE_JAR = http.cookiejar.CookieJar()
|
||||
handlers = [urllib.request.HTTPCookieProcessor(COOKIE_JAR)]
|
||||
if ctx is not None:
|
||||
handlers.append(urllib.request.HTTPSHandler(context=ctx))
|
||||
return urllib.request.build_opener(*handlers)
|
||||
|
||||
|
||||
def seed_session_cookie(token: str, base: str) -> None:
|
||||
"""Put a minted session into the jar directly, the same shape a Set-Cookie
|
||||
response from the old /api/auth/login would have produced — so the logout
|
||||
check below (which relies on the jar honoring logout()'s Set-Cookie that
|
||||
expires it) keeps working unchanged. `base` is explicit rather than read off
|
||||
this module's own BASE global, so seed_demo.py (which imports this function
|
||||
but has its own BASE) seeds the cookie for the host it's actually targeting."""
|
||||
host = urlparse(base).hostname or "localhost"
|
||||
COOKIE_JAR.set_cookie(http.cookiejar.Cookie(
|
||||
version=0, name="wp_session", value=token,
|
||||
port=None, port_specified=False,
|
||||
domain=host, domain_specified=True, domain_initial_dot=False,
|
||||
path="/", path_specified=True,
|
||||
secure=False, expires=None, discard=True,
|
||||
comment=None, comment_url=None, rest={"HttpOnly": None},
|
||||
))
|
||||
|
||||
|
||||
def call(method, path, body=None):
|
||||
"""Returns (status_code, parsed_body). Never raises on HTTP status."""
|
||||
url = BASE + path
|
||||
@@ -116,10 +153,7 @@ def main():
|
||||
ap.add_argument("--insecure", action="store_true", help="skip TLS verification")
|
||||
ap.add_argument("--keep", action="store_true", help="keep the demo project (don't delete)")
|
||||
ap.add_argument("--user", default=os.getenv("WP_SMOKE_USER", ""),
|
||||
help="account to sign in as (default: $WP_SMOKE_USER). Use an admin account.")
|
||||
ap.add_argument("--password", default=os.getenv("WP_SMOKE_PASSWORD", ""),
|
||||
help="its password (default: $WP_SMOKE_PASSWORD — preferred, "
|
||||
"so it stays out of shell history)")
|
||||
help="existing account to sign in as (default: $WP_SMOKE_USER). Use an admin account.")
|
||||
args = ap.parse_args()
|
||||
BASE = args.base_url.rstrip("/")
|
||||
if args.insecure:
|
||||
@@ -128,17 +162,14 @@ def main():
|
||||
|
||||
print(f"\nWork Package Suite — API smoke test\nTarget: {BASE}\n")
|
||||
|
||||
# Refuse to start without credentials rather than running headlong into 401s.
|
||||
if not args.user or not args.password:
|
||||
missing = " and ".join(
|
||||
n for n, v in (("WP_SMOKE_USER", args.user), ("WP_SMOKE_PASSWORD", args.password)) if not v)
|
||||
# Refuse to start without a username rather than running headlong into 401s.
|
||||
if not args.user:
|
||||
return abort(
|
||||
f"no credentials — {missing} not set.",
|
||||
"no account — $WP_SMOKE_USER not set.",
|
||||
" Every /api/ route except /api/health needs a session, so there is nothing\n"
|
||||
" meaningful to test without one. Set them and re-run:\n\n"
|
||||
" export WP_SMOKE_USER=<admin-account>\n"
|
||||
" export WP_SMOKE_PASSWORD='…'\n\n"
|
||||
" Or pass --user/--password. Use an admin account: the run creates a project\n"
|
||||
" meaningful to test without one. Set it and re-run:\n\n"
|
||||
" export WP_SMOKE_USER=<admin-account>\n\n"
|
||||
" Or pass --user. Use an admin account: the run creates a project\n"
|
||||
" and deletes it again, and the delete needs Project Admin on it.")
|
||||
|
||||
project_id = None
|
||||
@@ -157,21 +188,29 @@ def main():
|
||||
check("health endpoint returns ok", st == 200 and isinstance(body, dict) and body.get("ok") is True,
|
||||
f"status={st} body={body}")
|
||||
|
||||
# 2) Sign in. The cookie the response sets is held by OPENER's jar and rides
|
||||
# every request after this one.
|
||||
st, body = call("POST", "/api/auth/login",
|
||||
{"username": args.user, "password": args.password})
|
||||
if st != 200:
|
||||
detail = body.get("detail") if isinstance(body, dict) else body
|
||||
hint = (" The account may be locked: the API locks an account for a while after\n"
|
||||
" a few consecutive failures (AUTH_MAX_ATTEMPTS / AUTH_LOCKOUT_MINUTES),\n"
|
||||
" so re-running with the wrong password makes this worse, not better.\n"
|
||||
" Check the password, then wait out the lockout window."
|
||||
if st in (401, 403, 423, 429) else
|
||||
" Unexpected status from the login endpoint — check the API logs.")
|
||||
return abort(f"could not sign in as '{args.user}' (HTTP {st}): {detail}", hint)
|
||||
# 2) "Sign in" — mint a session directly (see AUTHENTICATION above) and seed
|
||||
# it into OPENER's jar, so it rides every request after this one exactly the
|
||||
# way a real Set-Cookie response would have.
|
||||
try:
|
||||
from server import auth as srv_auth
|
||||
from server.db import SessionLocal
|
||||
except ImportError as e:
|
||||
return abort(f"cannot import the server package to mint a session: {e}",
|
||||
" This script now needs to run where server/ is importable and\n"
|
||||
" AUTH_SECRET_KEY / DATABASE_URL match the target server's — see\n"
|
||||
" AUTHENTICATION above.")
|
||||
with SessionLocal() as db:
|
||||
user = srv_auth.find_user(db, args.user)
|
||||
if not user:
|
||||
return abort(f"no account named '{args.user}'.",
|
||||
" This script signs in as an existing account, it doesn't create one —\n"
|
||||
" sign in through Okta once first, or create it from the admin console.")
|
||||
if not user.is_active:
|
||||
return abort(f"'{args.user}' is disabled.", "")
|
||||
token = srv_auth.create_token(user)
|
||||
seed_session_cookie(token, BASE)
|
||||
logged_in = True
|
||||
check("login issues a session", st == 200)
|
||||
check("session cookie seeded", bool(token))
|
||||
|
||||
# 3) Prove the session actually travels — this is the check whose absence let
|
||||
# an unauthenticated version of this script look like a broken stack.
|
||||
|
||||
Reference in New Issue
Block a user