diff --git a/html/auth-guard.js b/html/auth-guard.js index 6a991ab..6d55683 100644 --- a/html/auth-guard.js +++ b/html/auth-guard.js @@ -60,62 +60,10 @@ .then(function () { window.location.replace('login.html'); }); }; - // Change-password dialog (uses POST /api/auth/password, which requires the - // current password). Available from the top-right pill on any page. - window.wpChangePassword = function () { - if (document.getElementById('wp-pw-modal')) return; - var ov = document.createElement('div'); - ov.id = 'wp-pw-modal'; - ov.style.cssText = 'position:fixed;inset:0;background:rgba(20,30,50,.5);display:flex;align-items:center;' + - 'justify-content:center;z-index:10002;padding:20px;font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;'; - var inp = 'width:100%;padding:9px 10px;margin-bottom:12px;border:1px solid var(--cds-border-strong);border-radius:4px;font-size:14px;'; - var lbl = 'display:block;font-size:12px;color:var(--cds-text-secondary);margin-bottom:4px;'; - ov.innerHTML = - '
' + - '
Change password
' + - '
' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '
' + - '
' + - '' + - '' + - '
' + - '
'; - function close() { var m = document.getElementById('wp-pw-modal'); if (m) m.remove(); } - function msg(text, ok) { - var el = document.getElementById('wp-pw-msg'); - el.style.display = 'block'; el.textContent = text; - el.style.background = ok ? 'var(--wp-status-success-bg)' : 'var(--wp-status-error-bg)'; el.style.color = ok ? 'var(--wp-status-success-text)' : 'var(--cds-support-error)'; - } - ov.addEventListener('click', function (e) { if (e.target === ov) close(); }); - document.body.appendChild(ov); - document.getElementById('wp-pw-cancel').onclick = close; - document.getElementById('wp-pw-cur').focus(); - document.getElementById('wp-pw-save').onclick = function () { - var cur = document.getElementById('wp-pw-cur').value; - var n1 = document.getElementById('wp-pw-new').value; - var n2 = document.getElementById('wp-pw-new2').value; - if (!cur || !n1) { msg('Please fill in every field.', false); return; } - if (n1.length < 12) { msg('New password must be at least 12 characters.', false); return; } - if (n1 !== n2) { msg('New passwords do not match.', false); return; } - fetch('/api/auth/password', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ current_password: cur, new_password: n1 }) - }) - .then(function (r) { return r.json().catch(function () { return null; }).then(function (j) { return { ok: r.ok, status: r.status, j: j }; }); }) - .then(function (res) { - if (res.ok) { msg('Password updated.', true); setTimeout(close, 1200); } - else { msg((res.j && res.j.detail) || ('Could not update (HTTP ' + res.status + ').'), false); } - }) - .catch(function () { msg('Could not reach the server.', false); }); - }; - }; + // window.wpChangePassword used to open a change-password dialog here. Removed in + // T10.4 (D15/D16): there is no local password to change anymore — identity is + // Okta's job. The "Password" item that called this is gone from wp-sidenav.js + // too. // ── permissions helpers ──────────────────────────────────────────────────── // The server enforces all of this; these are for hiding controls the signed-in @@ -171,7 +119,8 @@ // Admin, Users and Sign out from the navigation drawer, and being one unbreakable // 412px run with an inline white-space:nowrap, it was what clipped the bar at 390px // and cut "Sign out" in half — F2. wp-sidenav.js now carries all of it, including - // the two items that were only here: Language & time, and Password. + // the item that was only here: Language & time. (Password was the other one; T10.4 + // removed it along with the rest of local auth — D15/D16.) // // Nothing replaces it. Every signed-in page mounts the drawer, so there is no page // left that would need a floating fallback pill. diff --git a/html/users.html b/html/users.html index 1e9fa2b..1a4f423 100644 --- a/html/users.html +++ b/html/users.html @@ -26,9 +26,6 @@ room for "Assistant Project Manager" without pushing Actions off screen. */ #users-table table td:nth-child(3){ max-width:230px; overflow:hidden; text-overflow:ellipsis; } #users-banner:not(:empty), #scope-banner:not(:empty){ margin-bottom:var(--s3); } - /* The create form is a lot of fields; give the password one room to breathe and - let the project picker take a full row of its own. */ - #nu-password{ flex:1 1 200px; } #nu-projects{ margin-top:var(--s2); } #nu-projects .pickrow{ padding:var(--s1) var(--s1); } /* A manager with one project doesn't need a scrolling picker; a manager with @@ -83,12 +80,12 @@

Add a user

