porchlight/docs/plans/2026-03-25-form-validation-hardening-plan.md
2026-04-10 11:28:51 +02:00

47 KiB

Form Validation Hardening Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Harden form validation across the application — fix security gaps (inactive user login, rate limiting, password max length), add missing validation (username, groups, locale, password strength), deduplicate profile validation, require current password on change, add CSRF tokens to admin forms, and display all validation errors.

Architecture: Extend src/porchlight/validation.py with new Pydantic models and a shared error-formatting helper. Add slowapi middleware for rate limiting. Add zxcvbn for password strength. Update route handlers to use the new models and helper. Update templates for CSRF tokens and password UI.

Tech Stack: Pydantic v2, slowapi (rate limiting), zxcvbn (password strength), FastAPI, HTMX.


Task 1: Add slowapi and zxcvbn dependencies

Files:

  • Modify: pyproject.toml:7-22

  • Step 1: Add dependencies

In pyproject.toml, add to the dependencies list:

    "slowapi>=0.1.9",
    "zxcvbn>=4.5",
  • Step 2: Install

Run: uv sync

  • Step 3: Verify imports

Run:

uv run python -c "from slowapi import Limiter; print('slowapi OK')"
uv run python -c "from zxcvbn import zxcvbn; print('zxcvbn OK')"

Expected: Both print OK.

  • Step 4: Commit
git add pyproject.toml uv.lock
git commit -m "build: add slowapi and zxcvbn dependencies"

Task 2: Add locale BCP 47 validator to ProfileUpdate

Files:

  • Modify: src/porchlight/validation.py:1-42

  • Modify: tests/test_validation.py

  • Step 1: Write the failing tests

Add to tests/test_validation.py:

class TestProfileUpdateLocale:
    def test_valid_locale_language_only(self) -> None:
        p = ProfileUpdate(locale="en")
        assert p.locale == "en"

    def test_valid_locale_language_region(self) -> None:
        p = ProfileUpdate(locale="sv-SE")
        assert p.locale == "sv-SE"

    def test_valid_locale_language_script_region(self) -> None:
        p = ProfileUpdate(locale="zh-Hans-CN")
        assert p.locale == "zh-Hans-CN"

    def test_valid_locale_three_letter(self) -> None:
        p = ProfileUpdate(locale="gsw")
        assert p.locale == "gsw"

    def test_empty_locale_allowed(self) -> None:
        p = ProfileUpdate(locale="")
        assert p.locale == ""

    def test_invalid_locale_rejected(self) -> None:
        with pytest.raises(ValidationError, match="locale"):
            ProfileUpdate(locale="not_a_locale!")

    def test_invalid_locale_numbers(self) -> None:
        with pytest.raises(ValidationError, match="locale"):
            ProfileUpdate(locale="12345")

    def test_whitespace_locale_becomes_empty(self) -> None:
        p = ProfileUpdate(locale="  ")
        assert p.locale == ""
  • Step 2: Run tests to verify they fail

Run: uv run pytest tests/test_validation.py::TestProfileUpdateLocale -v Expected: FAIL (no locale validation exists yet)

  • Step 3: Add locale validator

In src/porchlight/validation.py, add at the top:

import re

And add a validator to ProfileUpdate:

    @field_validator("locale", mode="before")
    @classmethod
    def validate_locale(cls, v: str) -> str:
        if isinstance(v, str):
            v = v.strip()
            if v == "":
                return ""
            # BCP 47 simplified: language(-Script)?(-REGION)?
            if not re.match(r"^[a-z]{2,3}(-[A-Z][a-z]{3})?(-[A-Z]{2})?$", v):
                raise ValueError("Locale must be a valid BCP 47 language tag (e.g. en, sv-SE, zh-Hans-CN)")
        return v
  • Step 4: Run tests to verify they pass

Run: uv run pytest tests/test_validation.py::TestProfileUpdateLocale -v Expected: PASS

  • Step 5: Commit
git add src/porchlight/validation.py tests/test_validation.py
git commit -m "feat: add BCP 47 locale validation to ProfileUpdate"

Task 3: Add username validation model

Files:

  • Modify: src/porchlight/validation.py

  • Modify: tests/test_validation.py

  • Step 1: Write the failing tests

Add to tests/test_validation.py:

from porchlight.validation import UsernameInput


class TestUsernameInput:
    def test_valid_simple(self) -> None:
        u = UsernameInput(username="alice")
        assert u.username == "alice"

    def test_valid_with_dots_dashes_underscores(self) -> None:
        u = UsernameInput(username="alice.bob_charlie-1")
        assert u.username == "alice.bob_charlie-1"

    def test_valid_email_style(self) -> None:
        u = UsernameInput(username="user@example.com")
        assert u.username == "user@example.com"

    def test_valid_uppercase(self) -> None:
        u = UsernameInput(username="Alice")
        assert u.username == "Alice"

    def test_empty_rejected(self) -> None:
        with pytest.raises(ValidationError, match="username"):
            UsernameInput(username="")

    def test_whitespace_only_rejected(self) -> None:
        with pytest.raises(ValidationError, match="username"):
            UsernameInput(username="   ")

    def test_too_long_rejected(self) -> None:
        with pytest.raises(ValidationError):
            UsernameInput(username="a" * 256)

    def test_spaces_rejected(self) -> None:
        with pytest.raises(ValidationError, match="username"):
            UsernameInput(username="alice bob")

    def test_special_chars_rejected(self) -> None:
        with pytest.raises(ValidationError, match="username"):
            UsernameInput(username="alice<script>")

    def test_strips_whitespace(self) -> None:
        u = UsernameInput(username="  alice  ")
        assert u.username == "alice"
  • Step 2: Run tests to verify they fail

