Skip to content

English · Español

Lab 01 — Passwordless onboarding (first login sets the password)

🇪🇸 Una persona nueva entra al portal con su nombre de usuario y nada más. El sistema detecta que aún no hay contraseña y la lleva a una pantalla para crearla. La segunda vez ya tiene que escribirla. La regla: el "primer beso" es sin fricción; lo demás, con Argon2id y pepper.

Goal

Implement the no-password-by-default flow defined in docs/phase-41-learner-portal/theory/03-passwordless-onboarding.md. On first login the user types only a username; if their record has argon2_hash IS NULL AND must_set_on_next_login=TRUE, they are redirected to /set-password, where a server-side Argon2id hash (with global pepper) is computed, written to the secret vault, and the flag flipped. Every transition is audited. Subsequent logins demand the password normally.

Why this lab exists

The classroom audience for this portal is people Borja knows in person (family, students). Forcing them to invent a password before they have even seen the dashboard is the largest single source of "I'll do it later" drop-off in educational tools of this shape. The Phase 41 theory argues that one in-person verbal handshake ("here is your username, log in once and set a password") is a stronger trust signal than a random-link email flow — and is cheaper to operate.

The implementation is small but the security surface is sharp. Argon2id parameters, the pepper-vs-salt split, audit logging of the flag transition, and the redirect-vs-render decision all need to be deliberate. This lab makes them deliberate.

Prerequisites

  • Lab 00 done (/healthz green).
  • Phase 37 sanitizer module exists (src/sanitizer/) — used here for form input.
  • BLUEPRINT for src/miniportal/auth/ written and approved (covers the Argon2id parameters, the pepper storage location, the audit-log schema).
  • students table partially exists from Phase 41 plan §4 schema; column list available even if migrations live in lab 02.

Deliverables

  • src/miniportal/auth/__init__.py — package marker.
  • src/miniportal/auth/passwords.py — Argon2id hasher (params + pepper, no plaintext logged).
  • src/miniportal/auth/sessions.pyitsdangerous signed cookie session.
  • src/miniportal/auth/vault.py — read/write of the pepper + per-secret material.
  • src/miniportal/routes/login.pyGET /login, POST /login, GET /set-password, POST /set-password.
  • src/miniportal/templates/login.html.jinja, templates/set-password.html.jinja.
  • src/miniportal/audit.py — append-only audit log writer (one JSONL file per day).
  • alembic migration creating students.argon2_hash, students.must_set_on_next_login, audit_log table.
  • tests/portal/test_passwordless_first_login.py.
  • tests/portal/test_subsequent_login_requires_password.py.
  • tests/portal/test_audit_log_first_password_set.py.

Step 1 — The Argon2id hasher

# src/miniportal/auth/passwords.py
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError

from miniportal.auth.vault import get_pepper

# Phase 41 BLUEPRINT chooses: time_cost=3, memory_cost=64*1024, parallelism=4, hash_len=32.
# Justification: Borja's i5-8250U benchmark — see experiments/41-onboarding/argon2-bench.md.
_HASHER = PasswordHasher(time_cost=3, memory_cost=64 * 1024, parallelism=4, hash_len=32)


def hash_password(plaintext: str) -> str:
    """Return the Argon2id encoded hash of (plaintext + pepper). Never log plaintext."""
    raise NotImplementedError("Lab 01 step 1 — concatenate plaintext with pepper, pass to hasher.")


def verify_password(plaintext: str, stored: str) -> bool:
    """Constant-time verify. Returns False on mismatch (never raises to caller)."""
    raise NotImplementedError("Lab 01 step 1 — call verify, catch VerifyMismatchError, return False.")

Pepper rules:

  • Loaded from PORTAL_PEPPER env var (read once at app start via auth.vault.get_pepper).
  • Length ≥ 32 bytes (raise on shorter values).
  • Never written to a file the app can read at runtime; lives in the deployment's secret vault (Phase 41 deploy lab finalizes this).
# src/miniportal/auth/sessions.py
from itsdangerous import URLSafeTimedSerializer

from miniportal.settings import PortalSettings


def make_serializer(settings: PortalSettings) -> URLSafeTimedSerializer:
    raise NotImplementedError("Lab 01 step 2 — return URLSafeTimedSerializer(settings.session_secret, salt='miniportal-session').")


def set_session(response, student_id: int, settings: PortalSettings) -> None:
    raise NotImplementedError("Lab 01 step 2 — sign student_id, set HttpOnly Secure SameSite=Lax cookie with max_age=8h.")


def read_session(request, settings: PortalSettings) -> int | None:
    raise NotImplementedError("Lab 01 step 2 — read cookie, verify signature + age; return student_id or None.")

Cookie flags: HttpOnly is non-negotiable; Secure is on whenever settings.env != "dev"; SameSite=Lax is the chosen default (Phase 41 theory §03 §csrf discusses why not Strict).

Step 3 — The login routes

# src/miniportal/routes/login.py
from fastapi import APIRouter, Form, Request
from fastapi.responses import RedirectResponse

router = APIRouter()


@router.get("/login")
async def login_form(request: Request):
    raise NotImplementedError("Lab 01 step 3 — render login.html.jinja, single username field.")


