people management, account self-service, password reset by link
Until now nobody could be invited without a script and a forgotten
password was a locked account. The identity layer already had invites,
sessions and the capability map; this wires the levers to them.
Admin API (people:invite_leader and up; the break-glass token cannot act
here, it has no person): list people with roles, memberships, live
sessions and keys, plus open invites and what YOU may grant; invite with
the URL returned once (no outbound mail yet, hand it over yourself);
revoke an invite; replace roles and memberships in full (owner only);
disable and enable (owner only, never yourself, never the last owner;
disabling ends sessions now and keys die through can()); mint a one-time
24-hour reset link (admin may reset anyone but an owner). Every one logs
who, whom and what.
Site: /account grows details and change-password forms (current password
required; the other devices are signed out, this one stays). /reset/{token}
sets a password, burns the link, ends every session and signs the person
in. Expired, used, revoked and never-existed read the same.
tests/smoke_identity.py 92 -> 123. Driven end to end on a throwaway with
a DB copy: invite, accept, account forms, reset link used then reused
(410), disable (session dies, login 401), enable, roles.
This commit is contained in:
+139
-5
@@ -254,14 +254,148 @@ def account(request: Request):
|
||||
if not rows:
|
||||
rows.append('<div class="rrow"><span class="v">No roles assigned yet.</span></div>')
|
||||
|
||||
return HTMLResponse(_render("Your account", _account_page(person, request.query_params.get("done"))))
|
||||
|
||||
|
||||
def _account_page(person, done=None, error=None, error_form=None):
|
||||
rows = []
|
||||
if person.get("global_role"):
|
||||
rows.append('<div class="rrow"><span class="k" style="min-width:110px">Site-wide</span>'
|
||||
'<span class="v">%s</span></div>'
|
||||
% _esc(ROLE_LABEL.get(person["global_role"], person["global_role"])))
|
||||
for m in person["memberships"]:
|
||||
title = " · %s" % _esc(m["title"]) if m["title"] else ""
|
||||
rows.append('<div class="rrow"><span class="k" style="min-width:110px">%s</span>'
|
||||
'<span class="v">%s%s</span></div>'
|
||||
% (_esc(m["short_name"]), _esc(ROLE_LABEL.get(m["role"], m["role"])), title))
|
||||
if not rows:
|
||||
rows.append('<div class="rrow"><span class="v">No roles assigned yet.</span></div>')
|
||||
name = person.get("preferred_name") or person.get("full_name") or person["email"]
|
||||
flash = {"details": "Details saved.", "password": "Password changed. Your other devices were signed out."}.get(done or "")
|
||||
err_d = '<div class="autherr">%s</div>' % _esc(error) if error and error_form == "details" else ""
|
||||
err_p = '<div class="autherr">%s</div>' % _esc(error) if error and error_form == "password" else ""
|
||||
body = _shell(
|
||||
"Your account", _esc(person["email"]),
|
||||
f"""<div class="mcard" style="padding:18px 20px;margin:0 0 18px">
|
||||
f"""{'<div class="success">%s</div>' % _esc(flash) if flash else ''}<div class="mcard" style="padding:18px 20px;margin:0 0 18px">
|
||||
{''.join(rows)}
|
||||
</div>
|
||||
<form method="post" action="/logout"><button class="cta" type="submit">Sign out</button></form>
|
||||
<p class="authhint">Changing your own details is not built yet. Ask an administrator.</p>""")
|
||||
return HTMLResponse(_render("Your account", body.replace(
|
||||
<form method="post" action="/account/details" class="mcard" style="padding:18px 20px;margin:0 0 18px">
|
||||
<h2 class="sec" style="font-size:1.1rem;margin:0 0 4px">Your details</h2>{err_d}
|
||||
<label for="full_name">Full name</label>
|
||||
<input id="full_name" name="full_name" required value="{_esc(person.get('full_name'))}">
|
||||
<label for="preferred_name">Preferred name <span style="font-weight:400;color:#6B7280">(optional)</span></label>
|
||||
<input id="preferred_name" name="preferred_name" value="{_esc(person.get('preferred_name'))}">
|
||||
<label for="phone">Mobile <span style="font-weight:400;color:#6B7280">(optional)</span></label>
|
||||
<input id="phone" name="phone" type="tel" value="{_esc(person.get('phone'))}">
|
||||
<button class="cta" type="submit" style="margin-top:16px">Save details</button>
|
||||
</form>
|
||||
<form method="post" action="/account/password" class="mcard" style="padding:18px 20px;margin:0 0 18px">
|
||||
<h2 class="sec" style="font-size:1.1rem;margin:0 0 4px">Change password</h2>{err_p}
|
||||
<label for="current">Current password</label>
|
||||
<input id="current" name="current" type="password" autocomplete="current-password" required>
|
||||
<label for="new">New password</label>
|
||||
<input id="new" name="new" type="password" autocomplete="new-password" required minlength="12">
|
||||
<label for="confirm">Confirm new password</label>
|
||||
<input id="confirm" name="confirm" type="password" autocomplete="new-password" required minlength="12">
|
||||
<button class="cta" type="submit" style="margin-top:16px">Change password</button>
|
||||
<p class="authhint">At least 12 characters. Changing it signs out your other devices.</p>
|
||||
</form>
|
||||
<form method="post" action="/logout"><button class="cta" type="submit" style="background:#1E2F52;color:#fff">Sign out</button></form>
|
||||
<p class="authhint">Your email address and roles are set by an administrator.</p>""")
|
||||
return body.replace(
|
||||
"<h1 class=\"sec\" style=\"margin:0 0 6px\">Your account</h1>",
|
||||
"<h1 class=\"sec\" style=\"margin:0 0 6px\">Hello, %s</h1>" % _esc(name))))
|
||||
"<h1 class=\"sec\" style=\"margin:0 0 6px\">Hello, %s</h1>" % _esc(name))
|
||||
|
||||
|
||||
@router.post("/account/details")
|
||||
def account_details(request: Request, full_name: str = Form(""), preferred_name: str = Form(""),
|
||||
phone: str = Form("")):
|
||||
person = current_person(request)
|
||||
if not person:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
try:
|
||||
identity.update_own_details(person["id"], full_name, preferred_name, phone)
|
||||
except identity.IdentityError as e:
|
||||
return HTMLResponse(_render("Your account", _account_page(person, error=e.detail, error_form="details")),
|
||||
status_code=e.status)
|
||||
return RedirectResponse(url="/account?done=details", status_code=303)
|
||||
|
||||
|
||||
@router.post("/account/password")
|
||||
def account_password(request: Request, current: str = Form(""), new: str = Form(""), confirm: str = Form("")):
|
||||
person = current_person(request)
|
||||
if not person:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
if new != confirm:
|
||||
return HTMLResponse(_render("Your account", _account_page(person, error="Those two passwords do not match.",
|
||||
error_form="password")), status_code=422)
|
||||
try:
|
||||
identity.change_password(person["id"], current, new, ip=_client_ip(request))
|
||||
except identity.IdentityError as e:
|
||||
return HTMLResponse(_render("Your account", _account_page(person, error=e.detail, error_form="password")),
|
||||
status_code=e.status)
|
||||
# End the other sessions, keep this one: the person is still here.
|
||||
tok = request.cookies.get(COOKIE)
|
||||
con = identity.connect()
|
||||
try:
|
||||
con.execute("UPDATE sessions SET revoked_at=? WHERE person_id=? AND revoked_at IS NULL AND token_hash<>?",
|
||||
(identity._now(), person["id"], identity._hash_token(tok or "")))
|
||||
con.commit()
|
||||
finally:
|
||||
con.close()
|
||||
return RedirectResponse(url="/account?done=password", status_code=303)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Password reset by link. The link is minted by an admin on the console and
|
||||
# handed over out of band; there is no outbound mail. Same posture as
|
||||
# invites: expired, used, revoked and never-existed all read the same.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DEAD_RESET = ("This reset link is no longer valid. It may have been used already, replaced by "
|
||||
"a newer one, or expired. Ask an administrator for a fresh link.")
|
||||
|
||||
|
||||
def _reset_form(token, person, error=None):
|
||||
return _shell(
|
||||
"Choose a new password", "For <strong>%s</strong>." % _esc(person["email"]),
|
||||
f"""<form method="post" action="/reset/{_esc(token)}">
|
||||
<label for="password">New password</label>
|
||||
<input id="password" name="password" type="password" autocomplete="new-password" required minlength="12">
|
||||
<label for="confirm">Confirm password</label>
|
||||
<input id="confirm" name="confirm" type="password" autocomplete="new-password" required minlength="12">
|
||||
<button class="cta" type="submit">Set password and sign in</button>
|
||||
</form>
|
||||
<p class="authhint">At least 12 characters. Every device signed in as you will be signed out.</p>""",
|
||||
error)
|
||||
|
||||
|
||||
@router.get("/reset/{token}", response_class=HTMLResponse)
|
||||
def reset_form(request: Request, token: str):
|
||||
person = identity.peek_reset(token)
|
||||
if not person:
|
||||
return HTMLResponse(_render("Reset", _shell("Reset", "", "", DEAD_RESET)), status_code=410)
|
||||
return HTMLResponse(_render("Choose a new password", _reset_form(token, person)))
|
||||
|
||||
|
||||
@router.post("/reset/{token}")
|
||||
def reset_accept(request: Request, token: str, password: str = Form(""), confirm: str = Form("")):
|
||||
person = identity.peek_reset(token)
|
||||
if not person:
|
||||
return HTMLResponse(_render("Reset", _shell("Reset", "", "", DEAD_RESET)), status_code=410)
|
||||
if password != confirm:
|
||||
return HTMLResponse(_render("Choose a new password",
|
||||
_reset_form(token, person, "Those two passwords do not match.")), status_code=422)
|
||||
try:
|
||||
person = identity.consume_reset(token, password, ip=_client_ip(request))
|
||||
except identity.IdentityError as e:
|
||||
if e.status == 410:
|
||||
return HTMLResponse(_render("Reset", _shell("Reset", "", "", DEAD_RESET)), status_code=410)
|
||||
return HTMLResponse(_render("Choose a new password", _reset_form(token, person, e.detail)),
|
||||
status_code=e.status)
|
||||
tok = identity.start_session(person["id"], ip=_client_ip(request),
|
||||
user_agent=request.headers.get("user-agent"))
|
||||
resp = RedirectResponse(url="/account", status_code=303)
|
||||
resp.set_cookie(COOKIE, tok, max_age=identity.SESSION_ABSOLUTE_DAYS * 86400,
|
||||
httponly=True, secure=COOKIE_SECURE, samesite="lax", path="/")
|
||||
return resp
|
||||
|
||||
Reference in New Issue
Block a user