Run: uv run pytest tests/test_validation.py::TestUsernameInput -v Expected: FAIL (UsernameInput doesn't exist)

  • Step 3: Add UsernameInput model

In src/porchlight/validation.py, add:

class UsernameInput(BaseModel):
    username: str = Field(max_length=255)

    @field_validator("username", mode="before")
    @classmethod
    def validate_username(cls, v: str) -> str:
        if isinstance(v, str):
            v = v.strip()
            if not v:
                raise ValueError("Username is required")
            if not re.match(r"^[a-zA-Z0-9_.@-]+$", v):
                raise ValueError("Username may only contain letters, digits, dots, hyphens, underscores, and @")
        return v
  • Step 4: Run tests to verify they pass

Run: uv run pytest tests/test_validation.py::TestUsernameInput -v Expected: PASS

  • Step 5: Commit
git add src/porchlight/validation.py tests/test_validation.py
git commit -m "feat: add UsernameInput validation model"

Task 4: Add group name validation model

Files:

  • Modify: src/porchlight/validation.py

  • Modify: tests/test_validation.py

  • Step 1: Write the failing tests

Add to tests/test_validation.py:

from porchlight.validation import GroupListInput


class TestGroupListInput:
    def test_valid_single_group(self) -> None:
        g = GroupListInput(groups="users")
        assert g.group_list == ["users"]

    def test_valid_multiple_groups(self) -> None:
        g = GroupListInput(groups="users, admin, staff")
        assert g.group_list == ["users", "admin", "staff"]

    def test_empty_string_gives_empty_list(self) -> None:
        g = GroupListInput(groups="")
        assert g.group_list == []

    def test_whitespace_only_gives_empty_list(self) -> None:
        g = GroupListInput(groups="  ,  , ")
        assert g.group_list == []

    def test_valid_with_hyphens_underscores(self) -> None:
        g = GroupListInput(groups="my-group, my_group")
        assert g.group_list == ["my-group", "my_group"]

    def test_invalid_group_name_spaces(self) -> None:
        with pytest.raises(ValidationError, match="group"):
            GroupListInput(groups="bad group")

    def test_invalid_group_name_special_chars(self) -> None:
        with pytest.raises(ValidationError, match="group"):
            GroupListInput(groups="admin, bad!group")

    def test_invalid_group_name_uppercase(self) -> None:
        with pytest.raises(ValidationError, match="group"):
            GroupListInput(groups="Admin")

    def test_group_name_too_long(self) -> None:
        with pytest.raises(ValidationError, match="group"):
            GroupListInput(groups="a" * 65)

    def test_deduplicates(self) -> None:
        g = GroupListInput(groups="users, users, admin")
        assert g.group_list == ["users", "admin"]
  • Step 2: Run tests to verify they fail

Run: uv run pytest tests/test_validation.py::TestGroupListInput -v Expected: FAIL (GroupListInput doesn't exist)

  • Step 3: Add GroupListInput model

In src/porchlight/validation.py, add:

class GroupListInput(BaseModel):
    groups: str = ""

    @property
    def group_list(self) -> list[str]:
        """Parse comma-separated groups into a deduplicated list."""
        seen: set[str] = set()
        result: list[str] = []
        for g in (g.strip() for g in self.groups.split(",") if g.strip()):
            if g not in seen:
                seen.add(g)
                result.append(g)
        return result

    @field_validator("groups", mode="before")
    @classmethod
    def validate_groups(cls, v: str) -> str:
        if isinstance(v, str):
            names = [g.strip() for g in v.split(",") if g.strip()]
            for name in names:
                if not re.match(r"^[a-z0-9_-]{1,64}$", name):
                    raise ValueError(
                        f"Invalid group name '{name}'. "
                        "Groups must be 1-64 lowercase letters, digits, hyphens, or underscores."
                    )
        return v
  • Step 4: Run tests to verify they pass

Run: uv run pytest tests/test_validation.py::TestGroupListInput -v Expected: PASS

  • Step 5: Commit
git add src/porchlight/validation.py tests/test_validation.py
git commit -m "feat: add GroupListInput validation model"

Task 5: Add password validation models with zxcvbn

Files:

  • Modify: src/porchlight/validation.py

  • Modify: tests/test_validation.py

  • Step 1: Write the failing tests

Add to tests/test_validation.py:

from porchlight.validation import PasswordSet, PasswordChange


class TestPasswordSet:
    def test_valid_password(self) -> None:
        p = PasswordSet(password="strongP@ss123", confirm="strongP@ss123")
        assert p.password == "strongP@ss123"

    def test_mismatch_rejected(self) -> None:
        with pytest.raises(ValidationError, match="match"):
            PasswordSet(password="strongP@ss123", confirm="different")

    def test_too_short_rejected(self) -> None:
        with pytest.raises(ValidationError):
            PasswordSet(password="short", confirm="short")

    def test_too_long_rejected(self) -> None:
        long_pw = "a" * 257
        with pytest.raises(ValidationError):
            PasswordSet(password=long_pw, confirm=long_pw)

    def test_weak_password_rejected(self) -> None:
        with pytest.raises(ValidationError, match="[Ww]eak\\b|[Ss]trength|easily guessed"):
            PasswordSet(password="password", confirm="password")

    def test_common_password_rejected(self) -> None:
        with pytest.raises(ValidationError, match="[Ww]eak\\b|[Ss]trength|easily guessed"):
            PasswordSet(password="12345678", confirm="12345678")


class TestPasswordChange:
    def test_valid_change(self) -> None:
        p = PasswordChange(
            current_password="oldPassword1",
            password="newStrongP@ss99",
            confirm="newStrongP@ss99",
        )
        assert p.current_password == "oldPassword1"
        assert p.password == "newStrongP@ss99"

    def test_missing_current_password(self) -> None:
        with pytest.raises(ValidationError, match="current"):
            PasswordChange(
                current_password="",
                password="newStrongP@ss99",
                confirm="newStrongP@ss99",
            )
  • Step 2: Run tests to verify they fail

Run: uv run pytest tests/test_validation.py::TestPasswordSet -v Expected: FAIL (PasswordSet doesn't exist)

  • Step 3: Add password models

In src/porchlight/validation.py, add at the top:

from zxcvbn import zxcvbn

And add the models:

class PasswordSet(BaseModel):
    password: str = Field(min_length=8, max_length=256)
    confirm: str

    @model_validator(mode="after")
    def validate_password(self) -> "PasswordSet":
        if self.password != self.confirm:
            raise ValueError("Passwords do not match")
        result = zxcvbn(self.password)
        if result["score"] < 2:
            feedback = result.get("feedback", {})
            warning = feedback.get("warning", "")
            suggestions = feedback.get("suggestions", [])
            msg = "Password is too easily guessed."
            if warning:
                msg += f" {warning}."
            if suggestions:
                msg += " " + " ".join(suggestions)
            raise ValueError(msg)
        return self


class PasswordChange(PasswordSet):
    current_password: str

    @field_validator("current_password", mode="before")
    @classmethod
    def validate_current_password(cls, v: str) -> str:
        if isinstance(v, str) and v.strip() == "":
            raise ValueError("Current password is required")
        return v

Note: also add model_validator to the pydantic import at the top of the file:

from pydantic import BaseModel, EmailStr, Field, field_validator, model_validator
  • Step 4: Run tests to verify they pass

Run: uv run pytest tests/test_validation.py::TestPasswordSet tests/test_validation.py::TestPasswordChange -v Expected: PASS

  • Step 5: Commit
git add src/porchlight/validation.py tests/test_validation.py
git commit -m "feat: add password validation models with zxcvbn strength check"

Task 6: Add shared validation error formatting helper

Files:

  • Modify: src/porchlight/validation.py

  • Modify: tests/test_validation.py

  • Step 1: Write the failing tests

Add to tests/test_validation.py:

from porchlight.validation import format_validation_errors


class TestFormatValidationErrors:
    def test_single_error_no_list(self) -> None:
        try:
            ProfileUpdate(email="bad")
        except ValidationError as exc:
            result = format_validation_errors(exc)
            assert '<div role="alert">' in result
            assert "<ul>" not in result  # single error, no list
            assert "Email" in result

    def test_multiple_errors_uses_list(self) -> None:
        try:
            ProfileUpdate(email="bad", phone_number="bad")
        except ValidationError as exc:
            result = format_validation_errors(exc)
            assert '<div role="alert">' in result
            assert "<ul>" in result
            assert "<li>" in result
            assert "Email" in result
            assert "Phone" in result

    def test_value_error_strips_prefix(self) -> None:
        try:
            ProfileUpdate(picture="ftp://bad.url")
        except ValidationError as exc:
            result = format_validation_errors(exc)
            assert "Picture URL must be a valid HTTP or HTTPS URL" in result
            assert "Value error" not in result
  • Step 2: Run tests to verify they fail

Run: uv run pytest tests/test_validation.py::TestFormatValidationErrors -v Expected: FAIL (format_validation_errors doesn't exist)

  • Step 3: Add the helper function

In src/porchlight/validation.py, add:

# Field label mapping for user-friendly error messages
FIELD_LABELS: dict[str, str] = {
    "given_name": "Given name",
    "family_name": "Family name",
    "preferred_username": "Display name",
    "email": "Email",
    "phone_number": "Phone number",
    "picture": "Picture URL",
    "locale": "Locale",
    "username": "Username",
    "groups": "Groups",
    "password": "Password",
    "confirm": "Confirm password",
    "current_password": "Current password",
}


def format_validation_errors(exc: ValidationError) -> str:
    """Format Pydantic ValidationError into user-friendly HTML.

    Returns a single <div role="alert"> with either a plain message (one error)
    or an unordered list (multiple errors).
    """
    messages: list[str] = []
    for error in exc.errors():
        field = str(error["loc"][-1]) if error["loc"] else "input"
        label = FIELD_LABELS.get(field, field)
        msg = error["msg"]
        if error["type"] == "value_error":
            display_msg = msg.removeprefix("Value error, ")
        else:
            display_msg = f"{label}: {msg}"
        messages.append(display_msg)

    if len(messages) == 1:
        return f'<div role="alert">{messages[0]}</div>'

    items = "".join(f"<li>{m}</li>" for m in messages)
    return f'<div role="alert"><ul>{items}</ul></div>'

Also add ValidationError to the pydantic import:

from pydantic import BaseModel, EmailStr, Field, ValidationError, field_validator, model_validator
  • Step 4: Run tests to verify they pass

Run: uv run pytest tests/test_validation.py::TestFormatValidationErrors -v Expected: PASS

  • Step 5: Commit
git add src/porchlight/validation.py tests/test_validation.py
git commit -m "feat: add shared validation error formatting helper"

Task 7: Block inactive users from logging in

Files:

  • Modify: src/porchlight/authn/routes.py:30-58,69-91,104-153

  • Create: tests/test_authn_active.py

  • Step 1: Write the failing tests

Create tests/test_authn_active.py:

from datetime import UTC, datetime

import pytest
from httpx import AsyncClient

from porchlight.authn.password import PasswordHasher, PasswordService
from porchlight.models import PasswordCredential, User

from tests.conftest import get_csrf_token


async def _create_inactive_user_with_password(client: AsyncClient) -> None:
    """Create an inactive user with a password credential."""
    app = client._transport.app
    user_repo = app.state.user_repo
    cred_repo = app.state.credential_repo

    user = User(
        userid="inactive-01",
        username="inactive",
        active=False,
        created_at=datetime.now(UTC),
        updated_at=datetime.now(UTC),
    )
    await user_repo.create(user)

    svc = PasswordService(hasher=PasswordHasher(time_cost=1, memory_cost=8192))
    await cred_repo.create_password(
        PasswordCredential(user_id=user.userid, password_hash=svc.hash("password123!Secure"))
    )


@pytest.mark.asyncio
async def test_inactive_user_cannot_login_password(client: AsyncClient) -> None:
    await _create_inactive_user_with_password(client)
    token = await get_csrf_token(client)

    response = await client.post(
        "/login/password",
        data={"username": "inactive", "password": "password123!Secure"},
        headers={"HX-Request": "true", "X-CSRF-Token": token},
    )

    assert "Invalid username or password" in response.text


@pytest.mark.asyncio
async def test_inactive_user_cannot_register_magic_link(client: AsyncClient) -> None:
    """If an inactive user exists, magic link registration should reject them."""
    app = client._transport.app
    user_repo = app.state.user_repo
    magic_link_service = app.state.magic_link_service

    user = User(
        userid="inactive-02",
        username="deactivated",
        active=False,
        created_at=datetime.now(UTC),
        updated_at=datetime.now(UTC),
    )
    await user_repo.create(user)

    link = await magic_link_service.create(
        username="deactivated", created_by="admin", note="test"
    )

    response = await client.get(f"/register/{link.token}", follow_redirects=False)

    # Should not redirect to credentials setup — should return error
    assert response.status_code == 400 or "deactivated" in response.text.lower() or "Invalid" in response.text
  • Step 2: Run tests to verify they fail

Run: uv run pytest tests/test_authn_active.py -v Expected: FAIL (inactive user can currently log in)

  • Step 3: Add active check to login_password

In src/porchlight/authn/routes.py, in the login_password function, add after the password verify check (after line 51, before setting session):

    if not user.active:
        return HTMLResponse(error_html)
  • Step 4: Add active check to login_webauthn_complete

In src/porchlight/authn/routes.py, in the login_webauthn_complete function, after the line user = await user_repo.get_by_userid(userid) (line 146) and the None check (line 147-148), add:

    if not user.active:
        return JSONResponse({"error": "Authentication failed"}, status_code=400)
  • Step 5: Add active check to register_magic_link

In src/porchlight/authn/routes.py, in the register_magic_link function, after checking for existing user (after line 80, inside the if existing_user is not None block), add:

    if existing_user is not None:
        if not existing_user.active:
            return HTMLResponse("<p>This account has been deactivated.</p>", status_code=400)
        user = existing_user
  • Step 6: Run tests to verify they pass

Run: uv run pytest tests/test_authn_active.py -v Expected: PASS

  • Step 7: Run all tests

Run: uv run pytest -v Expected: All pass

  • Step 8: Commit
git add src/porchlight/authn/routes.py tests/test_authn_active.py
git commit -m "fix: block inactive users from all authentication paths"

Task 8: Add rate limiting middleware

Files:

  • Modify: src/porchlight/app.py:1-153

  • Modify: src/porchlight/authn/routes.py:30-58,104-153

  • Create: tests/test_rate_limit.py

  • Step 1: Write the failing tests

Create tests/test_rate_limit.py:

import pytest
from httpx import AsyncClient

from tests.conftest import get_csrf_token


@pytest.mark.asyncio
async def test_password_login_rate_limited(client: AsyncClient) -> None:
    """After 5 failed attempts, the 6th should be rate-limited."""
    token = await get_csrf_token(client)

    for _ in range(5):
        await client.post(
            "/login/password",
            data={"username": "nobody", "password": "wrong"},
            headers={"X-CSRF-Token": token},
        )

    response = await client.post(
        "/login/password",
        data={"username": "nobody", "password": "wrong"},
        headers={"X-CSRF-Token": token},
    )

    assert response.status_code == 429
  • Step 2: Run test to verify it fails

Run: uv run pytest tests/test_rate_limit.py -v Expected: FAIL (no rate limiting yet)

  • Step 3: Create rate_limit module

Create src/porchlight/rate_limit.py:

from slowapi import Limiter
from slowapi.util import get_remote_address

limiter = Limiter(key_func=get_remote_address)
  • Step 4: Wire rate limiter into app.py

In src/porchlight/app.py, add imports:

from slowapi.errors import RateLimitExceeded
from starlette.responses import HTMLResponse as StarletteHTMLResponse

from porchlight.rate_limit import limiter

In the create_app function, after the middleware section (after line 124), add:

    # Rate limiting
    app.state.limiter = limiter

    @app.exception_handler(RateLimitExceeded)
    async def rate_limit_handler(request: Request, exc: RateLimitExceeded) -> StarletteHTMLResponse:
        return StarletteHTMLResponse(
            '<div role="alert">Too many attempts. Please try again later.</div>',
            status_code=429,
        )
  • Step 5: Add rate limit decorators to auth routes

In src/porchlight/authn/routes.py, add import:

from porchlight.rate_limit import limiter

Add @limiter.limit("5/minute") decorator to login_password (between @router.post(...) and async def):

@router.post("/login/password", response_class=HTMLResponse)
@limiter.limit("5/minute")
async def login_password(
    request: Request,
    username: str = Form(),
    password: str = Form(),
) -> Response:
    ...

Add @limiter.limit("10/minute") decorator to login_webauthn_complete:

@router.post("/login/webauthn/complete")
@limiter.limit("10/minute")
async def login_webauthn_complete(request: Request) -> Response:
    ...
  • Step 6: Run test to verify it passes

Run: uv run pytest tests/test_rate_limit.py -v Expected: PASS

  • Step 7: Run all tests

Run: uv run pytest -v Expected: All pass

  • Step 8: Commit
git add src/porchlight/rate_limit.py src/porchlight/app.py src/porchlight/authn/routes.py tests/test_rate_limit.py
git commit -m "feat: add rate limiting middleware for authentication endpoints"

Task 9: Deduplicate profile validation and use shared error helper

Files:

  • Modify: src/porchlight/manage/routes.py:200-262

  • Modify: src/porchlight/admin/routes.py:127-191

  • Modify: tests/test_manage_profile.py (verify existing tests still pass)

  • Step 1: Update manage/routes.py to use shared helper

In src/porchlight/manage/routes.py, update the import:

from porchlight.validation import ProfileUpdate, format_validation_errors

Replace the except ValidationError block in update_profile (lines 227-244) with:

    except ValidationError as exc:
        return HTMLResponse(format_validation_errors(exc))

Remove the now-unused ValidationError import from pydantic (it's re-exported from validation.py), or keep it — the import from validation.py is cleaner. Actually, ValidationError is still needed from pydantic for the except clause. The import is fine.

  • Step 2: Update admin/routes.py to use shared helper

In src/porchlight/admin/routes.py, update the import:

from porchlight.validation import ProfileUpdate, format_validation_errors

Replace the except ValidationError block in update_user_profile (lines 157-172) with:

    except ValidationError as exc:
        return HTMLResponse(format_validation_errors(exc))
  • Step 3: Run existing tests

Run: uv run pytest tests/test_manage_profile.py -v Expected: All pass (behavior is backward-compatible, though now multiple errors may be shown)

  • Step 4: Run all tests

Run: uv run pytest -v Expected: All pass

  • Step 5: Commit
git add src/porchlight/manage/routes.py src/porchlight/admin/routes.py
git commit -m "refactor: deduplicate profile validation using shared error formatter"

Task 10: Wire username validation into admin invite route

Files:

  • Modify: src/porchlight/admin/routes.py:99-123

  • Create: tests/test_admin_invite_validation.py

  • Step 1: Write the failing tests

Create tests/test_admin_invite_validation.py:

from datetime import UTC, datetime

import pytest
from httpx import AsyncClient

from porchlight.authn.password import PasswordHasher, PasswordService
from porchlight.models import PasswordCredential, User

from tests.conftest import get_csrf_token


async def _login_admin(client: AsyncClient) -> str:
    """Create and login as admin user, return CSRF token."""
    app = client._transport.app
    user_repo = app.state.user_repo
    cred_repo = app.state.credential_repo

    user = User(
        userid="admin-01",
        username="admin",
        groups=["admin", "users"],
        created_at=datetime.now(UTC),
        updated_at=datetime.now(UTC),
    )
    await user_repo.create(user)

    svc = PasswordService(hasher=PasswordHasher(time_cost=1, memory_cost=8192))
    await cred_repo.create_password(
        PasswordCredential(user_id=user.userid, password_hash=svc.hash("AdminPass123!"))
    )

    token = await get_csrf_token(client)
    await client.post(
        "/login/password",
        data={"username": "admin", "password": "AdminPass123!"},
        headers={"HX-Request": "true", "X-CSRF-Token": token},
    )
    return token


@pytest.mark.asyncio
async def test_invite_valid_username(client: AsyncClient) -> None:
    token = await _login_admin(client)
    response = await client.post(
        "/admin/invite",
        data={"username": "newuser@example.com"},
        headers={"X-CSRF-Token": token},
    )
    assert response.status_code == 200
    assert "Invite created" in response.text


@pytest.mark.asyncio
async def test_invite_empty_username_rejected(client: AsyncClient) -> None:
    token = await _login_admin(client)
    response = await client.post(
        "/admin/invite",
        data={"username": ""},
        headers={"X-CSRF-Token": token},
    )
    assert "alert" in response.text


@pytest.mark.asyncio
async def test_invite_invalid_username_rejected(client: AsyncClient) -> None:
    token = await _login_admin(client)
    response = await client.post(
        "/admin/invite",
        data={"username": "bad user<script>"},
        headers={"X-CSRF-Token": token},
    )
    assert "alert" in response.text
    assert "letters" in response.text.lower() or "Username" in response.text
  • Step 2: Run tests to verify they fail

Run: uv run pytest tests/test_admin_invite_validation.py -v Expected: The invalid username test fails (currently any non-empty string is accepted)

  • Step 3: Wire UsernameInput into create_invite route

In src/porchlight/admin/routes.py, add import:

from porchlight.validation import UsernameInput, format_validation_errors

Update create_invite to use the model:

@router.post("/invite", response_class=HTMLResponse)
async def create_invite(
    request: Request,
    username: str = Form(),
) -> Response:
    session_user = get_session_user(request)
    if session_user is None:
        return RedirectResponse("/login", status_code=303)

    admin = await _get_admin_user(request)
    if admin is None:
        return HTMLResponse("Forbidden", status_code=403)

    try:
        validated = UsernameInput(username=username)
    except ValidationError as exc:
        return HTMLResponse(format_validation_errors(exc))

    magic_link_service = request.app.state.magic_link_service
    settings = request.app.state.settings
    link = await magic_link_service.create(
        username=validated.username, created_by=admin.username, note="admin invite"
    )
    url = f"{settings.issuer}/register/{link.token}"

    return HTMLResponse(
        f'<div role="status">Invite created for <strong>{validated.username}</strong>:</div>'
        f'<div class="invite-url">{url}</div>'
    )
  • Step 4: Run tests to verify they pass

Run: uv run pytest tests/test_admin_invite_validation.py -v Expected: PASS

  • Step 5: Commit
git add src/porchlight/admin/routes.py tests/test_admin_invite_validation.py
git commit -m "feat: validate username format in admin invite route"

Task 11: Wire group validation into admin groups route

Files:

  • Modify: src/porchlight/admin/routes.py:195-216

  • Create: tests/test_admin_groups_validation.py

  • Step 1: Write the failing tests

Create tests/test_admin_groups_validation.py:

from datetime import UTC, datetime

import pytest
from httpx import AsyncClient

from porchlight.authn.password import PasswordHasher, PasswordService
from porchlight.models import PasswordCredential, User

from tests.conftest import get_csrf_token


async def _setup_admin_and_target(client: AsyncClient) -> tuple[str, str]:
    """Create admin + target user, login as admin, return (token, target_userid)."""
    app = client._transport.app
    user_repo = app.state.user_repo
    cred_repo = app.state.credential_repo

    admin = User(
        userid="admin-g01",
        username="admin_g",
        groups=["admin", "users"],
        created_at=datetime.now(UTC),
        updated_at=datetime.now(UTC),
    )
    await user_repo.create(admin)

    target = User(
        userid="target-g01",
        username="target_g",
        groups=["users"],
        created_at=datetime.now(UTC),
        updated_at=datetime.now(UTC),
    )
    await user_repo.create(target)

    svc = PasswordService(hasher=PasswordHasher(time_cost=1, memory_cost=8192))
    await cred_repo.create_password(
        PasswordCredential(user_id=admin.userid, password_hash=svc.hash("AdminPass123!"))
    )

    token = await get_csrf_token(client)
    await client.post(
        "/login/password",
        data={"username": "admin_g", "password": "AdminPass123!"},
        headers={"HX-Request": "true", "X-CSRF-Token": token},
    )
    return token, target.userid


@pytest.mark.asyncio
async def test_valid_groups(client: AsyncClient) -> None:
    token, userid = await _setup_admin_and_target(client)
    response = await client.post(
        f"/admin/users/{userid}/groups",
        data={"groups": "users, staff"},
        headers={"X-CSRF-Token": token},
    )
    assert "Groups updated" in response.text

    app = client._transport.app
    user = await app.state.user_repo.get_by_userid(userid)
    assert user.groups == ["users", "staff"]


@pytest.mark.asyncio
async def test_invalid_group_name_rejected(client: AsyncClient) -> None:
    token, userid = await _setup_admin_and_target(client)
    response = await client.post(
        f"/admin/users/{userid}/groups",
        data={"groups": "users, Bad Group!"},
        headers={"X-CSRF-Token": token},
    )
    assert "alert" in response.text


@pytest.mark.asyncio
async def test_empty_groups_clears(client: AsyncClient) -> None:
    token, userid = await _setup_admin_and_target(client)
    response = await client.post(
        f"/admin/users/{userid}/groups",
        data={"groups": ""},
        headers={"X-CSRF-Token": token},
    )
    assert "Groups updated" in response.text

    app = client._transport.app
    user = await app.state.user_repo.get_by_userid(userid)
    assert user.groups == []
  • Step 2: Run tests to verify they fail

Run: uv run pytest tests/test_admin_groups_validation.py -v Expected: The invalid group name test fails

  • Step 3: Wire GroupListInput into update_user_groups

In src/porchlight/admin/routes.py, update imports to include GroupListInput:

from porchlight.validation import GroupListInput, ProfileUpdate, UsernameInput, format_validation_errors

Update update_user_groups:

@router.post("/users/{userid}/groups", response_class=HTMLResponse)
async def update_user_groups(
    request: Request,
    userid: str,
    groups: str = Form(""),
) -> Response:
    session_user = get_session_user(request)
    if session_user is None:
        return RedirectResponse("/login", status_code=303)
    admin = await _get_admin_user(request)
    if admin is None:
        return HTMLResponse("Forbidden", status_code=403)

    try:
        validated = GroupListInput(groups=groups)
    except ValidationError as exc:
        return HTMLResponse(format_validation_errors(exc))

    user_repo = request.app.state.user_repo
    user = await user_repo.get_by_userid(userid)
    if user is None:
        return HTMLResponse("User not found", status_code=404)

    updated = user.model_copy(update={"groups": validated.group_list})
    await user_repo.update(updated)
    return HTMLResponse('<div role="status">Groups updated</div>')
  • Step 4: Run tests to verify they pass

Run: uv run pytest tests/test_admin_groups_validation.py -v Expected: PASS

  • Step 5: Commit
git add src/porchlight/admin/routes.py tests/test_admin_groups_validation.py
git commit -m "feat: validate group names in admin groups route"

Task 12: Require current password when changing password

Files:

  • Modify: src/porchlight/manage/routes.py:53-81

  • Modify: src/porchlight/templates/manage/credentials.html:41-52

  • Modify: tests/test_manage_profile.py or create tests/test_password_change.py

  • Step 1: Write the failing tests

Create tests/test_password_change.py:

from datetime import UTC, datetime

import pytest
from httpx import AsyncClient

from porchlight.authn.password import PasswordHasher, PasswordService
from porchlight.models import PasswordCredential, User

from tests.conftest import get_csrf_token


async def _login_user_with_password(client: AsyncClient) -> str:
    """Create user with password, login, return CSRF token."""
    app = client._transport.app
    user_repo = app.state.user_repo
    cred_repo = app.state.credential_repo

    user = User(
        userid="pw-user-01",
        username="pwuser",
        created_at=datetime.now(UTC),
        updated_at=datetime.now(UTC),
    )
    await user_repo.create(user)

    svc = PasswordService(hasher=PasswordHasher(time_cost=1, memory_cost=8192))
    await cred_repo.create_password(
        PasswordCredential(user_id=user.userid, password_hash=svc.hash("OldPass123!ok"))
    )

    token = await get_csrf_token(client)
    await client.post(
        "/login/password",
        data={"username": "pwuser", "password": "OldPass123!ok"},
        headers={"HX-Request": "true", "X-CSRF-Token": token},
    )
    return token


async def _login_user_without_password(client: AsyncClient) -> str:
    """Create user without password, simulate session login, return CSRF token."""
    app = client._transport.app
    user_repo = app.state.user_repo

    user = User(
        userid="pw-user-02",
        username="newuser",
        created_at=datetime.now(UTC),
        updated_at=datetime.now(UTC),
    )
    await user_repo.create(user)

    # Simulate session login (as if registered via magic link)
    token = await get_csrf_token(client)
    # We need to set session directly — do a magic link registration
    magic_link_service = app.state.magic_link_service
    link = await magic_link_service.create(username="newuser", created_by="admin", note="test")
    await client.get(f"/register/{link.token}", follow_redirects=False)
    # Re-fetch CSRF token after session change
    token = await get_csrf_token(client)
    return token


@pytest.mark.asyncio
async def test_change_password_requires_current(client: AsyncClient) -> None:
    token = await _login_user_with_password(client)

    response = await client.post(
        "/manage/credentials/password",
        data={
            "password": "NewStrong!Pass99",
            "confirm": "NewStrong!Pass99",
        },
        headers={"X-CSRF-Token": token},
    )
    assert "alert" in response.text
    assert "urrent password" in response.text


@pytest.mark.asyncio
async def test_change_password_wrong_current_rejected(client: AsyncClient) -> None:
    token = await _login_user_with_password(client)

    response = await client.post(
        "/manage/credentials/password",
        data={
            "current_password": "WrongPassword",
            "password": "NewStrong!Pass99",
            "confirm": "NewStrong!Pass99",
        },
        headers={"X-CSRF-Token": token},
    )
    assert "alert" in response.text
    assert "incorrect" in response.text.lower() or "Invalid" in response.text or "current" in response.text.lower()


@pytest.mark.asyncio
async def test_change_password_correct_current_succeeds(client: AsyncClient) -> None:
    token = await _login_user_with_password(client)

    response = await client.post(
        "/manage/credentials/password",
        data={
            "current_password": "OldPass123!ok",
            "password": "NewStrong!Pass99",
            "confirm": "NewStrong!Pass99",
        },
        headers={"X-CSRF-Token": token},
    )
    assert "updated" in response.text.lower() or "status" in response.text


@pytest.mark.asyncio
async def test_set_password_no_current_needed(client: AsyncClient) -> None:
    """First-time password setup should not require current password."""
    token = await _login_user_without_password(client)

    response = await client.post(
        "/manage/credentials/password",
        data={
            "password": "FirstPass!Strong99",
            "confirm": "FirstPass!Strong99",
        },
        headers={"X-CSRF-Token": token},
    )
    assert "updated" in response.text.lower() or "status" in response.text
  • Step 2: Run tests to verify they fail

Run: uv run pytest tests/test_password_change.py -v Expected: FAIL (current password not required yet)

  • Step 3: Update set_password route

In src/porchlight/manage/routes.py, update imports:

from porchlight.validation import PasswordChange, PasswordSet, ProfileUpdate, format_validation_errors

Replace set_password:

@router.post("/credentials/password", response_class=HTMLResponse)
async def set_password(
    request: Request,
    password: str = Form(),
    confirm: str = Form(),
    current_password: str = Form(""),
) -> Response:
    session_user = get_session_user(request)
    if session_user is None:
        return RedirectResponse("/login", status_code=303)

    userid, _username = session_user
    cred_repo = request.app.state.credential_repo
    password_service = request.app.state.password_service

    existing = await cred_repo.get_password_by_user(userid)
    has_password = existing is not None

    # Validate input
    try:
        if has_password:
            validated = PasswordChange(
                current_password=current_password,
                password=password,
                confirm=confirm,
            )
        else:
            validated = PasswordSet(password=password, confirm=confirm)
    except ValidationError as exc:
        return HTMLResponse(format_validation_errors(exc))

    # Verify current password if changing
    if has_password:
        if not password_service.verify(existing.password_hash, validated.current_password):
            return HTMLResponse('<div role="alert">Current password is incorrect</div>')

    # Store new password
    password_hash = password_service.hash(validated.password)

    if existing is not None:
        await cred_repo.delete_password(userid)

    await cred_repo.create_password(PasswordCredential(user_id=userid, password_hash=password_hash))

    return HTMLResponse('<div role="status">Password updated successfully</div>')
  • Step 4: Update credentials template

In src/porchlight/templates/manage/credentials.html, update the password form section (lines 41-52) to conditionally show a current password field:

        <form hx-post="/manage/credentials/password" hx-target="#password-section" hx-swap="innerHTML">
            <input type="hidden" name="csrf_token" value="{{ csrf_token_processor(request) }}">
            {% if has_password %}
            <div>
                <label for="current_password">Current password</label>
                <input type="password" id="current_password" name="current_password" required autocomplete="current-password">
            </div>
            {% endif %}
            <div>
                <label for="password">{{ "New password" if has_password else "Set password" }}</label>
                <input type="password" id="password" name="password" required minlength="8" maxlength="256" autocomplete="new-password">
            </div>
            <div>
                <label for="confirm">Confirm password</label>
                <input type="password" id="confirm" name="confirm" required minlength="8" maxlength="256" autocomplete="new-password">
            </div>
            <button type="submit">{{ "Change password" if has_password else "Set password" }}</button>
        </form>

Note: also adds maxlength="256" to the password fields.

  • Step 5: Run tests to verify they pass

Run: uv run pytest tests/test_password_change.py -v Expected: PASS

  • Step 6: Run all tests

Run: uv run pytest -v Expected: All pass

  • Step 7: Commit
git add src/porchlight/manage/routes.py src/porchlight/templates/manage/credentials.html tests/test_password_change.py
git commit -m "feat: require current password when changing password, add zxcvbn strength check"

Task 13: Add CSRF tokens to admin form templates

Files:

  • Modify: src/porchlight/templates/admin/user_detail.html:13,50

  • Modify: src/porchlight/templates/admin/users.html:10

  • Step 1: Add CSRF token to admin profile form

In src/porchlight/templates/admin/user_detail.html, add after line 13 (after the <form> opening tag):

        <input type="hidden" name="csrf_token" value="{{ csrf_token_processor(request) }}">
  • Step 2: Add CSRF token to admin groups form

In src/porchlight/templates/admin/user_detail.html, add after line 50 (after the groups <form> opening tag):

            <input type="hidden" name="csrf_token" value="{{ csrf_token_processor(request) }}">
  • Step 3: Add CSRF token to admin invite form

In src/porchlight/templates/admin/users.html, add after line 10 (after the invite <form> opening tag):

        <input type="hidden" name="csrf_token" value="{{ csrf_token_processor(request) }}">
  • Step 4: Run all tests

Run: uv run pytest -v Expected: All pass

  • Step 5: Commit
git add src/porchlight/templates/admin/user_detail.html src/porchlight/templates/admin/users.html
git commit -m "fix: add explicit CSRF tokens to admin form templates"

Task 14: Add username validation HTML attributes to invite form

Files:

  • Modify: src/porchlight/templates/admin/users.html:12

  • Step 1: Update invite username input

In src/porchlight/templates/admin/users.html, update the username input (line 12) to add validation hints:

            <input type="text" name="username" placeholder="Username or email for new invite" required
                   maxlength="255" pattern="[a-zA-Z0-9_.@-]+"
                   title="Letters, digits, dots, hyphens, underscores, and @">
  • Step 2: Commit
git add src/porchlight/templates/admin/users.html
git commit -m "style: add HTML5 validation hints to admin invite form"

Task 15: Run full check suite

Files: None (verification only)

  • Step 1: Run formatter

Run: make reformat

  • Step 2: Run linter

Run: make lint

  • Step 3: Run type checker

Run: make typecheck

  • Step 4: Run all tests

Run: make test

  • Step 5: Fix any issues

If any check fails, fix the issue and re-run.

  • Step 6: Final commit if any formatting/lint changes
git add -A
git commit -m "style: apply formatting and lint fixes"

Summary of Changes

Task File(s) What
1 pyproject.toml Add slowapi + zxcvbn deps
2 validation.py, tests Locale BCP 47 validation
3 validation.py, tests UsernameInput model
4 validation.py, tests GroupListInput model
5 validation.py, tests PasswordSet + PasswordChange with zxcvbn
6 validation.py, tests Shared format_validation_errors helper
7 authn/routes.py, tests Block inactive users from all auth paths
8 rate_limit.py, app.py, authn/routes.py, tests Rate limiting on login endpoints
9 manage/routes.py, admin/routes.py Deduplicate profile validation
10 admin/routes.py, tests Username validation in invite
11 admin/routes.py, tests Group name validation
12 manage/routes.py, template, tests Require current password + zxcvbn UI
13 Admin templates CSRF tokens in forms
14 Admin template HTML5 validation hints
15 Full check suite