@router.post("/login")
async def login_submit(request: Request, username: str = Form(...)):
    """Decision tree:

    1. Username does not exist                  -> render login with neutral error (no user enumeration).
    2. argon2_hash IS NULL                      -> redirect to /set-password?u=<id-token>.
    3. argon2_hash IS NOT NULL, password absent -> render login.html with password field.
    4. password provided + verifies             -> set session cookie, redirect to /.
    """
    raise NotImplementedError("Lab 01 step 3 — implement the four-branch decision tree.")


@router.get("/set-password")
async def set_password_form(request: Request, u: str):
    """`u` is a single-use signed token (max_age=10min) referencing the student.
    Never include the bare student_id in a URL."""
    raise NotImplementedError("Lab 01 step 3 — verify token, render set-password.html.")


@router.post("/set-password")
async def set_password_submit(request: Request, u: str = Form(...), password: str = Form(...), confirm: str = Form(...)):
    """Validate strength (Phase 37 sanitizer + zxcvbn or equivalent), hash, write to vault row,
    flip must_set_on_next_login=FALSE, audit, redirect to /."""
    raise NotImplementedError("Lab 01 step 3 — full set-password flow.")

User-enumeration safety: in branch 1 the response page MUST be byte-for-byte identical to branch 3's error response. Time-equivalence is best-effort but not the primary defense; the primary defense is "same body, same status, same headers."

Step 4 — Audit log

# src/miniportal/audit.py
from pathlib import Path
import json, time


def audit(event: str, *, student_id: int | None = None, request_id: str | None = None, **fields) -> None:
    """Append a JSONL row to var/audit/YYYY-MM-DD.log. Never raises."""
    raise NotImplementedError("Lab 01 step 4 — open file in append mode, write JSON line, fsync.")

Events emitted by this lab:

  • auth.login.attempt (success/fail per branch — no plaintext password ever in fields).
  • auth.first_password_set (student_id, request_id, argon2_params_version).
  • auth.session.start.

Step 5 — The migration

# alembic/versions/<rev>_passwordless_onboarding.py
def upgrade() -> None:
    raise NotImplementedError("Lab 01 step 5 — add columns argon2_hash NULLABLE, must_set_on_next_login BOOL DEFAULT TRUE; create audit_log if not exists.")


def downgrade() -> None:
    raise NotImplementedError("Lab 01 step 5 — drop the two columns; keep audit_log (one-way).")

downgrade deliberately does not drop the audit log — losing audit history during a rollback is worse than leaving an empty table behind.

Step 6 — The three tests

# tests/portal/test_passwordless_first_login.py
def test_first_login_redirects_to_set_password():
    raise NotImplementedError(
        "Seed a student with argon2_hash=NULL, must_set=TRUE. "
        "POST /login with username only. Assert 302 -> /set-password?u=...; "
        "assert no Set-Cookie session yet."
    )


def test_set_password_writes_hash_and_flips_flag():
    raise NotImplementedError(
        "POST /set-password with valid token + matching password+confirm. "
        "Assert: argon2_hash is not NULL; must_set_on_next_login is FALSE; "
        "audit_log has auth.first_password_set row."
    )
# tests/portal/test_subsequent_login_requires_password.py
def test_login_after_first_set_demands_password():
    raise NotImplementedError(
        "Seed a student already through onboarding. POST /login with username only -> "
        "response is the login page with password field visible (status 200, not 302)."
    )


def test_wrong_password_returns_neutral_error():
    raise NotImplementedError(
        "POST /login with correct username + wrong password. Response status, body, and headers "
        "must equal those of POST /login with a nonexistent username."
    )
# tests/portal/test_audit_log_first_password_set.py
def test_audit_event_no_plaintext():
    raise NotImplementedError(
        "Run the first-password-set flow with password='hunter2'. Read today's audit JSONL. "
        "Assert: exactly one row with event='auth.first_password_set'; the row does NOT contain 'hunter2' "
        "anywhere in its serialized form."
    )

What "done" looks like

  • alembic upgrade head applies cleanly on a fresh DB.
  • First-login flow: username-only POST redirects to /set-password.
  • Set-password flow: writes Argon2id hash, flips flag, audits.
  • Subsequent login demands a password.
  • User-enumeration test passes (response equivalence for unknown user vs wrong password).
  • Audit log contains the event and never the plaintext.
  • bandit -r src/miniportal/auth/ reports zero high findings; pepper is not hardcoded.
  • mypy --strict src/miniportal/auth/ is green.
  • Argon2 benchmark notes recorded in experiments/41-onboarding/argon2-bench.md (justifies the parameter choice for Borja's hardware).

Common pitfalls

  1. Returning different status codes for unknown vs known users. Classic user enumeration. Same body, same status, same headers — both branches.
  2. Logging the form payload. A request log that captures the POST body leaks the plaintext password to disk. Configure the request logger to redact form fields named password/confirm.
  3. Forgetting the pepper. Argon2 with no pepper is fine — until the DB is exfiltrated and the salts are right there in the same file. The pepper lives outside the DB.
  4. Putting the student_id in the set-password URL. Use a signed, short-TTL token. The bare ID makes enumerable URLs.
  5. Skipping the strength check. Borja's instinct is "we're among friends, any password is fine." Theory §03 disagrees: the friend's password gets reused on their bank. The strength check is the cheapest favor we do for them.
  6. One audit file per row. Filesystem will complain at scale. One file per day, append-only, JSONL.

Next: lab/02-multi-student-profiles.md — the students table and the role enum.