feat(auth): password reset & invite flows for web + admin (#34) (#37)

* feat(auth): password reset & invite flows for web + admin (#34)

Wires end-to-end the previously orphaned password_reset.html and
password_setup.html templates, adds the missing POST /auth/password/reset
handler (closes #34), and restores the Reset action in the admin user UI
(which origin/main had removed precisely because the flow was broken).

Web flow
- GET  /auth/password/reset — renders the set-new-password form
- POST /auth/password/reset — 'Forgot Password?' request; emails link,
  anti-enumeration (same response for unknown email)
- POST /auth/password/reset/confirm — validates token + 24h TTL, sets new
  password, clears token, logs user in
- GET  /auth/password/setup — renders the setup form (invite link landing)
- POST /auth/password/setup/request — signup-tab 'Request Access' (email-only)
- POST /auth/password/setup/confirm — 7-day TTL, sets password + name, logs in
- Reuses LOCAL_DEV_MODE pattern from email.py: logs the link loudly so
  developers can use the flow without an SMTP/SendGrid transport

Admin flow
- POST /api/users accepts send_invite → returns invite_url + invite_email_sent
- POST /api/users/{id}/reset-password now returns a full reset_url pointing
  at the dedicated password-reset endpoint (NOT the magic-link verifier,
  which would log the user in without prompting for a new password)
- admin_users.html: restored Reset row action, copyable reset/invite link
  modals, invite checkbox on create, reworded 'magic-link not wired' notes

Backward compat
- JSON POST /auth/password/setup kept unchanged (existing tests pass)
- Active-account gate applied to reset/setup flows (matches password_login)

Tests: 21 new cases (tests/test_password_flows.py) covering GET renders,
request/confirm happy + error paths, TTLs, anti-enumeration, and admin
invite/reset URL responses. Full suite: 1309 passed.

Closes #34

* fix(admin-users): allow horizontal scroll when actions overflow

Four action buttons (Tokens, Reset, Set pwd, Delete) can exceed the
viewport on narrow screens. Switch .users-table-wrap from overflow: hidden
to overflow-x: auto so the table scrolls instead of clipping, and lock
row-actions buttons to a single nowrap line.

* fix(admin-users): override base 800px container so table can use full width

The base layout caps .container at 800px, so the table was always being
clipped regardless of viewport. Unclamp the container on this page and
widen the inner page cap to 1400px.

* fix(auth): address Devin review — harden JSON setup, anti-enumeration, preserve email case

Addresses findings from Devin review on PR #37:

1. JSON POST /auth/password/setup now enforces the same SETUP_TOKEN_TTL
   (7 days) and active-account check as the web flow. An expired token or
   a deactivated user can no longer bypass the gate by posting JSON.
   Existing test fixture seeds setup_token_created=now so backward-compat
   tests continue to pass.

2. GET /auth/password/setup no longer looks up the user to pre-fill name.
   The form renders identically regardless of whether the email exists,
   consistent with anti-enumeration in POST /setup/request.

3. reset_request / setup_request no longer lowercase the submitted email.
   The rest of the codebase (password_login, magic-link, admin create)
   uses case-sensitive lookups, so normalizing only here would silently
   fail for mixed-case accounts.

Tests: 6 new cases covering expired-JSON-setup, missing-created-timestamp,
deactivated-user-rejection, mixed-case email preservation, and the
anti-enumeration property of GET /setup.
This commit is contained in:
ZdenekSrotyr 2026-04-22 17:43:57 +02:00 committed by GitHub
parent f593a151fc
commit 7e4ddf0b01
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 881 additions and 75 deletions

View file

@ -41,6 +41,7 @@ class CreateUserRequest(BaseModel):
email: str
name: str
role: str = "analyst"
send_invite: bool = False
class UpdateUserRequest(BaseModel):
@ -61,9 +62,11 @@ class UserResponse(BaseModel):
active: bool = True
created_at: Optional[str]
deactivated_at: Optional[str] = None
invite_url: Optional[str] = None
invite_email_sent: Optional[bool] = None
def _to_response(u: dict) -> UserResponse:
def _to_response(u: dict, invite_url: Optional[str] = None, invite_email_sent: Optional[bool] = None) -> UserResponse:
return UserResponse(
id=u["id"],
email=u["email"],
@ -72,6 +75,8 @@ def _to_response(u: dict) -> UserResponse:
active=bool(u.get("active", True)),
created_at=str(u.get("created_at", "")),
deactivated_at=str(u["deactivated_at"]) if u.get("deactivated_at") else None,
invite_url=invite_url,
invite_email_sent=invite_email_sent,
)
@ -97,11 +102,27 @@ async def create_user(
Role(payload.role)
except ValueError:
raise HTTPException(status_code=400, detail=f"Unknown role: {payload.role}")
import secrets
user_id = str(uuid.uuid4())
repo.create(id=user_id, email=payload.email, name=payload.name, role=payload.role)
_audit(conn, user["id"], "user.create", user_id, {"email": payload.email, "role": payload.role})
invite_url: Optional[str] = None
invite_email_sent: Optional[bool] = None
if payload.send_invite:
token = secrets.token_urlsafe(32)
repo.update(
id=user_id,
setup_token=token,
setup_token_created=datetime.now(timezone.utc),
)
from app.auth.providers.password import build_setup_url, send_setup_email
invite_url = build_setup_url(request, payload.email, token)
invite_email_sent = send_setup_email(request, payload.email, token)
_audit(conn, user["id"], "user.invite", user_id, {"email": payload.email, "email_sent": invite_email_sent})
created = repo.get_by_id(user_id)
return _to_response(created)
return _to_response(created, invite_url=invite_url, invite_email_sent=invite_email_sent)
@router.patch("/{user_id}", response_model=UserResponse)
@ -199,14 +220,17 @@ async def reset_password(
reset_token_created=datetime.now(timezone.utc),
)
_audit(conn, user["id"], "user.reset_password", user_id, {"email": target["email"]})
# Intentionally do NOT auto-send an email. The magic-link sender
# (`app/auth/providers/email.py:_send_email`) would deliver a "Login Link"
# that — when clicked — consumes the reset_token via verify_magic_link and
# logs the user in WITHOUT prompting for a new password, defeating the
# reset. Until a dedicated password-reset email flow with its own token
# column exists, admins share the `reset_token` below manually (or use the
# `set-password` endpoint directly).
return {"reset_token": token, "email_sent": False}
# Dedicated password-reset email/URL — points to /auth/password/reset where the
# user sets a new password, NOT to the magic-link verify endpoint (which would
# log them in without prompting for a new password).
from app.auth.providers.password import build_reset_url, send_reset_email
reset_url = build_reset_url(request, target["email"], token)
email_sent = send_reset_email(request, target["email"], token)
return {
"reset_token": token,
"reset_url": reset_url,
"email_sent": email_sent,
}
@router.post("/{user_id}/set-password", status_code=204)

View file

@ -2,21 +2,28 @@
import logging
import os
import secrets
from datetime import datetime, timedelta, timezone
from urllib.parse import quote
from fastapi import APIRouter, Depends, Form, HTTPException
from fastapi.responses import RedirectResponse
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from pydantic import BaseModel
import duckdb
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from app.auth.jwt import create_access_token
from app.auth.dependencies import _get_db
from app.auth.dependencies import _get_db, is_local_dev_mode
from src.repositories.users import UserRepository
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth/password", tags=["auth"])
RESET_TOKEN_TTL = timedelta(hours=24)
SETUP_TOKEN_TTL = timedelta(days=7)
MIN_PASSWORD_LEN = 8
class PasswordLoginRequest(BaseModel):
email: str
@ -33,6 +40,136 @@ def is_available() -> bool:
return True # Always available
def _has_email_transport() -> bool:
return bool(os.environ.get("SMTP_HOST") or os.environ.get("SENDGRID_API_KEY"))
def _cookie_secure() -> bool:
# Secure cookie only over HTTPS (DOMAIN env set = production with TLS)
return os.environ.get("DOMAIN", "") != ""
def _set_login_cookie(response, user_id: str, email: str, role: str) -> None:
token = create_access_token(user_id, email, role)
response.set_cookie(
key="access_token", value=token,
httponly=True, max_age=86400, samesite="lax",
secure=_cookie_secure(),
)
def _base_url(request: Request) -> str:
explicit = os.environ.get("SERVER_URL")
if explicit:
return explicit.rstrip("/")
return str(request.base_url).rstrip("/")
def build_reset_url(request: Request, email: str, token: str) -> str:
return f"{_base_url(request)}/auth/password/reset?email={quote(email, safe='')}&token={token}"
def build_setup_url(request: Request, email: str, token: str) -> str:
return f"{_base_url(request)}/auth/password/setup?email={quote(email, safe='')}&token={token}"
def _token_is_fresh(created, ttl: timedelta) -> bool:
if not created:
return False
if isinstance(created, str):
try:
created = datetime.fromisoformat(created)
except ValueError:
return False
# DuckDB returns TIMESTAMP as offset-naive; we stored it as UTC, so assume UTC.
if created.tzinfo is None:
created = created.replace(tzinfo=timezone.utc)
return (datetime.now(timezone.utc) - created) <= ttl
def _render_message(request: Request, title: str, message: str, status_code: int = 200):
from app.web.router import templates, _build_context
ctx = _build_context(request, page_title=title, page_message=message)
return templates.TemplateResponse(request, "_message.html", ctx, status_code=status_code)
def _render_reset_form(request: Request, email: str, token: str, error: str = ""):
from app.web.router import templates, _build_context
ctx = _build_context(request, email=email, token=token, error=error)
return templates.TemplateResponse(request, "password_reset.html", ctx)
def _render_setup_form(request: Request, email: str, token: str, name: str = "", error: str = ""):
from app.web.router import templates, _build_context
ctx = _build_context(request, email=email, token=token, name=name, error=error)
return templates.TemplateResponse(request, "password_setup.html", ctx)
def _send_mail(to_email: str, subject: str, body_text: str) -> bool:
"""Send a plaintext email via SendGrid or SMTP. Returns True on success."""
try:
sendgrid_key = os.environ.get("SENDGRID_API_KEY")
if sendgrid_key:
import sendgrid
from sendgrid.helpers.mail import Mail
sg = sendgrid.SendGridAPIClient(api_key=sendgrid_key)
msg = Mail(
from_email=os.environ.get("EMAIL_FROM_ADDRESS", "noreply@example.com"),
to_emails=to_email,
subject=subject,
plain_text_content=body_text,
)
sg.send(msg)
return True
smtp_host = os.environ.get("SMTP_HOST")
if smtp_host:
import smtplib
from email.mime.text import MIMEText
msg = MIMEText(body_text)
msg["Subject"] = subject
msg["From"] = os.environ.get("SMTP_FROM", "noreply@example.com")
msg["To"] = to_email
with smtplib.SMTP(smtp_host, int(os.environ.get("SMTP_PORT", "587"))) as s:
if os.environ.get("SMTP_USE_TLS", "true").lower() == "true":
s.starttls()
smtp_user = os.environ.get("SMTP_USER")
if smtp_user:
s.login(smtp_user, os.environ.get("SMTP_PASSWORD", ""))
s.send_message(msg)
return True
except Exception:
logger.exception("Failed to send mail to %s", to_email)
return False
def send_reset_email(request: Request, email: str, token: str) -> bool:
"""Deliver a password-reset link. In LOCAL_DEV_MODE logs the link as well."""
link = build_reset_url(request, email, token)
if is_local_dev_mode():
logger.warning("=" * 60)
logger.warning("Password reset link for %s (LOCAL_DEV_MODE):", email)
logger.warning(" %s", link)
logger.warning("=" * 60)
if not _has_email_transport():
return False
return _send_mail(email, "Reset your password", f"Click to reset your password: {link}")
def send_setup_email(request: Request, email: str, token: str) -> bool:
link = build_setup_url(request, email, token)
if is_local_dev_mode():
logger.warning("=" * 60)
logger.warning("Account setup link for %s (LOCAL_DEV_MODE):", email)
logger.warning(" %s", link)
logger.warning("=" * 60)
if not _has_email_transport():
return False
return _send_mail(email, "Set up your account", f"Click to set up your password: {link}")
# ---- Existing flows ----
@router.post("/login")
async def password_login(
request: PasswordLoginRequest,
@ -46,7 +183,6 @@ async def password_login(
if not bool(user.get("active", True)):
raise HTTPException(status_code=401, detail="Account deactivated")
# Verify password
try:
ph = PasswordHasher()
ph.verify(user["password_hash"], request.password)
@ -79,48 +215,219 @@ async def password_login_web(
ph = PasswordHasher()
ph.verify(user["password_hash"], password)
except VerifyMismatchError:
# Genuinely wrong password → usual UX.
return RedirectResponse(url="/login/password?error=invalid", status_code=302)
except Exception:
# Corrupted hash / library error → surface a distinct error code so ops
# can tell broken-hash cases apart from bad-password cases. Log loudly.
logger.exception("Unexpected error during web password verification for %s", email)
return RedirectResponse(url="/login/password?err=auth_internal", status_code=302)
token = create_access_token(user["id"], user["email"], user["role"])
# Secure cookie only over HTTPS (detect via X-Forwarded-Proto or request scheme)
# For dev/staging on plain HTTP, secure=False so the cookie is actually sent
use_secure = os.environ.get("DOMAIN", "") != "" # DOMAIN set = production with TLS
# Sanitize `next`: must start with `/` and must not start with `//` (open-redirect guard)
target = next if (next.startswith("/") and not next.startswith("//")) else "/dashboard"
response = RedirectResponse(url=target, status_code=302)
response.set_cookie(
key="access_token", value=token,
httponly=True, max_age=86400, samesite="lax",
secure=use_secure,
)
_set_login_cookie(response, user["id"], user["email"], user["role"])
return response
# ---- JSON programmatic setup (backward compat — used by existing tests) ----
@router.post("/setup")
async def password_setup(
request: PasswordSetupRequest,
request_body: PasswordSetupRequest,
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Set initial password using setup token."""
"""Set initial password using setup token (JSON API)."""
repo = UserRepository(conn)
user = repo.get_by_email(request.email)
user = repo.get_by_email(request_body.email)
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user.get("setup_token") != request.token:
if user.get("setup_token") != request_body.token:
raise HTTPException(status_code=400, detail="Invalid setup token")
if not _token_is_fresh(user.get("setup_token_created"), SETUP_TOKEN_TTL):
raise HTTPException(status_code=400, detail="Setup token has expired")
if not bool(user.get("active", True)):
raise HTTPException(status_code=403, detail="Account deactivated")
if len(request_body.password) < MIN_PASSWORD_LEN:
raise HTTPException(status_code=400, detail=f"Password must be at least {MIN_PASSWORD_LEN} characters")
# Hash and save password
ph = PasswordHasher()
hashed = ph.hash(request.password)
hashed = ph.hash(request_body.password)
repo.update(id=user["id"], password_hash=hashed, setup_token=None)
repo.update(id=user["id"], password_hash=hashed, setup_token=None, setup_token_created=None)
token = create_access_token(user["id"], user["email"], user["role"])
return {"access_token": token, "token_type": "bearer", "message": "Password set successfully"}
# ---- Web flow: password RESET ----
@router.get("/reset", response_class=HTMLResponse)
async def reset_page(
request: Request,
email: str = "",
token: str = "",
):
"""Render the 'set new password' form when arriving via reset link."""
if not email or not token:
return RedirectResponse(url="/login/password", status_code=302)
return _render_reset_form(request, email=email, token=token)
@router.post("/reset")
async def reset_request(
request: Request,
email: str = Form(""),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Request a password-reset link. Anti-enumeration: same response regardless."""
# Match the rest of the codebase's case-sensitive lookup (password_login,
# email magic-link, admin create). Lowercasing here would silently fail
# for mixed-case emails the admin stored as-is.
email = (email or "").strip()
if email:
repo = UserRepository(conn)
user = repo.get_by_email(email)
if user and bool(user.get("active", True)):
token = secrets.token_urlsafe(32)
repo.update(
id=user["id"],
reset_token=token,
reset_token_created=datetime.now(timezone.utc),
)
send_reset_email(request, email, token)
return _render_message(
request,
title="Check your email",
message="If an account exists for that email, a password-reset link has been sent. "
"The link is valid for 24 hours.",
)
@router.post("/reset/confirm")
async def reset_confirm(
request: Request,
email: str = Form(...),
token: str = Form(...),
password: str = Form(...),
confirm_password: str = Form(...),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Submit a new password using a reset token."""
if password != confirm_password:
return _render_reset_form(request, email=email, token=token, error="Passwords do not match.")
if len(password) < MIN_PASSWORD_LEN:
return _render_reset_form(
request, email=email, token=token,
error=f"Password must be at least {MIN_PASSWORD_LEN} characters.",
)
repo = UserRepository(conn)
user = repo.get_by_email(email)
if not user or user.get("reset_token") != token:
return _render_reset_form(request, email=email, token=token, error="Invalid or expired reset link.")
if not _token_is_fresh(user.get("reset_token_created"), RESET_TOKEN_TTL):
return _render_reset_form(request, email=email, token=token, error="Reset link has expired. Please request a new one.")
if not bool(user.get("active", True)):
return _render_reset_form(request, email=email, token=token, error="This account is deactivated.")
ph = PasswordHasher()
repo.update(
id=user["id"],
password_hash=ph.hash(password),
reset_token=None,
reset_token_created=None,
)
response = RedirectResponse(url="/login/password?msg=password_reset", status_code=302)
_set_login_cookie(response, user["id"], user["email"], user["role"])
return response
# ---- Web flow: initial SETUP ----
@router.get("/setup", response_class=HTMLResponse)
async def setup_page(
request: Request,
email: str = "",
token: str = "",
):
"""Render the initial 'set password + name' form when arriving via invite link.
Note: we render the form based on URL params only, without a DB lookup, so
the response is identical for valid and invalid email/token combinations
(anti-enumeration). Token validity is checked at POST /setup/confirm."""
if not email or not token:
return RedirectResponse(url="/login/password", status_code=302)
return _render_setup_form(request, email=email, token=token)
@router.post("/setup/request")
async def setup_request(
request: Request,
email: str = Form(""),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Self-service 'Request Access' — emails a setup link if user is pre-approved and unset."""
# Match the rest of the codebase's case-sensitive lookup (password_login,
# email magic-link, admin create). Lowercasing here would silently fail
# for mixed-case emails the admin stored as-is.
email = (email or "").strip()
if email:
repo = UserRepository(conn)
user = repo.get_by_email(email)
# Only issue setup token if user exists, has no password yet, and is active.
if user and not user.get("password_hash") and bool(user.get("active", True)):
token = secrets.token_urlsafe(32)
repo.update(
id=user["id"],
setup_token=token,
setup_token_created=datetime.now(timezone.utc),
)
send_setup_email(request, email, token)
return _render_message(
request,
title="Check your email",
message="If your account is pre-approved, a setup link has been sent to your email. "
"Ask an administrator if you do not receive it.",
)
@router.post("/setup/confirm")
async def setup_confirm(
request: Request,
email: str = Form(...),
token: str = Form(...),
password: str = Form(...),
confirm_password: str = Form(...),
name: str = Form(""),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Web form: complete initial password setup via setup token."""
if password != confirm_password:
return _render_setup_form(request, email=email, token=token, name=name, error="Passwords do not match.")
if len(password) < MIN_PASSWORD_LEN:
return _render_setup_form(
request, email=email, token=token, name=name,
error=f"Password must be at least {MIN_PASSWORD_LEN} characters.",
)
repo = UserRepository(conn)
user = repo.get_by_email(email)
if not user or user.get("setup_token") != token:
return _render_setup_form(request, email=email, token=token, name=name, error="Invalid or expired setup link.")
if not _token_is_fresh(user.get("setup_token_created"), SETUP_TOKEN_TTL):
return _render_setup_form(request, email=email, token=token, name=name, error="Setup link has expired. Ask an administrator for a new one.")
if not bool(user.get("active", True)):
return _render_setup_form(request, email=email, token=token, name=name, error="This account is deactivated.")
ph = PasswordHasher()
updates: dict = dict(
password_hash=ph.hash(password),
setup_token=None,
setup_token_created=None,
)
if name.strip():
updates["name"] = name.strip()
repo.update(id=user["id"], **updates)
response = RedirectResponse(url="/dashboard", status_code=302)
_set_login_cookie(response, user["id"], user["email"], user["role"])
return response

View file

@ -0,0 +1,15 @@
{% extends "base.html" %}
{% block title %}{{ page_title|default('Notice', true) }} - {{ config.INSTANCE_NAME }}{% endblock %}
{% block content %}
<div class="login-container">
<div class="login-card">
<h2>{{ page_title|default('Notice', true) }}</h2>
<p class="login-description">{{ page_message|default('', true) }}</p>
<div class="login-links">
<a href="/login/password" class="btn btn-link">Back to Login</a>
</div>
</div>
</div>
{% endblock %}

View file

@ -3,7 +3,9 @@
{% block content %}
<style>
.users-page { max-width: 1200px; margin: 24px auto; padding: 0 16px; }
/* Override base.html's 800px .container cap for this wide table. */
.container:has(.users-page) { max-width: none; padding: 24px 16px; }
.users-page { max-width: 1400px; margin: 0 auto; padding: 0; }
.users-toolbar {
display: flex; justify-content: space-between; align-items: center;
gap: 16px; margin-bottom: 20px; flex-wrap: wrap;
@ -23,7 +25,7 @@
background: var(--surface, #fff);
border: 1px solid var(--border, #e5e7eb);
border-radius: 12px;
overflow: hidden;
overflow-x: auto;
}
.users-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.users-table thead th {
@ -85,11 +87,13 @@
.date-cell { color: var(--text-secondary, #6b7280); font-size: 12px; white-space: nowrap; }
.row-actions { display: flex; gap: 6px; justify-content: flex-end; }
.row-actions { display: flex; gap: 6px; justify-content: flex-end; flex-wrap: nowrap; white-space: nowrap; }
.icon-btn {
background: transparent; border: 1px solid var(--border, #e5e7eb); border-radius: 6px;
padding: 5px 10px; font-size: 12px; cursor: pointer;
color: var(--text-secondary, #6b7280); transition: all 0.15s;
text-decoration: none; line-height: 1.4;
white-space: nowrap;
}
.icon-btn:hover { color: var(--text-primary, #111827); border-color: #cbd5e1; background: #f9fafb; }
.icon-btn.danger:hover { color: #b91c1c; border-color: #fecaca; background: #fef2f2; }
@ -208,7 +212,7 @@
<div class="modal-backdrop" id="create-modal" role="dialog" aria-modal="true" aria-labelledby="create-modal-title">
<div class="modal-card">
<h3 id="create-modal-title">Add user</h3>
<p class="sub">Invites a new account. The user will need a password (set via the Reset link below) or a configured SSO provider to sign in.</p>
<p class="sub">Invites a new account. When "Send invitation link" is ticked we generate a setup link the user can follow to pick their own password.</p>
<label for="new-email">Email</label>
<input id="new-email" type="email" required autocomplete="off">
<label for="new-name">Name (optional)</label>
@ -220,6 +224,10 @@
<option value="km_admin">km_admin</option>
<option value="admin">admin</option>
</select>
<label style="display:flex; align-items:center; gap:8px; margin-top:12px; font-size:13px; color: var(--text-primary, #111827); font-weight:500;">
<input id="new-send-invite" type="checkbox" checked>
Send invitation link (generates setup token, emails it if transport is configured)
</label>
<div class="modal-actions">
<button class="modal-btn" data-close-modal="create-modal">Cancel</button>
<button class="modal-btn primary" id="confirm-create-btn">Create</button>
@ -241,18 +249,12 @@
</div>
</div>
<!-- Reset token reveal modal
NOTE: The reset_token endpoint still exists for API-level future use,
but no matching "consume this token to set a new password" endpoint
ships today — the magic-link sender would log the user in without
prompting for a password, defeating the reset. Admins should use the
"Set pwd" action (/{id}/set-password) instead. This modal is retained
for API inspection only; the Reset button in the row actions is gone. -->
<!-- Reset URL reveal modal -->
<div class="modal-backdrop" id="reset-modal" role="dialog" aria-modal="true" aria-labelledby="reset-title">
<div class="modal-card">
<h3 id="reset-title">Password reset token</h3>
<h3 id="reset-title">Password reset link</h3>
<p class="sub" id="reset-target"></p>
<p class="sub">Admins should use <strong>Set password</strong> directly to assign a new password. The magic-link flow is not available for password-reset tokens in this build — this token currently has no matching consumer endpoint.</p>
<p class="sub" id="reset-transport-note"></p>
<div class="token-reveal">
<code id="reset-token-text"></code>
<button class="copy-btn" id="reset-copy-btn">Copy</button>
@ -263,6 +265,22 @@
</div>
</div>
<!-- Invitation link modal -->
<div class="modal-backdrop" id="invite-modal" role="dialog" aria-modal="true" aria-labelledby="invite-title">
<div class="modal-card">
<h3 id="invite-title">Invitation link</h3>
<p class="sub" id="invite-target"></p>
<p class="sub" id="invite-transport-note"></p>
<div class="token-reveal">
<code id="invite-url-text"></code>
<button class="copy-btn" id="invite-copy-btn">Copy</button>
</div>
<div class="modal-actions">
<button class="modal-btn primary" data-close-modal="invite-modal">Done</button>
</div>
</div>
</div>
<!-- Confirm dialog -->
<div class="modal-backdrop" id="confirm-modal" role="dialog" aria-modal="true" aria-labelledby="confirm-title">
<div class="modal-card">
@ -406,7 +424,8 @@ function renderUsers() {
<td>
<div class="row-actions">
<a class="icon-btn" href="/admin/tokens?user=${encodeURIComponent(u.email || "")}" title="View this user's personal access tokens">Tokens</a>
<button class="icon-btn" data-action="set-password" data-user-id="${esc(u.id)}" data-user-email="${esc(u.email)}" title="Assign a new password (the 'reset token' flow is not wired end-to-end in this build)">Set pwd</button>
<button class="icon-btn" data-action="reset-password" data-user-id="${esc(u.id)}" data-user-email="${esc(u.email)}" title="Generate a reset link (user picks their own new password)">Reset</button>
<button class="icon-btn" data-action="set-password" data-user-id="${esc(u.id)}" data-user-email="${esc(u.email)}" title="Assign a password directly">Set pwd</button>
<button class="icon-btn danger" data-action="delete-user" data-user-id="${esc(u.id)}" data-user-email="${esc(u.email)}">Delete</button>
</div>
</td>`;
@ -418,9 +437,8 @@ function renderUsers() {
el.addEventListener("click", () => editRole(el.dataset.userId)));
tbody.querySelectorAll('[data-action="toggle-active"]').forEach(el =>
el.addEventListener("change", () => toggleActive(el.dataset.userId, el.checked)));
// Note: the "Reset" row action has been removed (the reset_token endpoint
// has no matching consumer in this build); admins use Set pwd instead.
// resetPassword() below is kept for API-level inspection / future use.
tbody.querySelectorAll('[data-action="reset-password"]').forEach(el =>
el.addEventListener("click", () => resetPassword(el.dataset.userId, el.dataset.userEmail)));
tbody.querySelectorAll('[data-action="set-password"]').forEach(el =>
el.addEventListener("click", () => openSetPassword(el.dataset.userId, el.dataset.userEmail)));
tbody.querySelectorAll('[data-action="delete-user"]').forEach(el =>
@ -478,17 +496,20 @@ async function patch(id, body, successMsg) {
// ── Reset password ──
async function resetPassword(id, email) {
if (!await confirmModal(`Generate a reset token for ${email}?`)) return;
if (!await confirmModal(`Generate a password-reset link for ${email}?`)) return;
const r = await fetch(`${API}/${id}/reset-password`, { method: "POST", credentials: "include" });
const data = await r.json().catch(() => ({}));
if (!r.ok) { toast("Failed: " + (data.detail || r.status), "error"); return; }
document.getElementById("reset-target").textContent = `For ${email}`;
document.getElementById("reset-token-text").textContent = data.reset_token;
document.getElementById("reset-transport-note").textContent = data.email_sent
? "Email sent. Copy the link below only if you need to deliver it manually."
: "Email transport unavailable — send this link to the user directly.";
document.getElementById("reset-token-text").textContent = data.reset_url;
const copyBtn = document.getElementById("reset-copy-btn");
copyBtn.textContent = "Copy"; copyBtn.classList.remove("copied");
copyBtn.onclick = async () => {
try {
await navigator.clipboard.writeText(data.reset_token);
await navigator.clipboard.writeText(data.reset_url);
copyBtn.textContent = "Copied!"; copyBtn.classList.add("copied");
setTimeout(() => { copyBtn.textContent = "Copy"; copyBtn.classList.remove("copied"); }, 1500);
} catch { toast("Copy failed — select the text manually", "error"); }
@ -530,24 +551,46 @@ document.getElementById("open-create-btn").addEventListener("click", () => {
document.getElementById("new-email").value = "";
document.getElementById("new-name").value = "";
document.getElementById("new-role").value = "analyst";
document.getElementById("new-send-invite").checked = true;
openModal("create-modal");
});
document.getElementById("confirm-create-btn").addEventListener("click", async () => {
const email = document.getElementById("new-email").value.trim();
const name = document.getElementById("new-name").value.trim();
const role = document.getElementById("new-role").value;
const sendInvite = document.getElementById("new-send-invite").checked;
if (!email) { toast("Email is required", "error"); return; }
const r = await fetch(API, {
method: "POST", credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, name: name || email.split("@")[0], role }),
body: JSON.stringify({ email, name: name || email.split("@")[0], role, send_invite: sendInvite }),
});
if (!r.ok) { toast("Failed: " + (await r.text()), "error"); return; }
const data = await r.json().catch(() => ({}));
closeModal("create-modal");
toast("User created", "success");
loadUsers();
if (data.invite_url) showInviteLink(email, data.invite_url, data.invite_email_sent);
});
function showInviteLink(email, url, emailSent) {
document.getElementById("invite-target").textContent = `For ${email}`;
document.getElementById("invite-transport-note").textContent = emailSent
? "Invitation email sent. Copy the link below only if you need to deliver it manually."
: "Email transport unavailable — send this link to the user directly.";
document.getElementById("invite-url-text").textContent = url;
const copyBtn = document.getElementById("invite-copy-btn");
copyBtn.textContent = "Copy"; copyBtn.classList.remove("copied");
copyBtn.onclick = async () => {
try {
await navigator.clipboard.writeText(url);
copyBtn.textContent = "Copied!"; copyBtn.classList.add("copied");
setTimeout(() => { copyBtn.textContent = "Copy"; copyBtn.classList.remove("copied"); }, 1500);
} catch { toast("Copy failed — select the text manually", "error"); }
};
openModal("invite-modal");
}
loadUsers();
</script>
{% endblock %}

View file

@ -57,7 +57,7 @@
Enter your email to receive a setup link. You must be pre-approved by an administrator.
</p>
<form method="POST" action="/auth/password/setup" class="login-form">
<form method="POST" action="/auth/password/setup/request" class="login-form">
<div class="form-group">
<label for="email-signup">Email Address</label>
<input type="email"

View file

@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Reset Password - Data Analyst Portal{% endblock %}
{% block title %}Reset Password - {{ config.INSTANCE_NAME }}{% endblock %}
{% block content %}
<div class="login-container">
@ -14,7 +14,14 @@
<strong>{{ email }}</strong>
</div>
<form method="POST" class="login-form">
{% if error %}
<div class="flash flash-error">{{ error }}</div>
{% endif %}
<form method="POST" action="/auth/password/reset/confirm" class="login-form">
<input type="hidden" name="email" value="{{ email }}">
<input type="hidden" name="token" value="{{ token }}">
<div class="form-group">
<label for="password">New Password</label>
<input type="password"
@ -25,9 +32,7 @@
autocomplete="new-password"
minlength="8"
autofocus>
<small class="form-hint">
At least 8 characters with uppercase, lowercase, and a number.
</small>
<small class="form-hint">At least 8 characters.</small>
</div>
<div class="form-group">
@ -45,9 +50,7 @@
</form>
<div class="login-links">
<a href="{{ url_for('password_auth.login_email') }}" class="btn btn-link">
Back to Login
</a>
<a href="/login/password" class="btn btn-link">Back to Login</a>
</div>
</div>
</div>

View file

@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Set Up Your Account - Data Analyst Portal{% endblock %}
{% block title %}Set Up Your Account - {{ config.INSTANCE_NAME }}{% endblock %}
{% block content %}
<div class="login-container">
@ -14,7 +14,14 @@
<strong>{{ email }}</strong>
</div>
<form method="POST" class="login-form">
{% if error %}
<div class="flash flash-error">{{ error }}</div>
{% endif %}
<form method="POST" action="/auth/password/setup/confirm" class="login-form">
<input type="hidden" name="email" value="{{ email }}">
<input type="hidden" name="token" value="{{ token }}">
<div class="form-group">
<label for="name">Your Name (optional)</label>
<input type="text"
@ -34,9 +41,7 @@
required
autocomplete="new-password"
minlength="8">
<small class="form-hint">
At least 8 characters with uppercase, lowercase, and a number.
</small>
<small class="form-hint">At least 8 characters.</small>
</div>
<div class="form-group">
@ -54,9 +59,7 @@
</form>
<div class="login-links">
<a href="{{ url_for('password_auth.login_email') }}" class="btn btn-link">
Back to Login
</a>
<a href="/login/password" class="btn btn-link">Back to Login</a>
</div>
</div>
</div>

View file

@ -26,9 +26,12 @@ def client(tmp_path, monkeypatch):
pw_hash = hashlib.sha256(b"testpass123").hexdigest()
ur.create(id="pw1", email="pw@test.com", name="PW User", role="analyst", password_hash=pw_hash)
# User with setup token
# User with setup token (and fresh created timestamp so the JSON /setup
# endpoint's TTL check accepts it)
from datetime import datetime, timezone
ur.create(id="setup1", email="setup@test.com", name="Setup User", role="analyst")
ur.update(id="setup1", setup_token="setup-token-123")
ur.update(id="setup1", setup_token="setup-token-123",
setup_token_created=datetime.now(timezone.utc))
# User for magic link
ur.create(id="ml1", email="ml@test.com", name="ML User", role="analyst")
conn.close()

View file

@ -0,0 +1,408 @@
"""Tests for password reset + setup web flows (closes #34)."""
from __future__ import annotations
import tempfile
import uuid
from datetime import datetime, timedelta, timezone
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def fresh_db(monkeypatch):
with tempfile.TemporaryDirectory() as tmp:
monkeypatch.setenv("DATA_DIR", tmp)
from src.db import close_system_db
close_system_db()
yield tmp
close_system_db()
@pytest.fixture
def app_client(fresh_db, monkeypatch):
monkeypatch.setenv("TESTING", "1")
monkeypatch.setenv("JWT_SECRET_KEY", "test-jwt-secret-key-minimum-32-chars!!")
from app.main import app
return TestClient(app, follow_redirects=False)
def _seed_user(email: str, *, password_hash: str | None = None, setup_token: str | None = None,
setup_token_created: datetime | None = None, reset_token: str | None = None,
reset_token_created: datetime | None = None, role: str = "analyst") -> str:
"""Create a user, return its id."""
from src.db import get_system_db
from src.repositories.users import UserRepository
uid = str(uuid.uuid4())
conn = get_system_db()
try:
repo = UserRepository(conn)
repo.create(id=uid, email=email, name=email.split("@")[0], role=role,
password_hash=password_hash)
updates: dict = {}
if setup_token is not None:
updates["setup_token"] = setup_token
if setup_token_created is not None:
updates["setup_token_created"] = setup_token_created
if reset_token is not None:
updates["reset_token"] = reset_token
if reset_token_created is not None:
updates["reset_token_created"] = reset_token_created
if updates:
repo.update(id=uid, **updates)
return uid
finally:
conn.close()
def _get_user(email: str) -> dict:
from src.db import get_system_db
from src.repositories.users import UserRepository
conn = get_system_db()
try:
return UserRepository(conn).get_by_email(email)
finally:
conn.close()
def _seed_admin() -> str:
from src.db import get_system_db
from src.repositories.users import UserRepository
from app.auth.jwt import create_access_token
conn = get_system_db()
try:
uid = str(uuid.uuid4())
UserRepository(conn).create(id=uid, email="admin@test", name="Admin", role="admin")
return create_access_token(user_id=uid, email="admin@test", role="admin")
finally:
conn.close()
# ---- GET pages ----
class TestResetGet:
def test_renders_form_with_params(self, app_client, fresh_db):
_seed_user("reset-me@test.com")
resp = app_client.get("/auth/password/reset", params={"email": "reset-me@test.com", "token": "abc"})
assert resp.status_code == 200
assert "Reset Your Password" in resp.text
# Hidden inputs with email + token are rendered
assert 'name="email"' in resp.text
assert 'value="reset-me@test.com"' in resp.text
assert 'value="abc"' in resp.text
def test_redirects_without_params(self, app_client, fresh_db):
resp = app_client.get("/auth/password/reset")
assert resp.status_code == 302
assert resp.headers["location"].endswith("/login/password")
class TestSetupGet:
def test_renders_form_with_params(self, app_client, fresh_db):
_seed_user("new@test.com")
resp = app_client.get("/auth/password/setup", params={"email": "new@test.com", "token": "xyz"})
assert resp.status_code == 200
assert "Set Up Your Account" in resp.text
assert 'value="new@test.com"' in resp.text
assert 'value="xyz"' in resp.text
def test_does_not_leak_user_existence_via_name_prefill(self, app_client, fresh_db):
"""GET /setup must render the same form whether the email exists or not,
so an attacker can't enumerate users by watching the name field."""
_seed_user("alice@test.com") # seeded with name="alice" (derived from email)
r_known = app_client.get("/auth/password/setup",
params={"email": "alice@test.com", "token": "anything"})
r_unknown = app_client.get("/auth/password/setup",
params={"email": "ghost@test.com", "token": "anything"})
assert r_known.status_code == 200 and r_unknown.status_code == 200
# Seeded user's display name must NOT be pre-filled in the name input.
assert 'value="alice"' not in r_known.text
# The two responses should differ only by URL-reflected values (email).
for body in (r_known.text, r_unknown.text):
assert 'name="name"' in body # the blank name input is always there
def test_redirects_without_params(self, app_client, fresh_db):
resp = app_client.get("/auth/password/setup")
assert resp.status_code == 302
# ---- POST /auth/password/reset (request) ----
class TestResetRequest:
def test_issues_token_for_existing_user(self, app_client, fresh_db):
_seed_user("forgot@test.com", password_hash="argon2_placeholder")
resp = app_client.post("/auth/password/reset", data={"email": "forgot@test.com"})
assert resp.status_code == 200
assert "Check your email" in resp.text
u = _get_user("forgot@test.com")
assert u["reset_token"] # token was stored
def test_unknown_email_same_response(self, app_client, fresh_db):
# Anti-enumeration: should not reveal whether email is registered.
resp = app_client.post("/auth/password/reset", data={"email": "ghost@test.com"})
assert resp.status_code == 200
assert "Check your email" in resp.text
def test_empty_email_same_response(self, app_client, fresh_db):
resp = app_client.post("/auth/password/reset", data={"email": ""})
assert resp.status_code == 200
# ---- POST /auth/password/reset/confirm ----
class TestResetConfirm:
def test_valid_token_sets_password_and_redirects(self, app_client, fresh_db):
from argon2 import PasswordHasher
uid = _seed_user(
"r1@test.com",
password_hash=PasswordHasher().hash("oldpass123"),
reset_token="tok-valid",
reset_token_created=datetime.now(timezone.utc),
)
resp = app_client.post("/auth/password/reset/confirm", data={
"email": "r1@test.com", "token": "tok-valid",
"password": "brand-new-pwd", "confirm_password": "brand-new-pwd",
})
assert resp.status_code == 302
assert "password_reset" in resp.headers["location"]
u = _get_user("r1@test.com")
assert u["reset_token"] is None
# New password must verify
PasswordHasher().verify(u["password_hash"], "brand-new-pwd")
def test_wrong_token_renders_error(self, app_client, fresh_db):
_seed_user("r2@test.com",
reset_token="tok-correct",
reset_token_created=datetime.now(timezone.utc))
resp = app_client.post("/auth/password/reset/confirm", data={
"email": "r2@test.com", "token": "tok-WRONG",
"password": "abcdefgh", "confirm_password": "abcdefgh",
})
assert resp.status_code == 200
assert "Invalid or expired" in resp.text
def test_expired_token_rejected(self, app_client, fresh_db):
_seed_user("r3@test.com",
reset_token="old",
reset_token_created=datetime.now(timezone.utc) - timedelta(days=2))
resp = app_client.post("/auth/password/reset/confirm", data={
"email": "r3@test.com", "token": "old",
"password": "abcdefgh", "confirm_password": "abcdefgh",
})
assert resp.status_code == 200
assert "expired" in resp.text.lower()
def test_password_mismatch(self, app_client, fresh_db):
_seed_user("r4@test.com",
reset_token="t",
reset_token_created=datetime.now(timezone.utc))
resp = app_client.post("/auth/password/reset/confirm", data={
"email": "r4@test.com", "token": "t",
"password": "onething", "confirm_password": "another1",
})
assert resp.status_code == 200
assert "do not match" in resp.text
def test_password_too_short(self, app_client, fresh_db):
_seed_user("r5@test.com",
reset_token="t",
reset_token_created=datetime.now(timezone.utc))
resp = app_client.post("/auth/password/reset/confirm", data={
"email": "r5@test.com", "token": "t",
"password": "short", "confirm_password": "short",
})
assert resp.status_code == 200
assert "at least 8" in resp.text
# ---- POST /auth/password/setup/request ----
class TestSetupRequest:
def test_issues_token_for_pre_approved_user(self, app_client, fresh_db):
_seed_user("invited@test.com") # no password_hash
resp = app_client.post("/auth/password/setup/request", data={"email": "invited@test.com"})
assert resp.status_code == 200
assert "Check your email" in resp.text
u = _get_user("invited@test.com")
assert u["setup_token"]
def test_no_token_for_user_with_password(self, app_client, fresh_db):
from argon2 import PasswordHasher
_seed_user("already@test.com", password_hash=PasswordHasher().hash("x" * 10))
resp = app_client.post("/auth/password/setup/request", data={"email": "already@test.com"})
assert resp.status_code == 200 # anti-enumeration — same response
u = _get_user("already@test.com")
assert u["setup_token"] is None
def test_unknown_email_same_response(self, app_client, fresh_db):
resp = app_client.post("/auth/password/setup/request", data={"email": "who@test.com"})
assert resp.status_code == 200
assert "Check your email" in resp.text
# ---- POST /auth/password/setup/confirm ----
class TestSetupConfirm:
def test_valid_token_sets_password_and_logs_in(self, app_client, fresh_db):
_seed_user(
"s1@test.com",
setup_token="stok",
setup_token_created=datetime.now(timezone.utc),
)
resp = app_client.post("/auth/password/setup/confirm", data={
"email": "s1@test.com", "token": "stok",
"password": "new-password-x", "confirm_password": "new-password-x",
"name": "Seth One",
})
assert resp.status_code == 302
assert resp.headers["location"] == "/dashboard"
assert "access_token" in resp.cookies or "access_token" in resp.headers.get("set-cookie", "")
u = _get_user("s1@test.com")
assert u["setup_token"] is None
assert u["name"] == "Seth One"
from argon2 import PasswordHasher
PasswordHasher().verify(u["password_hash"], "new-password-x")
def test_expired_setup_token(self, app_client, fresh_db):
_seed_user("s2@test.com",
setup_token="stok",
setup_token_created=datetime.now(timezone.utc) - timedelta(days=10))
resp = app_client.post("/auth/password/setup/confirm", data={
"email": "s2@test.com", "token": "stok",
"password": "abcdefgh", "confirm_password": "abcdefgh",
})
assert resp.status_code == 200
assert "expired" in resp.text.lower()
def test_wrong_token(self, app_client, fresh_db):
_seed_user("s3@test.com",
setup_token="right",
setup_token_created=datetime.now(timezone.utc))
resp = app_client.post("/auth/password/setup/confirm", data={
"email": "s3@test.com", "token": "wrong",
"password": "abcdefgh", "confirm_password": "abcdefgh",
})
assert resp.status_code == 200
assert "Invalid" in resp.text
# ---- Admin API: /api/users/{id}/reset-password, send_invite on create ----
class TestAdminInviteFlow:
def test_reset_password_returns_reset_url(self, app_client, fresh_db):
token = _seed_admin()
_seed_user("target@test.com")
target_id = _get_user("target@test.com")["id"]
resp = app_client.post(
f"/api/users/{target_id}/reset-password",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["reset_token"]
assert "reset_url" in data
assert "/auth/password/reset" in data["reset_url"]
assert f"email=target%40test.com" in data["reset_url"]
assert f"token={data['reset_token']}" in data["reset_url"]
assert data["email_sent"] is False # no SMTP configured in tests
def test_create_user_with_send_invite_returns_invite_url(self, app_client, fresh_db):
token = _seed_admin()
resp = app_client.post(
"/api/users",
headers={"Authorization": f"Bearer {token}"},
json={"email": "new@test.com", "name": "New", "role": "analyst", "send_invite": True},
)
assert resp.status_code == 201
data = resp.json()
assert data["invite_url"]
assert "/auth/password/setup" in data["invite_url"]
assert data["invite_email_sent"] is False
# And setup_token is actually stored on the user
u = _get_user("new@test.com")
assert u["setup_token"]
def test_create_user_without_invite_has_no_invite_url(self, app_client, fresh_db):
token = _seed_admin()
resp = app_client.post(
"/api/users",
headers={"Authorization": f"Bearer {token}"},
json={"email": "plain@test.com", "name": "Plain", "role": "analyst"},
)
assert resp.status_code == 201
data = resp.json()
assert data.get("invite_url") is None
assert data.get("invite_email_sent") is None
class TestJsonSetupHardening:
"""The JSON POST /auth/password/setup endpoint must enforce the same token
TTL and active-account gate as the web flow."""
def test_expired_token_rejected(self, app_client, fresh_db):
_seed_user(
"j1@test.com",
setup_token="tok",
setup_token_created=datetime.now(timezone.utc) - timedelta(days=10),
)
resp = app_client.post("/auth/password/setup",
json={"email": "j1@test.com", "token": "tok",
"password": "long-enough-1"})
assert resp.status_code == 400
assert "expired" in resp.json()["detail"].lower()
def test_missing_created_timestamp_rejected(self, app_client, fresh_db):
"""A token row without setup_token_created is treated as invalid — we
cannot verify its age, so it must fail closed."""
_seed_user("j2@test.com", setup_token="tok")
resp = app_client.post("/auth/password/setup",
json={"email": "j2@test.com", "token": "tok",
"password": "long-enough-1"})
assert resp.status_code == 400
def test_deactivated_user_rejected(self, app_client, fresh_db):
uid = _seed_user(
"j3@test.com",
setup_token="tok",
setup_token_created=datetime.now(timezone.utc),
)
# Flip user to inactive
from src.db import get_system_db
from src.repositories.users import UserRepository
conn = get_system_db()
try:
UserRepository(conn).update(id=uid, active=False)
finally:
conn.close()
resp = app_client.post("/auth/password/setup",
json={"email": "j3@test.com", "token": "tok",
"password": "long-enough-1"})
assert resp.status_code == 403
class TestCaseSensitiveEmailLookup:
"""Reset/setup requests must match the codebase's case-sensitive email
lookup lowercasing here would silently fail for mixed-case accounts."""
def test_reset_request_preserves_email_case(self, app_client, fresh_db):
# User stored as-is with mixed-case local-part
_seed_user("User.Mixed@Example.com", password_hash="x")
# Caller submits the same exact case → token must be issued
resp = app_client.post("/auth/password/reset",
data={"email": "User.Mixed@Example.com"})
assert resp.status_code == 200
u = _get_user("User.Mixed@Example.com")
assert u["reset_token"]
def test_reset_request_case_mismatch_still_anti_enumerates(self, app_client, fresh_db):
_seed_user("User.Mixed@Example.com", password_hash="x")
# Wrong case: response is the same (anti-enumeration) and no token is issued
resp = app_client.post("/auth/password/reset",
data={"email": "user.mixed@example.com"})
assert resp.status_code == 200
assert "Check your email" in resp.text
u = _get_user("User.Mixed@Example.com")
assert u["reset_token"] is None