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:
@@ -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 =
|
||||
'<div style="background:var(--cds-layer);color:var(--cds-text-primary);border-radius:10px;max-width:380px;width:100%;box-shadow:0 12px 40px rgba(20,30,50,.3);overflow:hidden;">' +
|
||||
'<div style="padding:14px 18px;border-bottom:1px solid var(--cds-border-subtle);font-weight:700;">Change password</div>' +
|
||||
'<div style="padding:16px 18px;">' +
|
||||
'<div id="wp-pw-msg" style="display:none;font-size:12.5px;padding:8px 10px;border-radius:6px;margin-bottom:12px;"></div>' +
|
||||
'<label style="' + lbl + '">Current password</label>' +
|
||||
'<input id="wp-pw-cur" type="password" autocomplete="current-password" style="' + inp + '">' +
|
||||
'<label style="' + lbl + '">New password (at least 12 characters)</label>' +
|
||||
'<input id="wp-pw-new" type="password" autocomplete="new-password" style="' + inp + '">' +
|
||||
'<label style="' + lbl + '">Confirm new password</label>' +
|
||||
'<input id="wp-pw-new2" type="password" autocomplete="new-password" style="' + inp + 'margin-bottom:0;">' +
|
||||
'</div>' +
|
||||
'<div style="padding:12px 18px;border-top:1px solid var(--cds-border-subtle);display:flex;gap:8px;justify-content:flex-end;">' +
|
||||
'<button type="button" id="wp-pw-cancel" style="padding:8px 14px;border:1px solid var(--cds-border-strong);background:var(--cds-layer);border-radius:6px;cursor:pointer;font-weight:600;">Cancel</button>' +
|
||||
'<button type="button" id="wp-pw-save" style="padding:8px 14px;border:none;background:var(--cds-interactive-01);color:var(--cds-text-on-color);border-radius:6px;cursor:pointer;font-weight:600;">Update password</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
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.
|
||||
|
||||
@@ -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 @@
|
||||
<h2>Add a user</h2>
|
||||
<div class="sub" id="create-sub"></div>
|
||||
<div class="urow">
|
||||
<input id="nu-username" placeholder="Username *" autocomplete="off">
|
||||
<input id="nu-username" placeholder="Username *" autocomplete="off"
|
||||
title="Must exactly match this person's Okta sign-in identity — that's how their first Okta sign-in finds this account instead of creating a second one.">
|
||||
<input id="nu-fullname" placeholder="Full name" autocomplete="off">
|
||||
<input id="nu-email" placeholder="Email" autocomplete="off">
|
||||
<select id="nu-role" title="Permissions — what this account may do"></select>
|
||||
<select id="nu-project-role" title="Job function on the project"></select>
|
||||
<input id="nu-password" type="password" placeholder="Password (min 12)" autocomplete="new-password">
|
||||
</div>
|
||||
<div id="nu-projects">
|
||||
<div class="note" id="nu-projects-label" style="margin-bottom:var(--s1)"></div>
|
||||
|
||||
@@ -183,11 +183,9 @@ function managerRow(u){
|
||||
: projRoleReadonly(u, can, why);
|
||||
|
||||
const actions = [];
|
||||
if(can && !me) actions.push('<button class="mini" onclick="resetPw(\''+uid+'\',\''+uname+'\')">Reset password</button>');
|
||||
if(can && !me) actions.push('<button class="mini" onclick="toggleActive(\''+uid+'\','+(!u.is_active)+')">'+
|
||||
(u.is_active?'Disable':'Enable')+'</button>');
|
||||
if(can && !me) actions.push('<button class="mini danger" onclick="deleteUser(\''+uid+'\',\''+uname+'\')">Delete</button>');
|
||||
if(me) actions.push('<button class="mini" disabled title="Use the Password link in the top bar to change your own">—</button>');
|
||||
if(!can && !me) actions.push('<span class="note" style="margin:0" title="'+uesc(why)+'">read-only</span>');
|
||||
|
||||
return '<tr'+(can||me ? '' : ' class="is-locked"')+'>'+
|
||||
@@ -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'));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
29
server/alembic/versions/1d60a608bb51_drop_local_password.py
Normal file
29
server/alembic/versions/1d60a608bb51_drop_local_password.py
Normal file
@@ -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=''))
|
||||
225
server/app.py
225
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)
|
||||
|
||||
101
server/auth.py
101
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,
|
||||
|
||||
@@ -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,
|
||||
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."
|
||||
)
|
||||
db.add(u)
|
||||
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 <username>")
|
||||
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,
|
||||
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":
|
||||
|
||||
@@ -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="")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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=<admin-account> # 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=<admin-account>\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=<admin-account>\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}.")
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()}
|
||||
|
||||
|
||||
@@ -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')"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user