- + -
diff --git a/html/users.js b/html/users.js index 2a32c77..20ecb3e 100644 --- a/html/users.js +++ b/html/users.js @@ -183,11 +183,9 @@ function managerRow(u){ : projRoleReadonly(u, can, why); const actions = []; - if(can && !me) actions.push(''); if(can && !me) actions.push(''); if(can && !me) actions.push(''); - if(me) actions.push(''); if(!can && !me) actions.push('read-only'); return ''+ @@ -254,19 +252,6 @@ function projAccessCell(u){ // ── row actions ─────────────────────────────────────────────────────────────── // Each one reloads on failure so a control can never sit there showing a value the // server refused. -async function resetPw(id, username){ - // The min-12 rule was stated in the prompt label and enforced only by the - // server round-trip; the kit's validate() answers AT the input instead. - const pw = await wpPromptDialog({title:'Reset password', - message:'Set a new password for "'+username+'". Their existing sessions are signed out.', - label:'New password (min 12 characters)', - validate:v => (v && v.length >= 12) ? '' : 'At least 12 characters.'}); - if(pw === null) return; - const { status, json } = await api('POST','/api/auth/users/'+id+'/password',{new_password:pw}); - if(status === 200) toast('Password reset for '+username+'. Their existing sessions are signed out.'); - else wpAlertDialog({title:'Reset failed', message:'Could not reset the password: '+apiError(status, json)}); -} - async function toggleActive(id, makeActive){ const { status, json } = await api('POST','/api/auth/users/'+id+'/active',{is_active:makeActive}); if(status === 200) loadUsers(); @@ -344,25 +329,27 @@ function renderCreateForm(){ async function createUser(){ const msg = document.getElementById('users-create-msg'); const val = id => (document.getElementById(id)||{}).value || ''; + // The username entered here MUST match what Okta's identity claim will send for + // this person exactly — this creates the account ahead of their first sign-in, + // and that's how a later Okta sign-in finds this row instead of provisioning a + // second one. See create_user()'s docstring in server/app.py. const username = val('nu-username').trim(); - const password = val('nu-password'); const project_ids = [...document.querySelectorAll('#nu-project-list input[type=checkbox]:checked')] .map(c => c.value); const say = (color, text) => { msg.style.color = color; msg.textContent = text; }; if(!username){ say('var(--red)','Username is required.'); return; } - if(password.length < 12){ say('var(--red)','Password must be at least 12 characters.'); return; } if(_scope.scope !== 'all' && !project_ids.length){ say('var(--red)','Pick at least one project — you administer users per project.'); return; } say('var(--muted)','Creating…'); const { status, json } = await api('POST','/api/auth/users',{ - username, password, project_ids, + username, project_ids, full_name: val('nu-fullname').trim(), email: val('nu-email').trim(), role: val('nu-role'), project_role: val('nu-project-role'), }); if(status === 200){ say('var(--green)','✓ Created '+username+'.'); - ['nu-username','nu-fullname','nu-email','nu-password'].forEach(id => document.getElementById(id).value = ''); + ['nu-username','nu-fullname','nu-email'].forEach(id => document.getElementById(id).value = ''); loadUsers(); } else { say('var(--red)','✕ '+apiError(status, json, 'Could not create the account')); diff --git a/html/wp-sidenav.js b/html/wp-sidenav.js index 3ca490e..aa38868 100644 --- a/html/wp-sidenav.js +++ b/html/wp-sidenav.js @@ -53,7 +53,6 @@ { section: 'Account' }, { action: 'wpPreferences', icon: '◷', label: 'Language & time', sub: 'Dates, numbers and time zone' }, - { action: 'wpChangePassword', icon: '⚿', label: 'Password', sub: 'Change your password' }, ]; function esc(v) { diff --git a/server/alembic/versions/1d60a608bb51_drop_local_password.py b/server/alembic/versions/1d60a608bb51_drop_local_password.py new file mode 100644 index 0000000..5a5d0de --- /dev/null +++ b/server/alembic/versions/1d60a608bb51_drop_local_password.py @@ -0,0 +1,29 @@ +"""drop local password (T10.4, wave 10 / D15 / D16) + +Real deletion, not a toggle: no local password exists anymore, only Okta OIDC. +`password_hash` was NOT NULL at the database level since the baseline schema, so +downgrade re-adds it with server_default='' rather than leaving existing rows +without a value — the same pattern used for the locale/timezone drop-precedent +columns, applied in reverse. + +Revision ID: 1d60a608bb51 +Revises: a1b8c6d4e2f9 +Create Date: 2026-09-03 00:00:00.000000 +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '1d60a608bb51' +down_revision = 'a1b8c6d4e2f9' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.drop_column('users', 'password_hash') + + +def downgrade() -> None: + op.add_column('users', sa.Column('password_hash', sa.String(length=200), nullable=False, server_default='')) diff --git a/server/app.py b/server/app.py index 58c1334..c1c4e6b 100644 --- a/server/app.py +++ b/server/app.py @@ -13,8 +13,6 @@ import logging import os import re import uuid -from datetime import timedelta, timezone -from time import monotonic from typing import Any, Optional from urllib.parse import urlparse @@ -609,14 +607,8 @@ def health(): # ── Authentication ───────────────────────────────────────────────────────────── -class LoginIn(BaseModel): - username: str - password: str - - class NewUserIn(BaseModel): username: str - password: str full_name: str = "" email: str = "" role: str = auth.ROLE_PROJECT_USER # permissions role — see auth.ROLES @@ -638,24 +630,6 @@ class PreferencesIn(BaseModel): timezone: Optional[str] = None -class ForgotPasswordIn(BaseModel): - username: str = "" # username or email - - -class ResetPasswordIn(BaseModel): - token: str - new_password: str - - -class PasswordChangeIn(BaseModel): - current_password: str - new_password: str - - -class AdminPasswordIn(BaseModel): - new_password: str - - class ActiveIn(BaseModel): is_active: bool @@ -678,47 +652,6 @@ class AutoAddIn(BaseModel): role: str = "" -LOGIN_MAX_ATTEMPTS = int(os.getenv("AUTH_MAX_ATTEMPTS", "5")) -LOGIN_LOCKOUT_MINUTES = int(os.getenv("AUTH_LOCKOUT_MINUTES", "15")) - - -@app.post("/api/auth/login") -def login(body: LoginIn, request: Request, response: Response, db: Session = Depends(get_db)): - """Verify credentials and, on success, set the HttpOnly session cookie. - Throttles online password guessing: after LOGIN_MAX_ATTEMPTS consecutive - failures an account is locked for LOGIN_LOCKOUT_MINUTES.""" - user = auth.find_user(db, body.username) - now = models.utcnow() - # Always run the hash comparison first — even for missing or locked accounts — - # so response timing doesn't leak which usernames exist. verify_password - # tolerates an empty hash. - valid = auth.verify_password(body.password, user.password_hash if user else "") - locked = user.locked_until if user else None - if locked is not None and locked.tzinfo is None: - locked = locked.replace(tzinfo=timezone.utc) # SQLite returns naive datetimes; normalize to UTC - if locked is not None and locked > now: - raise HTTPException(status_code=429, detail="Too many failed attempts. Try again later.") - if not user or not valid: - if user: - user.failed_attempts = (user.failed_attempts or 0) + 1 - if user.failed_attempts >= LOGIN_MAX_ATTEMPTS: - user.locked_until = now + timedelta(minutes=LOGIN_LOCKOUT_MINUTES) - user.failed_attempts = 0 - log_event(db, user.username, "login_locked", "user", user.id, summary=user.username, - detail={"minutes": LOGIN_LOCKOUT_MINUTES}) - db.commit() - raise HTTPException(status_code=401, detail="Invalid username or password") - if not user.is_active: - raise HTTPException(status_code=403, detail="Account is disabled") - user.failed_attempts = 0 - user.locked_until = None - user.last_login_at = now - db.commit() - token = auth.create_token(user) - auth.set_session_cookie(response, request, token) - return {"user": user.to_dict()} - - @app.post("/api/auth/logout") def logout(response: Response): auth.clear_session_cookie(response) @@ -762,14 +695,13 @@ async def okta_callback(request: Request, db: Session = Depends(get_db)): # at all — this app still decides what a first-time sign-in may do. A new # account gets the lowest-privilege role and no project membership; an admin # or project super user grants access afterward, same as any account created - # by hand today (create_user() above). No password is ever set — Okta is the - # only credential (D15's "no stored password"). + # by hand today (create_user() above). No password field exists at all — + # Okta is the only credential (D15/D16, real deletion as of T10.4). user = models.User( id=gen_id("user"), username=identity, email=(claims.get("email") or "").strip(), full_name=(claims.get("name") or "").strip(), - password_hash="", role=auth.ROLE_PROJECT_USER, ) db.add(user) @@ -794,113 +726,6 @@ async def okta_callback(request: Request, db: Session = Depends(get_db)): return redirect -# ── Self-service password reset (needs email switched on) ────────────────────── -RESET_COOLDOWN_SECONDS = int(os.getenv("AUTH_RESET_COOLDOWN_SECONDS", "120")) -# In-process throttle: one reset mail per (account, client) per cooldown. Enough to -# stop someone using the form to spam a colleague's inbox. Per-worker and lost on -# restart — deliberately simple; the token expiry is the real control. -_reset_last: dict[str, float] = {} - - -def _reset_throttled(request: Request, username: str) -> bool: - now = monotonic() - key = f"{(username or '').strip().lower()}|{request.client.host if request.client else ''}" - prev = _reset_last.get(key) - if prev is not None and (now - prev) < RESET_COOLDOWN_SECONDS: - return True - _reset_last[key] = now - if len(_reset_last) > 5000: # bound the dict on a long-lived worker - cutoff = now - RESET_COOLDOWN_SECONDS - for k in [k for k, t in _reset_last.items() if t < cutoff]: - _reset_last.pop(k, None) - return False - - -def reset_body(user: "models.User", link: str, minutes: int) -> str: - # No account detail beyond the username, and no customer data — same rule as - # the assignment mail. The link is the only sensitive thing in here. - who = user.full_name or user.username - return ( - f"Hi {who},\n\n" - f"A password reset was requested for your Work Package Suite account " - f"({user.username}).\n\n" - f"Set a new password:\n{link}\n\n" - f"The link expires in {minutes} minutes and can only be used once. " - f"If you didn't request this, you can ignore this email — your current " - f"password still works.\n" - ) - - -@app.get("/api/auth/reset-available") -def reset_available(db: Session = Depends(get_db)): - """Whether the login page should offer 'Forgot password'. Self-service reset - depends entirely on outbound email, so it's off unless email is enabled AND - SMTP is configured — otherwise the only route is an admin reset.""" - s = notify.get_settings(db) - return {"enabled": bool(s.get("email_enabled")) and notify.smtp_ready(s)} - - -@app.post("/api/auth/forgot-password") -def forgot_password(body: ForgotPasswordIn, request: Request, db: Session = Depends(get_db)): - """Email a reset link. Always returns the same 200 response whether or not the - account exists — this endpoint is unauthenticated, so it must not become a - username/email oracle. Failures are recorded in the audit log instead.""" - s = notify.get_settings(db) - if not (s.get("email_enabled") and notify.smtp_ready(s)): - raise HTTPException( - status_code=503, - detail="Password reset by email isn't available. Ask an administrator to reset it for you.", - ) - if _reset_throttled(request, body.username): - # Same shape as the success response — no oracle, no mail bomb. - return {"ok": True, "message": "If that account exists, a reset link is on its way."} - user = auth.find_user(db, body.username) - if user and user.is_active and user.email: - base = (s.get("app_base_url") or "").rstrip("/") - token = auth.create_reset_token(user) - link = f"{base}/login.html?reset={token}" if base else f"/login.html?reset={token}" - sent = notify.send_now( - db, user.email, - "Work Package Suite — reset your password", - reset_body(user, link, auth.RESET_MINUTES), - ) - log_event(db, user.username, "password_reset_requested", "user", user.id, - summary=user.username, detail={"emailed": bool(sent)}) - db.commit() - else: - # Log the miss for the admin's benefit; the caller can't tell the difference. - log_event(db, "(anonymous)", "password_reset_miss", "user", "", - summary=(body.username or "")[:200], - detail={"reason": "no account, inactive, or no email on file"}) - db.commit() - return {"ok": True, "message": "If that account exists, a reset link is on its way."} - - -@app.post("/api/auth/reset-password") -def reset_password(body: ResetPasswordIn, db: Session = Depends(get_db)): - """Complete a reset using the emailed token. The token carries the user's - token_version, and finishing a reset bumps it — so the link is single-use and - every existing session for that account is signed out.""" - claims = auth.decode_reset_token(body.token or "") - if not claims: - raise HTTPException(status_code=400, detail="This reset link is invalid or has expired. Request a new one.") - user = db.get(models.User, claims.get("sub")) - if not user or not user.is_active: - raise HTTPException(status_code=400, detail="This reset link is no longer valid.") - if (claims.get("ver", 0) or 0) != (user.token_version or 0): - raise HTTPException(status_code=400, detail="This reset link has already been used. Request a new one.") - problem = auth.password_problem(body.new_password, user.username, user.email) - if problem: - raise HTTPException(status_code=400, detail=problem) - user.password_hash = auth.hash_password(body.new_password) - user.token_version = (user.token_version or 0) + 1 # burns the link + all sessions - # A completed reset also clears any login lockout — the person has proven - # control of the mailbox, so there's nothing left to throttle. - user.failed_attempts = 0 - user.locked_until = None - log_event(db, user.username, "password_reset", "user", user.id, summary=user.username) - db.commit() - return {"ok": True} @app.get("/api/auth/me") @@ -956,22 +781,6 @@ def set_preferences(body: PreferencesIn, user: models.User = Depends(auth.get_cu return {"user": user.to_dict()} -@app.post("/api/auth/password") -def change_password(body: PasswordChangeIn, request: Request, response: Response, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)): - if not auth.verify_password(body.current_password, user.password_hash): - raise HTTPException(status_code=400, detail="Current password is incorrect") - problem = auth.password_problem(body.new_password, user.username, user.email) - if problem: - raise HTTPException(status_code=400, detail=problem) - user.password_hash = auth.hash_password(body.new_password) - user.token_version = (user.token_version or 0) + 1 # invalidate all OTHER existing sessions - db.commit() - db.refresh(user) - # Keep this session logged in by re-issuing a cookie carrying the new version. - auth.set_session_cookie(response, request, auth.create_token(user)) - return {"ok": True} - - # ── User administration ───────────────────────────────────────────────────────── # Two kinds of caller reach these routes: an app admin, who manages every account, # and a Project Super User, who manages the accounts on the projects they administer. @@ -1059,9 +868,14 @@ def user_scope(user: models.User = Depends(auth.get_current_user), db: Session = @app.post("/api/auth/users") def create_user(body: NewUserIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)): - problem = auth.password_problem(body.password, body.username, body.email) - if problem: - raise HTTPException(status_code=400, detail=problem) + """Create an account ahead of its first Okta sign-in — e.g. to put it on + projects or hand it a role before anyone has ever signed in as them. + + `body.username` MUST match what Okta's identity claim will send for this + person exactly (see OKTA_IDENTITY_CLAIM, server/okta_auth.py) — auth.find_user() + is how a later Okta sign-in locates this row (T10.3). A mismatch doesn't + fail loudly; it silently produces a second, JIT-provisioned account instead + of signing this person into the one just created here.""" allowed = grantable_roles(actor) if body.role not in allowed: raise HTTPException(status_code=400, detail=f"role must be one of {', '.join(allowed)}") @@ -1093,7 +907,6 @@ def create_user(body: NewUserIn, actor: models.User = Depends(require_user_manag username=body.username.strip(), email=body.email.strip(), full_name=body.full_name.strip(), - password_hash=auth.hash_password(body.password), role=body.role, project_role=body.project_role.strip()[:120], ) @@ -1120,24 +933,6 @@ def create_user(body: NewUserIn, actor: models.User = Depends(require_user_manag return directory_entry(db, u, actor) -@app.post("/api/auth/users/{user_id}/password") -def admin_reset_password(user_id: str, body: AdminPasswordIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)): - u = load_target_user(db, user_id) - require_see_user(db, actor, u) - require_manage_user(db, actor, u) - problem = auth.password_problem(body.new_password, u.username, u.email) - if problem: - raise HTTPException(status_code=400, detail=problem) - u.password_hash = auth.hash_password(body.new_password) - u.token_version = (u.token_version or 0) + 1 # revoke the user's existing sessions - # An administrative password reset was the one user-account change that left no - # trace; it is the most impersonation-adjacent thing on this page, so it logs. - log_event(db, actor, "password_reset", "user", u.id, summary=u.username, - detail={"by": "administrator"}) - db.commit() - return {"ok": True} - - @app.post("/api/auth/users/{user_id}/active") def set_user_active(user_id: str, body: ActiveIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)): u = load_target_user(db, user_id) diff --git a/server/auth.py b/server/auth.py index dac4e8d..17b9bd2 100644 --- a/server/auth.py +++ b/server/auth.py @@ -1,10 +1,11 @@ """Authentication for the Work Package Suite. -A self-contained username/password login. Passwords are stored only as bcrypt -hashes; a successful login issues a signed JWT that rides in an HttpOnly cookie -(`wp_session`). Because the token is signed and self-validating, there is no -server-side session store — every request is checked by verifying the cookie's -signature and expiry (see `auth_gate` and `get_current_user`). +Identity is confirmed by Okta (OIDC authorization-code flow, see server/okta_auth.py +and the routes in server/app.py); there is no local password anywhere in this app +(D15, D16 — T10.4 removed the last of it). A successful sign-in issues a signed JWT +that rides in an HttpOnly cookie (`wp_session`). Because the token is signed and +self-validating, there is no server-side session store — every request is checked by +verifying the cookie's signature and expiry (see `auth_gate` and `get_current_user`). Security model: • The real boundary is `auth_gate` (middleware in app.py): every /api/ data @@ -17,7 +18,9 @@ Security model: warning and invalidates every session on restart) so dev still works. Permissions roles (`User.role`) — distinct from a person's job function on the -project, which lives in `User.project_role` and grants nothing: +project, which lives in `User.project_role` and grants nothing. Decided entirely +locally: Okta gates WHO can authenticate at all, this app decides what an +authenticated account may do — see D15/D16. • admin application administrator: user administration, app settings, and implicit access to every project. • project_super_user @@ -30,15 +33,13 @@ project, which lives in `User.project_role` and grants nothing: modify a SOP after it has been completed, and delete projects. • project_user normal member: creates and edits work packages, authors a SOP up to completion. May NOT delete WPs or change a completed SOP. + Also where Okta JIT provisioning (T10.3) lands a brand-new + account — the lowest-privilege role, promoted locally from + there by an admin (see manage_users.py for the bootstrap case). The user-administration SCOPE of a super user is worked out in server/app.py (`managed_project_ids`, `manage_user_problem`), because it depends on project membership rows — this module only decides which roles carry the power at all. - -Password reset: a short-lived signed token (see `create_reset_token`) is emailed -to the account's address. It is single-use by construction — it embeds the user's -`token_version`, which is bumped when the password changes, so a used or -superseded link stops validating. """ import os import secrets @@ -46,7 +47,6 @@ import logging from datetime import datetime, timedelta, timezone from typing import Optional -import bcrypt import jwt from fastapi import Depends, HTTPException, Request, Response, status from sqlalchemy import select, func @@ -59,10 +59,8 @@ log = logging.getLogger("wpsuite.auth") COOKIE_NAME = "wp_session" JWT_ALG = "HS256" -# How long a login lasts before the user must sign in again. +# How long a session lasts before the person must sign in again. SESSION_HOURS = int(os.getenv("AUTH_SESSION_HOURS", "12")) -# How long an emailed password-reset link stays valid. -RESET_MINUTES = int(os.getenv("AUTH_RESET_MINUTES", "60")) # ── permissions roles ───────────────────────────────────────────────────────── ROLE_ADMIN = "admin" @@ -121,30 +119,7 @@ def is_project_admin(user: "models.User") -> bool: # same question used to exist here and silently disagreed with the scoped one, which # locked per-project super users out of the routes they were entitled to. -# Password policy (shared by the API and the CLI). -MIN_PASSWORD_LEN = int(os.getenv("AUTH_MIN_PASSWORD_LEN", "12")) -_COMMON_PASSWORDS = { - "password", "password1", "password123", "passw0rd", "12345678", "123456789", - "1234567890", "qwerty123", "letmein123", "changeme", "admin123", "welcome123", - "iloveyou1", "abc12345", "qwertyuiop", -} - - -def password_problem(pw: str, username: str = "", email: str = "") -> Optional[str]: - """Return a human-readable reason the password is unacceptable, or None if OK. - Shared by the API endpoints and the CLI so the policy is enforced everywhere.""" - if len(pw) < MIN_PASSWORD_LEN: - return f"Password must be at least {MIN_PASSWORD_LEN} characters." - low = pw.lower() - if username and low == username.strip().lower(): - return "Password must not be the same as the username." - if email and low == email.strip().lower(): - return "Password must not be the same as the email." - if low in _COMMON_PASSWORDS: - return "That password is too common — choose something less guessable." - return None - -# Paths under /api that do NOT require a session (login itself, health, docs). +# Paths under /api that do NOT require a session (the Okta routes themselves, health, docs). _EXEMPT_PREFIXES = ("/api/auth/",) _EXEMPT_EXACT = { "/api/health", @@ -184,22 +159,6 @@ def _load_secret() -> str: SECRET_KEY = _load_secret() -# ── password hashing ────────────────────────────────────────────────────────── -def hash_password(plain: str) -> str: - # bcrypt operates on at most 72 bytes; longer inputs are truncated by the - # algorithm. Encode explicitly so non-ASCII passwords hash consistently. - return bcrypt.hashpw(plain.encode("utf-8")[:72], bcrypt.gensalt()).decode("ascii") - - -def verify_password(plain: str, hashed: str) -> bool: - if not hashed: - return False - try: - return bcrypt.checkpw(plain.encode("utf-8")[:72], hashed.encode("ascii")) - except (ValueError, TypeError): - return False - - # ── tokens ────────────────────────────────────────────────────────────────── def create_token(user: "models.User") -> str: now = datetime.now(timezone.utc) @@ -221,39 +180,15 @@ def decode_token(token: str) -> Optional[dict]: claims = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG]) except jwt.PyJWTError: return None - # A password-reset token must never be usable as a session cookie. + # No route issues a `typ`-carrying token anymore (that was the password-reset + # token, removed in T10.4), but a session token still must never validate as + # one — kept as a defensive check, cheap insurance against a future token type + # riding the same cookie. if claims.get("typ"): return None return claims -def create_reset_token(user: "models.User") -> str: - """Short-lived, single-use token for an emailed password-reset link. - - Single-use falls out of `ver`: completing a reset bumps the user's - token_version, so the link (and any older link) no longer validates.""" - now = datetime.now(timezone.utc) - payload = { - "typ": "pwreset", - "sub": user.id, - "ver": user.token_version or 0, - "iat": now, - "exp": now + timedelta(minutes=RESET_MINUTES), - } - return jwt.encode(payload, SECRET_KEY, algorithm=JWT_ALG) - - -def decode_reset_token(token: str) -> Optional[dict]: - """Claims for a valid, unexpired reset token, else None.""" - try: - claims = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG]) - except jwt.PyJWTError: - return None - if claims.get("typ") != "pwreset": - return None - return claims - - # ── cookie helpers ──────────────────────────────────────────────────────────── def _is_https(request: Request) -> bool: # Behind NGINX, TLS is terminated at the proxy and forwarded as plain HTTP, diff --git a/server/manage_users.py b/server/manage_users.py index 08280b8..79a11d2 100644 --- a/server/manage_users.py +++ b/server/manage_users.py @@ -1,78 +1,59 @@ """Command-line user management for the Work Package Suite. -Use this to create the FIRST admin account (the /api/auth/users endpoint needs an -existing admin, so you have to bootstrap one here), and for occasional account +There is no local password (D15) and no local account creation from here anymore +(D16, T10.4) — accounts are created by signing in through Okta, which JIT- +provisions a row at the lowest-privilege role (see server/okta_auth.py, +server/app.py's okta_callback(), wave-10.md T10.3). This tool's job is narrower +now: change the role on an account that already exists, and do routine account maintenance from a shell on the server. +That narrower job is still how the very first admin gets named (D16): have that +person sign in through Okta once — they land as project_user — then promote them +from here. Promoting an existing row, rather than creating one blind, matters +because it never has to guess the exact string Okta will send as the identity +claim; a hand-typed username that doesn't match it exactly would just produce a +second, orphaned account instead of the one you meant to promote. + Run from the PROJECT ROOT (same place you run uvicorn), so the package imports and .env resolve the same way the API does: - python -m server.manage_users create-admin alice --name "Alice Smith" - python -m server.manage_users create bob --role user --name "Bob Jones" + python -m server.manage_users promote alice --role admin python -m server.manage_users list - python -m server.manage_users reset-password alice python -m server.manage_users disable bob python -m server.manage_users enable bob - -If --password is omitted you'll be prompted (input is hidden). Passwords must be -at least 8 characters. """ import argparse -import getpass import sys -import uuid from .db import SessionLocal, Base, engine from . import models, auth -def _gen_id() -> str: - return f"user_{uuid.uuid4().hex[:12]}" - - -def _prompt_password(provided: str | None, username: str = "") -> str: - pw = provided - if not pw: - pw = getpass.getpass("New password: ") - confirm = getpass.getpass("Confirm password: ") - if pw != confirm: - sys.exit("Passwords do not match.") - problem = auth.password_problem(pw, username) - if problem: - sys.exit(problem) - return pw - - -def cmd_create(args, role: str | None = None) -> None: - role = role or args.role - # 'user' is the pre-roles spelling of 'project_user' and is still accepted so the - # documented one-liners keep working; anything else has to be a current role. +def cmd_promote(args) -> None: + role = args.role + # 'user' is the pre-roles spelling of 'project_user', accepted here so a + # documented one-liner from before this rework keeps working. if role == "user": role = auth.ROLE_PROJECT_USER if role not in auth.ROLES: sys.exit(f"role must be one of {', '.join(auth.ROLES)}") - pw = _prompt_password(getattr(args, "password", None), args.username) with SessionLocal() as db: - if auth.find_user(db, args.username): - sys.exit(f"A user named '{args.username}' already exists.") - u = models.User( - id=_gen_id(), - username=args.username.strip(), - full_name=(args.name or "").strip(), - email=(args.email or "").strip(), - password_hash=auth.hash_password(pw), - role=role, - ) - db.add(u) + u = auth.find_user(db, args.username) + if not u: + sys.exit( + f"No user named '{args.username}'. This promotes an existing account, it " + f"doesn't create one — they need to sign in through Okta at least once first." + ) + u.role = role db.commit() - print(f"Created {role}: {u.username} (id={u.id})") + print(f"{u.username} is now {auth.ROLE_LABELS.get(role, role)}.") def cmd_list(args) -> None: with SessionLocal() as db: rows = db.query(models.User).order_by(models.User.username).all() if not rows: - print("No users yet. Create one with: create-admin ") + print("No users yet. Accounts appear here once someone signs in through Okta.") return print(f"{'USERNAME':<24}{'ROLE':<20}{'ACTIVE':<8}{'NAME'}") for u in rows: @@ -80,17 +61,6 @@ def cmd_list(args) -> None: f"{('yes' if u.is_active else 'no'):<8}{u.full_name}") -def cmd_reset_password(args) -> None: - pw = _prompt_password(getattr(args, "password", None), args.username) - with SessionLocal() as db: - u = auth.find_user(db, args.username) - if not u: - sys.exit(f"No user named '{args.username}'.") - u.password_hash = auth.hash_password(pw) - db.commit() - print(f"Password reset for {u.username}.") - - def _set_active(username: str, active: bool) -> None: with SessionLocal() as db: u = auth.find_user(db, username) @@ -108,39 +78,23 @@ def main() -> None: p = argparse.ArgumentParser(prog="manage_users", description="Work Package Suite user management") sub = p.add_subparsers(dest="cmd", required=True) - def add_create(name, help_): - sp = sub.add_parser(name, help=help_) - sp.add_argument("username") - sp.add_argument("--password", help="set non-interactively (otherwise prompted)") - sp.add_argument("--name", default="", help="full name") - sp.add_argument("--email", default="") - return sp - - add_create("create-admin", "create an admin account") - c = add_create("create", "create an account") - c.add_argument("--role", choices=list(auth.ROLES) + ["user"], default=auth.ROLE_PROJECT_USER, - help="permissions role ('user' is the legacy name for project_user)") + pr = sub.add_parser("promote", help="change an existing account's role (e.g. name the first admin)") + pr.add_argument("username") + pr.add_argument("--role", required=True, choices=list(auth.ROLES) + ["user"], + help="permissions role ('user' is the legacy name for project_user)") sub.add_parser("list", help="list all accounts") - rp = sub.add_parser("reset-password", help="reset a user's password") - rp.add_argument("username") - rp.add_argument("--password", help="set non-interactively (otherwise prompted)") - - dp = sub.add_parser("disable", help="disable an account (blocks login)") + dp = sub.add_parser("disable", help="disable an account (blocks sign-in)") dp.add_argument("username") ep = sub.add_parser("enable", help="re-enable an account") ep.add_argument("username") args = p.parse_args() - if args.cmd == "create-admin": - cmd_create(args, role="admin") - elif args.cmd == "create": - cmd_create(args) + if args.cmd == "promote": + cmd_promote(args) elif args.cmd == "list": cmd_list(args) - elif args.cmd == "reset-password": - cmd_reset_password(args) elif args.cmd == "disable": _set_active(args.username, False) elif args.cmd == "enable": diff --git a/server/models.py b/server/models.py index f841809..05d1b19 100644 --- a/server/models.py +++ b/server/models.py @@ -142,8 +142,10 @@ class WorkPackage(Base): class User(Base): - """A login account. Passwords are never stored in the clear — only a bcrypt - hash (see server/auth.py). `username` is what people sign in with. + """A login account. No password is stored here or anywhere else — identity is + confirmed by Okta (OIDC), this app only decides what the account may do once + Okta has vouched for it (see server/okta_auth.py, server/auth.py, D15/D16). + `username` is what Okta's identity claim resolves to. Two independent notions of "role", deliberately separate: • role the PERMISSIONS role — what the account may do in the app. @@ -159,7 +161,6 @@ class User(Base): username: Mapped[str] = mapped_column(String(120), unique=True, index=True) email: Mapped[str] = mapped_column(String(200), default="") full_name: Mapped[str] = mapped_column(String(200), default="") - password_hash: Mapped[str] = mapped_column(String(200), default="") role: Mapped[str] = mapped_column(String(20), default="project_user") # permissions role # Job function on the project — free text, offered from a suggested list. project_role: Mapped[str] = mapped_column(String(120), default="") diff --git a/server/requirements.txt b/server/requirements.txt index fc4e9a5..adb8568 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -17,8 +17,6 @@ pymssql==2.3.13 # read-only lookups against the Micron asset DB (SQL # MICRON_DB_URL to mssql+pyodbc://…?driver=ODBC+Driver+18+for+SQL+Server pydantic==2.13.4 python-dotenv==1.2.2 -bcrypt==5.0.0 # password hashing — still used until T10.4 removes the local - # password path; do not drop this before that task lands. PyJWT==2.13.0 # signed session tokens starlette==1.3.1 # pinned transitive (cookie / CORS handling — security-relevant) Authlib==1.7.2 # Okta OIDC authorization-code flow (T10.1, wave 10 / D15) diff --git a/server/seed_demo.py b/server/seed_demo.py index 5000b1c..d08a725 100644 --- a/server/seed_demo.py +++ b/server/seed_demo.py @@ -9,15 +9,17 @@ and to have data to inspect. Every /api/ route except /api/health requires a session, so this signs in first and keeps the session cookie for the rest of the run — the same way server/smoketest.py -does, reusing its opener rather than growing a second implementation of it. -Credentials come from the environment so the password never has to appear in a -command line or shell history: +does, reusing its opener AND its session-minting (not a second implementation). + +There is no local password anymore (D15/D16, T10.4) — see smoketest.py's own +AUTHENTICATION section for why and what that means: this needs to run where it can +read the SAME AUTH_SECRET_KEY and reach the SAME database as the server under test, +and the account must already exist (this seeds a project, not a user). export WP_SEED_USER= # or WP_SMOKE_USER, which is reused - export WP_SEED_PASSWORD='…' # or WP_SMOKE_PASSWORD -…or pass --user / --password. Use an admin account: seeding creates a project, and ---clean deletes one, which needs Project Admin on it. +…or pass --user. Use an admin account: seeding creates a project, and --clean +deletes one, which needs Project Admin on it. USAGE python3 server/seed_demo.py https://wp-suite.company.local --insecure @@ -48,16 +50,19 @@ import urllib.error import urllib.request sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +# So `from server import auth, models` / `from server.db import SessionLocal` also +# resolve (needed to mint a session — see AUTHENTICATION above). +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # The session handling is smoketest.py's, imported rather than copied: one cookie -# jar implementation, one login flow, one place to fix. Importing is safe — that -# module does its work under `if __name__ == "__main__"`. -from smoketest import build_opener # noqa: E402 +# jar implementation, one session-minting flow, one place to fix. Importing is +# safe — that module does its work under `if __name__ == "__main__"`. +from smoketest import build_opener, seed_session_cookie # noqa: E402 BASE = "" CTX = None -# Carries the cookie jar holding the session issued by /api/auth/login. This -# script used to call urllib.request.urlopen() directly, which has no cookie +# Carries the cookie jar holding the minted session (see AUTHENTICATION above). +# This script used to call urllib.request.urlopen() directly, which has no cookie # support, so the session was dropped and every data route answered 401 (S13). OPENER = None DEMO_NUMBER = "DEMO-001" # project number prefix used to find/clean demo data @@ -116,29 +121,22 @@ def main(): ap.add_argument("--insecure", action="store_true", help="skip TLS verification") ap.add_argument("--clean", action="store_true", help="delete existing DEMO-* projects and exit") ap.add_argument("--user", default=os.getenv("WP_SEED_USER", "") or os.getenv("WP_SMOKE_USER", ""), - help="account to sign in as (default: $WP_SEED_USER, then $WP_SMOKE_USER). " - "Use an admin account.") - ap.add_argument("--password", - default=os.getenv("WP_SEED_PASSWORD", "") or os.getenv("WP_SMOKE_PASSWORD", ""), - help="its password (default: $WP_SEED_PASSWORD, then $WP_SMOKE_PASSWORD — " - "preferred, so it stays out of shell history)") + help="existing account to sign in as (default: $WP_SEED_USER, then " + "$WP_SMOKE_USER). Use an admin account.") args = ap.parse_args() BASE = args.base_url.rstrip("/") if args.insecure: CTX = ssl.create_default_context(); CTX.check_hostname = False; CTX.verify_mode = ssl.CERT_NONE OPENER = build_opener(CTX) - if not args.user or not args.password: - missing = " and ".join(n for n, v in (("WP_SEED_USER", args.user), - ("WP_SEED_PASSWORD", args.password)) if not v) + if not args.user: return abort( - f"no credentials — {missing} not set.", + "no account — $WP_SEED_USER not set.", " Every /api/ route except /api/health needs a session, so there is nothing\n" - " this can seed without one. Set them and re-run:\n\n" - " export WP_SEED_USER=\n" - " export WP_SEED_PASSWORD='…'\n\n" - " Or pass --user/--password. WP_SMOKE_USER / WP_SMOKE_PASSWORD are accepted\n" - " too, so one set of credentials serves this and smoketest.py.") + " this can seed without one. Set it and re-run:\n\n" + " export WP_SEED_USER=\n\n" + " Or pass --user. WP_SMOKE_USER is accepted too, so one account name serves\n" + " this and smoketest.py.") # health gate try: @@ -148,18 +146,25 @@ def main(): if st != 200: print(f"ABORT: /api/health returned {st}"); return 1 - # 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 a\n" - " few consecutive failures, so retrying with the wrong password makes this\n" - " worse. 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) + # "Sign in" — mint a session directly (see AUTHENTICATION above) and seed it + # into OPENER's jar, so it rides every request after this one. + 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 needs to run where server/ is importable and AUTH_SECRET_KEY /\n" + " DATABASE_URL match the target server's — see 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 signs in as an existing account, it doesn't create one — sign in\n" + " 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 print(f"Signed in as {args.user}.") diff --git a/server/smoketest.py b/server/smoketest.py index ea29323..a9598b7 100644 --- a/server/smoketest.py +++ b/server/smoketest.py @@ -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=\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=\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. diff --git a/tests/browser_check.py b/tests/browser_check.py index 6870898..99c7246 100644 --- a/tests/browser_check.py +++ b/tests/browser_check.py @@ -39,7 +39,6 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import cdp # noqa: E402 -PW = "CorrectHorseBattery9" _PASS, _FAIL = [], [] @@ -86,7 +85,7 @@ def seed(db_path): def mk(username, role): db.add(models.User(id="user_" + username, username=username, email=f"{username}@example.test", full_name=username.title(), - password_hash=auth.hash_password(PW), role=role)) + role=role)) mk("root", auth.ROLE_ADMIN) mk("sue", auth.ROLE_PROJECT_SUPER) # super user on Job A @@ -436,7 +435,10 @@ def main(): finally: if args.keep_server: print(f"\n --keep-server: still up at {base}, database at {db_path}") - print(" Sign in as root / " + PW) + # No local password exists (D15/D16) — there's nothing to type into a login + # form. Set the session cookie directly, the same way this script's own + # fixture does, from the browser console on that origin: + print(f" document.cookie = 'wp_session={tok['root']}; path=/'") else: if server: # Wait for it to actually exit before deleting the database out from diff --git a/tests/console_dialogs_check.py b/tests/console_dialogs_check.py index 346db79..742f611 100644 --- a/tests/console_dialogs_check.py +++ b/tests/console_dialogs_check.py @@ -8,9 +8,17 @@ They now go through `wp-dialog.js` — the T7.9 kit extracted as a shared, self-injecting component (guarded so the creator's inline copy still wins on its own page). -Static half greps the counts; browser half drives the password-reset prompt on -the users console with natives poisoned, and proves validate() answers AT the -input while the server round-trip completes end to end. +Static half greps the counts; browser half drives the users console's dialogs +with natives poisoned and proves they still work end to end. + +Used to drive this via the password-reset prompt specifically, because it was +the one place on this page exercising wp-dialog.js's PROMPT variant (text input ++ client-side validate()) rather than its confirm variant. T10.4 (D15/D16) +removed admin password reset entirely — there is no password to reset anymore +— so that coverage moved with it. The prompt-with-validate() pattern itself is +still exercised, just not on this page: see creator_dialogs_check.py for +wp-creation-app.js's own wpPromptDialog() call sites. If users.html ever grows +a new prompt-style dialog, it belongs back in this file. Boots its own throwaway SQLite + uvicorn + headless browser; run it alone. Exit 0 all passed, 1 a failure, 2 could not run. @@ -91,31 +99,6 @@ def main(): chk("the console booted with a user table", page.eval("!!document.querySelector('table')")) - page.eval("void resetPw('user_pat','pat')") - time.sleep(0.4) - chk("the reset prompt is the kit's modal, open, focused at the input", - page.eval("(() => { const o=document.getElementById('wp-dlg-overlay');" - " return !!o && o.classList.contains('open')" - " && document.activeElement.id==='wp-dlg-input'; })()")) - page.eval("document.getElementById('wp-dlg-input').value='short';" - "document.getElementById('wp-dlg-ok').click()") - chk("a short password is refused AT the input - dialog stays, error says why", - page.eval("(() => { const o=document.getElementById('wp-dlg-overlay');" - " return o.classList.contains('open')" - " && /12 characters/.test(document.getElementById('wp-dlg-err').textContent); })()")) - page.eval("document.getElementById('wp-dlg-input').value='CorrectHorseBattery10';" - "document.getElementById('wp-dlg-ok').click()") - time.sleep(1.2) - chk("a good answer closes the dialog and the server accepts it", - page.eval("!document.getElementById('wp-dlg-overlay').classList.contains('open')")) - chk("...announced through the kit's toast (role=status)", - page.eval("(() => { const t=document.getElementById('toast');" - " return !!t && t.getAttribute('role')==='status'" - " && /Password reset for pat/.test(t.textContent); })()")) - st, _ = api(base, "/api/auth/login", "x", "POST", - {"username": "pat", "password": "CorrectHorseBattery10"}) - chk("...and the new password actually works", st == 200, st) - print("\n3. destroy needs a real yes") page.eval("void deleteUser('user_bob','bob')") time.sleep(0.4) diff --git a/tests/launcher_check.py b/tests/launcher_check.py index 9643091..3628edc 100644 --- a/tests/launcher_check.py +++ b/tests/launcher_check.py @@ -31,7 +31,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import cdp # noqa: E402 -from browser_check import seed, start_server, chk, _PASS, _FAIL, _c, PW # noqa: E402 +from browser_check import seed, start_server, chk, _PASS, _FAIL, _c # noqa: E402 STUB = """ window.__dialogs = []; @@ -55,8 +55,7 @@ def seed_empty(db_path): Base.metadata.create_all(bind=engine) with SessionLocal() as db: db.add(models.User(id="user_new", username="new", email="new@example.test", - full_name="New Starter", password_hash=auth.hash_password(PW), - role=auth.ROLE_ADMIN)) + full_name="New Starter", role=auth.ROLE_ADMIN)) db.commit() return {u.username: auth.create_token(u) for u in db.query(models.User).all()} diff --git a/tests/pipeline_check.py b/tests/pipeline_check.py index cfdb5c4..e1dedb2 100644 --- a/tests/pipeline_check.py +++ b/tests/pipeline_check.py @@ -33,7 +33,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import cdp # noqa: E402 -from browser_check import seed, start_server, chk, _PASS, _FAIL, _c, PW # noqa: E402 +from browser_check import seed, start_server, chk, _PASS, _FAIL, _c # noqa: E402 READY = "!!document.querySelector('#pipeline-strip .pipe-cell, #pipeline-strip .pipe-empty, " \ "#pipeline-strip .pipe-error')" diff --git a/tests/token_check.py b/tests/token_check.py index 83c172b..81fb04b 100644 --- a/tests/token_check.py +++ b/tests/token_check.py @@ -47,7 +47,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import cdp # noqa: E402 -from browser_check import seed, start_server, PW # noqa: E402,F401 +from browser_check import seed, start_server # noqa: E402 ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) HTML = os.path.join(ROOT, "html") diff --git a/tests/url_state_check.py b/tests/url_state_check.py index 436b294..82dcd7f 100644 --- a/tests/url_state_check.py +++ b/tests/url_state_check.py @@ -8,6 +8,10 @@ will rest on. 1. a URL identifying a work package opens that work package 2. the same URL works for a SIGNED-OUT user, via login, landing on the target + — SKIPPED as of T10.4: local login is gone (D15/D16), and the redirect- + through-Okta replacement doesn't exist until T10.5 rebuilds login.html. + Re-test this once that lands. Signing in via a minted token stands in as + setup only, so scenarios 3-6 below still get a signed-in page to run on. 3. refresh preserves project, package, tab and view 4. Back and Forward move through states without a reload or a broken view 5. the URL survives being copied to a second browsing context @@ -27,7 +31,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import cdp # noqa: E402 -from browser_check import seed, start_server, chk, _PASS, _FAIL, _c, PW # noqa: E402 +from browser_check import seed, start_server, chk, _PASS, _FAIL, _c # noqa: E402 def settle(page, seconds=1.4): @@ -169,6 +173,8 @@ def main(): page2.close() print("\n2. the same URL works for a signed-out user, via login") + print(" SKIPPED: local login is gone (D15/D16); the Okta-redirect replacement") + print(" doesn't exist until T10.5. Re-test the next= round trip once it lands.") page.clear_cookies() page.goto(deep) settle(page, 1.6) @@ -177,19 +183,14 @@ def main(): nxt = page.eval("new URLSearchParams(location.search).get('next')||''") chk("...carrying the requested target, package id and all", "wp-creation-index.html" in nxt and "wp=wpA1" in nxt, "next=%r" % nxt) - page.eval("document.getElementById('username').value=%r" % "root") - page.eval("document.getElementById('password').value=%r" % PW) - page.eval("document.querySelector('form').requestSubmit" - "? document.querySelector('form').requestSubmit()" - ": document.querySelector('form').submit()") - for _ in range(40): - if "wp-creation-index.html" in page.eval("location.href"): - break - time.sleep(0.3) - settle(page, 1.2) - chk("signing in continues to the requested page, not the home page", - "wp-creation-index.html" in page.eval("location.href"), - page.eval("location.href")) + # Setup only for scenarios 3-6 below, NOT a re-test of "signing in continues + # to the requested page" — 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 gets `page` to the same signed-in, + # on-target state those later scenarios need, without claiming to have + # exercised the (currently nonexistent) sign-in flow itself. + page.set_cookie("wp_session", tok["root"]) + page.goto(deep) for _ in range(30): if page.eval("!!window.wpCreatorReady"): break