left4me/docs/superpowers/plans/2026-05-11-profile-password-change-v1.md
mwiegand 6eb9bd0ab3
docs: plan for profile page with self-service password change
Adds /profile reachable via header username, with change-password form
as its first section. Industry-standard session semantics: other sessions
invalidated on password change, current session kept, via new
users.password_changed_at column + session marker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 21:41:25 +02:00

39 KiB

Profile page with self-service password change — implementation plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Logged-in users can change their own password from a new /profile page reachable by clicking their username in the header. Successful change invalidates every other active session for that user while keeping the current session signed in.

Architecture: New password_changed_at column on users plus a session["pw_changed_at"] marker. load_current_user already has the place to add a freshness check (mirroring the existing user.active rejection). A new profile blueprint owns GET /profile and POST /profile/password. The login rate-limit code is extracted into a tiny reusable helper.

Tech Stack: Flask, SQLAlchemy 2 ORM, Alembic, Werkzeug password hashing (werkzeug.security), Jinja2 templates, vendored HTMX (not needed here), pytest.


File map

New files

  • l4d2web/alembic/versions/0009_user_password_changed_at.py — Alembic migration adding users.password_changed_at.
  • l4d2web/services/rate_limit.py — generic per-IP sliding-window check used by login and password change.
  • l4d2web/routes/profile_routes.pyGET /profile, POST /profile/password.
  • l4d2web/templates/profile.html — profile page; one "Change password" section.
  • l4d2web/tests/test_profile.py — covers the new endpoint, validation branches, and cross-session invalidation.

Modified files

  • l4d2web/models.py — add the password_changed_at column.
  • l4d2web/auth.py — add MIN_PASSWORD_LENGTH, validate_new_password, freshness check in load_current_user, extend login_user signature.
  • l4d2web/routes/auth_routes.py — pass password_changed_at to login_user; consume the shared rate-limit helper.
  • l4d2web/templates/base.html — username <span> becomes <a href="/profile">.
  • l4d2web/app.py — register the new blueprint.

Task 1: Add password_changed_at to the User model

Files:

  • Modify: l4d2web/models.py:26-37

  • Test: l4d2web/tests/test_models.py

  • Step 1: Read the current model test

Run: cat l4d2web/tests/test_models.py

It's a tiny smoke test. We'll add a new test alongside it.

  • Step 2: Write the failing test

Append to l4d2web/tests/test_models.py:

def test_user_has_password_changed_at_default(tmp_path, monkeypatch):
    from datetime import UTC, datetime
    from l4d2web.app import create_app
    from l4d2web.auth import hash_password
    from l4d2web.db import init_db, session_scope
    from l4d2web.models import User

    db_url = f"sqlite:///{tmp_path/'pw.db'}"
    monkeypatch.setenv("DATABASE_URL", db_url)
    create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
    init_db()

    before = datetime.now(UTC).replace(tzinfo=None)
    with session_scope() as db:
        db.add(User(username="alice", password_digest=hash_password("secret")))
    with session_scope() as db:
        user = db.query(User).filter_by(username="alice").one()

    assert user.password_changed_at is not None
    assert user.password_changed_at >= before
  • Step 3: Run the test — expect failure

Run: pytest l4d2web/tests/test_models.py::test_user_has_password_changed_at_default -v Expected: FAIL — AttributeError: 'User' object has no attribute 'password_changed_at'.

  • Step 4: Add the column to the model

Edit l4d2web/models.py — in class User, add immediately after the updated_at column:

password_changed_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
  • Step 5: Run the test — expect pass

Run: pytest l4d2web/tests/test_models.py::test_user_has_password_changed_at_default -v Expected: PASS.

  • Step 6: Commit
git add l4d2web/models.py l4d2web/tests/test_models.py
git commit -m "models: add User.password_changed_at"

Task 2: Alembic migration for password_changed_at

Files:

  • Create: l4d2web/alembic/versions/0009_user_password_changed_at.py

  • Test: l4d2web/tests/test_alembic_migrations.py (read-only check that the new revision is reachable; only add a test if the existing file already iterates revisions — otherwise skip; the migration is exercised end-to-end below)

  • Step 1: Inspect the previous migration head

Run: ls l4d2web/alembic/versions | sort Expected: latest non-merge revision is 0008_user_active.

  • Step 2: Create the migration file

Create l4d2web/alembic/versions/0009_user_password_changed_at.py:

"""users.password_changed_at

Revision ID: 0009_user_password_changed_at
Revises: 0008_user_active
Create Date: 2026-05-11
"""
from typing import Sequence, Union

import sqlalchemy as sa
from alembic import op


revision: str = "0009_user_password_changed_at"
down_revision: Union[str, Sequence[str], None] = "0008_user_active"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
    # Add the column nullable, backfill with created_at so historical rows
    # have a sane marker, then enforce NOT NULL.
    with op.batch_alter_table("users") as batch_op:
        batch_op.add_column(sa.Column("password_changed_at", sa.DateTime(), nullable=True))
    op.execute("UPDATE users SET password_changed_at = created_at WHERE password_changed_at IS NULL")
    with op.batch_alter_table("users") as batch_op:
        batch_op.alter_column("password_changed_at", nullable=False)


def downgrade() -> None:
    with op.batch_alter_table("users") as batch_op:
        batch_op.drop_column("password_changed_at")
  • Step 3: Run the migration against a scratch DB
rm -f /tmp/l4d2web_migration_check.db
DATABASE_URL=sqlite:////tmp/l4d2web_migration_check.db alembic -c l4d2web/alembic.ini upgrade head
DATABASE_URL=sqlite:////tmp/l4d2web_migration_check.db alembic -c l4d2web/alembic.ini downgrade -1
DATABASE_URL=sqlite:////tmp/l4d2web_migration_check.db alembic -c l4d2web/alembic.ini upgrade head

Expected: each command exits 0; second upgrade reports Running upgrade 0008_user_active -> 0009_user_password_changed_at.

  • Step 4: Run the existing migration test (if any)

Run: pytest l4d2web/tests/test_alembic_migrations.py -v Expected: PASS — covers that all revisions are reachable from base to head.

  • Step 5: Commit
git add l4d2web/alembic/versions/0009_user_password_changed_at.py
git commit -m "alembic: add users.password_changed_at column"

Task 3: validate_new_password helper and MIN_PASSWORD_LENGTH

Files:

  • Modify: l4d2web/auth.py:16-22

  • Test: l4d2web/tests/test_auth.py (append)

  • Step 1: Write the failing tests

Append to l4d2web/tests/test_auth.py:

def test_validate_new_password_rejects_empty():
    from l4d2web.auth import validate_new_password
    assert validate_new_password("") == "password must not be empty"


def test_validate_new_password_rejects_short():
    from l4d2web.auth import validate_new_password, MIN_PASSWORD_LENGTH
    assert MIN_PASSWORD_LENGTH == 8
    assert validate_new_password("a" * 7) == "password must be at least 8 characters"


def test_validate_new_password_accepts_min_length():
    from l4d2web.auth import validate_new_password
    assert validate_new_password("a" * 8) is None
  • Step 2: Run the tests — expect failure

Run: pytest l4d2web/tests/test_auth.py -v -k validate_new_password Expected: 3 FAIL — ImportError: cannot import name 'validate_new_password'.

  • Step 3: Implement the helper

Edit l4d2web/auth.py — insert after the existing verify_password function:

MIN_PASSWORD_LENGTH = 8


def validate_new_password(raw: str) -> str | None:
    if raw == "":
        return "password must not be empty"
    if len(raw) < MIN_PASSWORD_LENGTH:
        return f"password must be at least {MIN_PASSWORD_LENGTH} characters"
    return None
  • Step 4: Run the tests — expect pass

Run: pytest l4d2web/tests/test_auth.py -v -k validate_new_password Expected: 3 PASS.

  • Step 5: Commit
git add l4d2web/auth.py l4d2web/tests/test_auth.py
git commit -m "auth: validate_new_password helper (min length 8)"

Task 4: Generic rate-limit helper, refactor login to use it

Files:

  • Create: l4d2web/services/rate_limit.py

  • Modify: l4d2web/routes/auth_routes.py:13-30

  • Test: l4d2web/tests/test_rate_limit.py (new)

  • Step 1: Write the failing test

Create l4d2web/tests/test_rate_limit.py:

import time

import pytest

from l4d2web.services.rate_limit import check_rate_limit


def test_under_threshold_allows():
    bucket: dict[str, list[float]] = {}
    for _ in range(3):
        assert check_rate_limit(bucket, "1.2.3.4", window=60.0, max_attempts=5) is False


def test_at_threshold_blocks():
    bucket: dict[str, list[float]] = {}
    for _ in range(5):
        assert check_rate_limit(bucket, "1.2.3.4", window=60.0, max_attempts=5) is False
    assert check_rate_limit(bucket, "1.2.3.4", window=60.0, max_attempts=5) is True


def test_other_ips_independent():
    bucket: dict[str, list[float]] = {}
    for _ in range(5):
        check_rate_limit(bucket, "1.2.3.4", window=60.0, max_attempts=5)
    assert check_rate_limit(bucket, "5.6.7.8", window=60.0, max_attempts=5) is False


def test_old_attempts_expire():
    bucket: dict[str, list[float]] = {}
    for _ in range(5):
        check_rate_limit(bucket, "1.2.3.4", window=0.05, max_attempts=5)
    time.sleep(0.1)
    assert check_rate_limit(bucket, "1.2.3.4", window=0.05, max_attempts=5) is False
  • Step 2: Run — expect failure

Run: pytest l4d2web/tests/test_rate_limit.py -v Expected: FAIL — ModuleNotFoundError: No module named 'l4d2web.services.rate_limit'.

  • Step 3: Implement the helper

Create l4d2web/services/rate_limit.py:

import time


def check_rate_limit(
    bucket: dict[str, list[float]],
    remote_addr: str,
    *,
    window: float,
    max_attempts: int,
) -> bool:
    """Return True if the request should be blocked.

    bucket is a caller-owned per-endpoint store; this lets tests reset state
    cleanly and keeps login/profile counts independent.
    """
    now = time.time()
    attempts = bucket.setdefault(remote_addr, [])
    cutoff = now - window
    attempts[:] = [ts for ts in attempts if ts >= cutoff]
    if len(attempts) >= max_attempts:
        return True
    attempts.append(now)
    return False
  • Step 4: Run — expect pass

Run: pytest l4d2web/tests/test_rate_limit.py -v Expected: 4 PASS.

  • Step 5: Refactor auth_routes.py to use the helper

Edit l4d2web/routes/auth_routes.py:

Replace lines 1-30 with:

import time

from flask import Blueprint, Response, redirect, render_template, request
from sqlalchemy import select

from l4d2web.auth import hash_password, is_safe_next, login_user, logout_user, verify_password
from l4d2web.db import session_scope
from l4d2web.models import User
from l4d2web.services.rate_limit import check_rate_limit


bp = Blueprint("auth", __name__)
_TIMING_DUMMY_DIGEST = hash_password("__timing_dummy__")
LOGIN_RATE_LIMIT_WINDOW_SECONDS = 60.0
LOGIN_RATE_LIMIT_MAX_ATTEMPTS = 20
LOGIN_ATTEMPTS_BY_IP: dict[str, list[float]] = {}


def reset_login_rate_limits() -> None:
    LOGIN_ATTEMPTS_BY_IP.clear()


def is_login_rate_limited(remote_addr: str) -> bool:
    return check_rate_limit(
        LOGIN_ATTEMPTS_BY_IP,
        remote_addr,
        window=LOGIN_RATE_LIMIT_WINDOW_SECONDS,
        max_attempts=LOGIN_RATE_LIMIT_MAX_ATTEMPTS,
    )

Note: the unused import time at the top of the original file can be removed in this rewrite — it was only needed for the old inline time.time() calls.

  • Step 6: Run the full auth test file to make sure nothing regressed

Run: pytest l4d2web/tests/test_auth.py -v Expected: PASS.

  • Step 7: Commit
git add l4d2web/services/rate_limit.py l4d2web/routes/auth_routes.py l4d2web/tests/test_rate_limit.py
git commit -m "rate-limit: extract generic helper, reuse from login"

Task 5: login_user stamps password_changed_at into the session

Files:

  • Modify: l4d2web/auth.py:40-42

  • Modify: l4d2web/routes/auth_routes.py (caller)

  • Test: l4d2web/tests/test_auth.py (append)

  • Step 1: Write the failing test

Append to l4d2web/tests/test_auth.py:

def test_login_stamps_password_changed_at_in_session(client) -> None:
    with session_scope() as session:
        session.add(User(username="alice", password_digest=hash_password("secret")))

    response = client.post("/login", data={"username": "alice", "password": "secret"})
    assert response.status_code == 302

    with client.session_transaction() as sess:
        marker = sess.get("pw_changed_at")
        assert marker is not None
    with session_scope() as session:
        user = session.query(User).filter_by(username="alice").one()
    assert marker == user.password_changed_at.isoformat()
  • Step 2: Run — expect failure

Run: pytest l4d2web/tests/test_auth.py::test_login_stamps_password_changed_at_in_session -v Expected: FAIL — marker is None.

  • Step 3: Update login_user signature in l4d2web/auth.py

Replace the existing login_user function (currently l4d2web/auth.py:40-42):

def login_user(user_id: int, password_changed_at) -> None:
    session["user_id"] = user_id
    session["pw_changed_at"] = password_changed_at.isoformat()

(No type hint on the second parameter to avoid adding a datetime import; it's straightforward at the call site.)

  • Step 4: Update the only caller

In l4d2web/routes/auth_routes.py, change the line login_user(user.id) to:

login_user(user.id, user.password_changed_at)
  • Step 5: Run — expect pass

Run: pytest l4d2web/tests/test_auth.py::test_login_stamps_password_changed_at_in_session -v Expected: PASS.

Then run the full auth file: pytest l4d2web/tests/test_auth.py -v — expect all PASS.

  • Step 6: Commit
git add l4d2web/auth.py l4d2web/routes/auth_routes.py l4d2web/tests/test_auth.py
git commit -m "auth: stamp password_changed_at marker in session on login"

Task 6: load_current_user rejects stale session markers

Files:

  • Modify: l4d2web/auth.py:24-33

  • Test: l4d2web/tests/test_auth.py (append)

  • Step 1: Write the failing tests

Append to l4d2web/tests/test_auth.py:

def test_load_current_user_rejects_missing_marker(client) -> None:
    with session_scope() as db:
        u = User(username="alice", password_digest=hash_password("secret"))
        db.add(u)
        db.flush()
        uid = u.id

    # Forge a pre-marker session.
    with client.session_transaction() as sess:
        sess["user_id"] = uid

    response = client.get("/dashboard")
    assert response.status_code == 302
    assert "/login" in response.headers["Location"]


def test_load_current_user_rejects_stale_marker(client) -> None:
    from datetime import UTC, datetime, timedelta

    with session_scope() as db:
        u = User(username="alice", password_digest=hash_password("secret"))
        db.add(u)
        db.flush()
        uid = u.id

    # Forge a session that knew about an earlier password_changed_at.
    stale = datetime.now(UTC).replace(tzinfo=None) - timedelta(minutes=5)
    with client.session_transaction() as sess:
        sess["user_id"] = uid
        sess["pw_changed_at"] = stale.isoformat()

    response = client.get("/dashboard")
    assert response.status_code == 302
    assert "/login" in response.headers["Location"]


def test_load_current_user_accepts_current_marker(client) -> None:
    with session_scope() as db:
        u = User(username="alice", password_digest=hash_password("secret"))
        db.add(u)
        db.flush()
        uid = u.id
        marker = u.password_changed_at.isoformat()

    with client.session_transaction() as sess:
        sess["user_id"] = uid
        sess["pw_changed_at"] = marker

    response = client.get("/dashboard")
    assert response.status_code == 200
  • Step 2: Run — expect 2 of 3 to fail

Run: pytest l4d2web/tests/test_auth.py -v -k load_current_user Expected: the "accepts current marker" test already passes (the freshness check doesn't exist yet); the "missing marker" and "stale marker" tests FAIL because we currently let any session in.

  • Step 3: Add the freshness check

First add from datetime import datetime to the top of l4d2web/auth.py (under the other stdlib imports).

Then replace the body of load_current_user in l4d2web/auth.py (currently lines 24-33):

def load_current_user() -> None:
    user_id = session.get("user_id")
    if user_id is None:
        g.user = None
        return
    with session_scope() as db:
        user = db.scalar(select(User).where(User.id == int(user_id)))
    if user is None or not user.active:
        g.user = None
        return

    marker = session.get("pw_changed_at")
    if marker is None:
        g.user = None
        return
    try:
        marker_dt = datetime.fromisoformat(marker)
    except ValueError:
        g.user = None
        return
    if marker_dt < user.password_changed_at:
        g.user = None
        return

    g.user = user
  • Step 4: Run — expect all pass

Run: pytest l4d2web/tests/test_auth.py -v Expected: full file PASS.

  • Step 5: Sanity-check the admin tests still pass

Run: pytest l4d2web/tests/test_admin_users.py -v Expected: PASS. The existing test_deactivated_user_existing_session_invalidated forges a session without pw_changed_at; now that the marker is required, the test should still pass for the same reason (just via a different rejection path). If it regresses because a different test seeds a session without the marker, update the test to include sess["pw_changed_at"] = u.password_changed_at.isoformat() where the seeding happens.

  • Step 6: Run the full suite

Run: pytest l4d2web/tests -q Expected: PASS. If any test relies on client.session_transaction() to forge a user_id without the marker, patch it to also set pw_changed_at from the user's password_changed_at. Likely candidates: test_admin_users.py:131-133, test_admin_users.py:30-32. Update those to:

with bob_client.session_transaction() as sess:
    sess["user_id"] = target
    sess["pw_changed_at"] = <user>.password_changed_at.isoformat()
    sess["csrf_token"] = "test-token"

(Fetch password_changed_at from the DB in the test fixture's with session_scope() block.)

  • Step 7: Commit
git add l4d2web/auth.py l4d2web/tests/test_auth.py l4d2web/tests/test_admin_users.py
git commit -m "auth: reject sessions older than user.password_changed_at"

Task 7: Profile page — GET /profile and template

Files:

  • Create: l4d2web/routes/profile_routes.py

  • Create: l4d2web/templates/profile.html

  • Modify: l4d2web/templates/base.html:27

  • Modify: l4d2web/app.py (register blueprint)

  • Test: l4d2web/tests/test_profile.py (new)

  • Step 1: Write the failing tests

Create l4d2web/tests/test_profile.py:

import pytest

from l4d2web.app import create_app
from l4d2web.auth import hash_password
from l4d2web.db import init_db, session_scope
from l4d2web.models import User


@pytest.fixture
def app_and_user(tmp_path, monkeypatch):
    db_url = f"sqlite:///{tmp_path/'profile.db'}"
    monkeypatch.setenv("DATABASE_URL", db_url)
    app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
    init_db()
    with session_scope() as db:
        u = User(username="alice", password_digest=hash_password("currentpass"))
        db.add(u)
        db.flush()
        uid = u.id
        marker = u.password_changed_at.isoformat()
    return app, uid, marker


def _logged_in_client(app, uid, marker):
    client = app.test_client()
    with client.session_transaction() as sess:
        sess["user_id"] = uid
        sess["pw_changed_at"] = marker
        sess["csrf_token"] = "test-token"
    return client


def test_profile_requires_login(app_and_user):
    app, _, _ = app_and_user
    response = app.test_client().get("/profile", follow_redirects=False)
    assert response.status_code == 302
    assert "/login" in response.headers["Location"]


def test_profile_page_renders(app_and_user):
    app, uid, marker = app_and_user
    client = _logged_in_client(app, uid, marker)
    response = client.get("/profile")
    assert response.status_code == 200
    body = response.get_data(as_text=True)
    assert "Change password" in body
    assert 'name="current_password"' in body
    assert 'name="new_password"' in body
    assert 'name="confirm_new_password"' in body


def test_base_template_links_username_to_profile(app_and_user):
    app, uid, marker = app_and_user
    client = _logged_in_client(app, uid, marker)
    response = client.get("/dashboard")
    body = response.get_data(as_text=True)
    assert 'href="/profile"' in body
    assert ">alice<" in body
  • Step 2: Run — expect failure

Run: pytest l4d2web/tests/test_profile.py -v Expected: all FAIL — /profile route doesn't exist yet.

  • Step 3: Create the profile route

Create l4d2web/routes/profile_routes.py:

from flask import Blueprint, render_template, request

from l4d2web.auth import require_login


bp = Blueprint("profile", __name__)


_ERROR_MESSAGES = {
    "fields_required": "Fill in all three fields.",
    "mismatch": "New password and confirmation do not match.",
    "too_short": "New password must be at least 8 characters.",
    "empty": "New password must not be empty.",
    "wrong_current": "Current password is incorrect.",
}


@bp.get("/profile")
@require_login
def profile_page() -> str:
    error_key = request.args.get("error", "")
    success = request.args.get("success") == "1"
    return render_template(
        "profile.html",
        error_message=_ERROR_MESSAGES.get(error_key, ""),
        success=success,
    )
  • Step 4: Create the template

Create l4d2web/templates/profile.html:

{% extends "base.html" %}

{% block title %}Profile | left4me{% endblock %}

{% block content %}
<section class="panel">
  <h1>Change password</h1>

  {% if success %}
    <p class="muted">Password changed.</p>
  {% endif %}
  {% if error_message %}
    <p class="error">{{ error_message }}</p>
  {% endif %}

  <form method="post" action="/profile/password" class="form">
    <input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
    <label>
      Current password
      <input type="password" name="current_password" autocomplete="current-password" required>
    </label>
    <label>
      New password
      <input type="password" name="new_password" autocomplete="new-password" required>
    </label>
    <label>
      Confirm new password
      <input type="password" name="confirm_new_password" autocomplete="new-password" required>
    </label>
    <button type="submit">Change password</button>
  </form>
</section>
{% endblock %}
  • Step 5: Link the username in the header

Edit l4d2web/templates/base.html line 27. Replace:

<span class="muted">{{ g.user.username }}</span>

with:

<a class="muted" href="/profile">{{ g.user.username }}</a>
  • Step 6: Register the blueprint

Edit l4d2web/app.py. In the import block, add:

from l4d2web.routes.profile_routes import bp as profile_bp

In the register_blueprint block (immediately after app.register_blueprint(page_bp)), add:

app.register_blueprint(profile_bp)
  • Step 7: Run the test file — expect pass

Run: pytest l4d2web/tests/test_profile.py -v Expected: 3 PASS.

  • Step 8: Run the full suite — sanity

Run: pytest l4d2web/tests -q Expected: PASS.

  • Step 9: Commit
git add l4d2web/routes/profile_routes.py l4d2web/templates/profile.html l4d2web/templates/base.html l4d2web/app.py l4d2web/tests/test_profile.py
git commit -m "profile: GET /profile page with change-password form"

Task 8: POST /profile/password — validation branches

Files:

  • Modify: l4d2web/routes/profile_routes.py

  • Test: l4d2web/tests/test_profile.py (append)

  • Step 1: Write the failing tests

Append to l4d2web/tests/test_profile.py:

from sqlalchemy import select


def _post_pw(client, **fields):
    payload = {"csrf_token": "test-token", **fields}
    return client.post("/profile/password", data=payload, follow_redirects=False)


def _digest_of(username):
    with session_scope() as db:
        u = db.scalar(select(User).where(User.username == username))
        return u.password_digest


def test_post_password_rejects_missing_csrf(app_and_user):
    app, uid, marker = app_and_user
    client = _logged_in_client(app, uid, marker)
    before = _digest_of("alice")
    response = client.post(
        "/profile/password",
        data={"current_password": "currentpass", "new_password": "newpass12", "confirm_new_password": "newpass12"},
    )
    assert response.status_code == 400
    assert _digest_of("alice") == before


def test_post_password_requires_login(app_and_user):
    app, _, _ = app_and_user
    response = app.test_client().post(
        "/profile/password",
        data={"csrf_token": "test-token", "current_password": "x", "new_password": "y" * 8, "confirm_new_password": "y" * 8},
        follow_redirects=False,
    )
    assert response.status_code == 302
    assert "/login" in response.headers["Location"]


def test_post_password_empty_fields_redirects_with_error(app_and_user):
    app, uid, marker = app_and_user
    client = _logged_in_client(app, uid, marker)
    before = _digest_of("alice")
    response = _post_pw(client, current_password="", new_password="", confirm_new_password="")
    assert response.status_code == 302
    assert "/profile?error=fields_required" in response.headers["Location"]
    assert _digest_of("alice") == before


def test_post_password_mismatched_confirm(app_and_user):
    app, uid, marker = app_and_user
    client = _logged_in_client(app, uid, marker)
    before = _digest_of("alice")
    response = _post_pw(
        client,
        current_password="currentpass",
        new_password="newpass12",
        confirm_new_password="newpass99",
    )
    assert response.status_code == 302
    assert "/profile?error=mismatch" in response.headers["Location"]
    assert _digest_of("alice") == before


def test_post_password_too_short(app_and_user):
    app, uid, marker = app_and_user
    client = _logged_in_client(app, uid, marker)
    before = _digest_of("alice")
    response = _post_pw(
        client,
        current_password="currentpass",
        new_password="short7x",
        confirm_new_password="short7x",
    )
    assert response.status_code == 302
    assert "/profile?error=too_short" in response.headers["Location"]
    assert _digest_of("alice") == before


def test_post_password_wrong_current(app_and_user):
    app, uid, marker = app_and_user
    client = _logged_in_client(app, uid, marker)
    before = _digest_of("alice")
    response = _post_pw(
        client,
        current_password="WRONG",
        new_password="newpass12",
        confirm_new_password="newpass12",
    )
    assert response.status_code == 302
    assert "/profile?error=wrong_current" in response.headers["Location"]
    assert _digest_of("alice") == before
  • Step 2: Run — expect failure

Run: pytest l4d2web/tests/test_profile.py -v -k post_password Expected: all FAIL — /profile/password route doesn't exist (404 / 405).

  • Step 3: Implement the endpoint

Append to l4d2web/routes/profile_routes.py:

from flask import Response, current_app, redirect, request
from sqlalchemy import select

from l4d2web.auth import (
    current_user,
    hash_password,
    validate_new_password,
    verify_password,
)
from l4d2web.db import session_scope
from l4d2web.models import User, now_utc
from l4d2web.services.rate_limit import check_rate_limit


PROFILE_PW_RATE_LIMIT_WINDOW_SECONDS = 60.0
PROFILE_PW_RATE_LIMIT_MAX_ATTEMPTS = 10
PROFILE_PW_ATTEMPTS_BY_IP: dict[str, list[float]] = {}


def reset_profile_password_rate_limits() -> None:
    PROFILE_PW_ATTEMPTS_BY_IP.clear()


def _redirect_with_error(error_key: str) -> Response:
    return redirect(f"/profile?error={error_key}")


@bp.post("/profile/password")
@require_login
def profile_password_change() -> Response:
    remote_addr = request.remote_addr or "unknown"
    if check_rate_limit(
        PROFILE_PW_ATTEMPTS_BY_IP,
        remote_addr,
        window=PROFILE_PW_RATE_LIMIT_WINDOW_SECONDS,
        max_attempts=PROFILE_PW_RATE_LIMIT_MAX_ATTEMPTS,
    ):
        return Response("too many attempts", status=429)

    current_password = request.form.get("current_password", "")
    new_password = request.form.get("new_password", "")
    confirm_new_password = request.form.get("confirm_new_password", "")

    if not current_password or not new_password or not confirm_new_password:
        return _redirect_with_error("fields_required")
    if new_password != confirm_new_password:
        return _redirect_with_error("mismatch")
    policy_error = validate_new_password(new_password)
    if policy_error is not None:
        return _redirect_with_error(
            "empty" if policy_error == "password must not be empty" else "too_short"
        )

    actor = current_user()
    assert actor is not None  # @require_login

    with session_scope() as db:
        user = db.scalar(select(User).where(User.id == actor.id))
        if user is None or not verify_password(current_password, user.password_digest):
            return _redirect_with_error("wrong_current")
        user.password_digest = hash_password(new_password)
        user.password_changed_at = now_utc()
        new_marker = user.password_changed_at.isoformat()

    # Re-stamp the current session so this browser stays logged in;
    # any other session whose marker is older is rejected by load_current_user.
    from flask import session
    session["pw_changed_at"] = new_marker

    return redirect("/profile?success=1")

(The duplicate from flask import ... at the top of the appended block is fine — Python allows it. If you prefer, merge it into the existing from flask import Blueprint, render_template, request line at the top of the file: from flask import Blueprint, Response, redirect, render_template, request, session.)

  • Step 4: Reset rate limit when testing

Edit l4d2web/app.py — in the if app.config.get("TESTING"): block (near reset_login_rate_limits()), add a call to the profile reset. First add the import alongside reset_login_rate_limits:

from l4d2web.routes.profile_routes import reset_profile_password_rate_limits

Then in the test branch:

if app.config.get("TESTING"):
    reset_login_rate_limits()
    reset_profile_password_rate_limits()
  • Step 5: Run the new tests — expect pass

Run: pytest l4d2web/tests/test_profile.py -v Expected: PASS.

  • Step 6: Run the full suite — sanity

Run: pytest l4d2web/tests -q Expected: PASS.

  • Step 7: Commit
git add l4d2web/routes/profile_routes.py l4d2web/app.py l4d2web/tests/test_profile.py
git commit -m "profile: POST /profile/password validation branches"

Task 9: Happy path — password actually rotates and current session survives

Files:

  • Test: l4d2web/tests/test_profile.py (append)

  • Step 1: Write the failing tests

Append to l4d2web/tests/test_profile.py:

def test_post_password_happy_path_rotates_and_keeps_current_session(app_and_user):
    app, uid, marker = app_and_user
    client = _logged_in_client(app, uid, marker)
    before_digest = _digest_of("alice")
    with session_scope() as db:
        before_marker = db.scalar(select(User).where(User.id == uid)).password_changed_at

    response = _post_pw(
        client,
        current_password="currentpass",
        new_password="newpass12",
        confirm_new_password="newpass12",
    )

    assert response.status_code == 302
    assert response.headers["Location"].endswith("/profile?success=1")

    # Password changed.
    after_digest = _digest_of("alice")
    assert after_digest != before_digest
    with session_scope() as db:
        u = db.scalar(select(User).where(User.id == uid))
        assert u.password_changed_at > before_marker
        new_marker = u.password_changed_at.isoformat()

    # Current session still authenticated.
    follow = client.get("/dashboard")
    assert follow.status_code == 200

    # Session marker was re-stamped to the new value.
    with client.session_transaction() as sess:
        assert sess["pw_changed_at"] == new_marker


def test_other_session_dies_after_password_change(app_and_user):
    app, uid, marker = app_and_user
    primary = _logged_in_client(app, uid, marker)
    other = _logged_in_client(app, uid, marker)

    # Other session works before the change.
    pre = other.get("/dashboard")
    assert pre.status_code == 200

    response = _post_pw(
        primary,
        current_password="currentpass",
        new_password="newpass12",
        confirm_new_password="newpass12",
    )
    assert response.status_code == 302

    # Other session is now stale.
    post = other.get("/dashboard", follow_redirects=False)
    assert post.status_code == 302
    assert "/login" in post.headers["Location"]


def test_new_password_works_for_login(app_and_user):
    app, uid, marker = app_and_user
    client = _logged_in_client(app, uid, marker)
    _post_pw(
        client,
        current_password="currentpass",
        new_password="newpass12",
        confirm_new_password="newpass12",
    )

    fresh = app.test_client()
    response = fresh.post(
        "/login",
        data={"username": "alice", "password": "newpass12"},
        headers={"X-CSRF-Token": "test-token"},
    )
    assert response.status_code == 302
    assert response.headers["Location"].endswith("/dashboard")
  • Step 2: Run — expect pass already

Run: pytest l4d2web/tests/test_profile.py -v -k happy_path Expected: PASS (Task 8 already implemented this).

Run the cross-session test: pytest l4d2web/tests/test_profile.py::test_other_session_dies_after_password_change -v Expected: PASS.

Run the login-with-new-password test: pytest l4d2web/tests/test_profile.py::test_new_password_works_for_login -v Expected: PASS.

If any of these fail, the bug is in Task 6 (stale-marker check) or Task 8 (digest rotation / re-stamp). Fix before continuing.

  • Step 3: Full suite

Run: pytest l4d2web/tests -q Expected: PASS.

  • Step 4: Commit
git add l4d2web/tests/test_profile.py
git commit -m "profile: happy-path + cross-session invalidation tests"

Task 10: Rate-limit test on POST /profile/password

Files:

  • Test: l4d2web/tests/test_profile.py (append)

  • Step 1: Write the failing test

Append to l4d2web/tests/test_profile.py:

def test_post_password_rate_limited(app_and_user):
    from l4d2web.routes.profile_routes import (
        PROFILE_PW_RATE_LIMIT_MAX_ATTEMPTS,
        reset_profile_password_rate_limits,
    )
    reset_profile_password_rate_limits()

    app, uid, marker = app_and_user
    client = _logged_in_client(app, uid, marker)

    # Use a deliberately-wrong current password so we never actually rotate.
    for _ in range(PROFILE_PW_RATE_LIMIT_MAX_ATTEMPTS):
        _post_pw(client, current_password="WRONG", new_password="newpass12", confirm_new_password="newpass12")

    blocked = _post_pw(client, current_password="WRONG", new_password="newpass12", confirm_new_password="newpass12")
    assert blocked.status_code == 429
  • Step 2: Run — expect pass

Run: pytest l4d2web/tests/test_profile.py::test_post_password_rate_limited -v Expected: PASS.

(If it fails because the limit constant differs between the test file and the route module, prefer importing the constant from the route module — already done above.)

  • Step 3: Commit
git add l4d2web/tests/test_profile.py
git commit -m "profile: rate-limit test for /profile/password"

Task 11 (optional): Apply validate_new_password to create-user CLI

This task is optional polish — keeps the password policy unified across all surfaces. Skip if the user prefers to keep CLI behavior unchanged.

Files:

  • Modify: l4d2web/cli.py:34

  • Test: l4d2web/tests/test_auth.py (append)

  • Step 1: Write the failing test

Append to l4d2web/tests/test_auth.py:

def test_create_user_cli_rejects_short_password(tmp_path, monkeypatch) -> None:
    db_url = f"sqlite:///{tmp_path/'short_pw.db'}"
    monkeypatch.setenv("DATABASE_URL", db_url)
    monkeypatch.setenv("LEFT4ME_ADMIN_PASSWORD", "short7x")
    app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
    init_db()

    result = app.test_cli_runner().invoke(args=["create-user", "admin", "--admin"])

    assert result.exit_code != 0
    assert "at least 8" in result.output
  • Step 2: Run — expect failure

Run: pytest l4d2web/tests/test_auth.py::test_create_user_cli_rejects_short_password -v Expected: FAIL — short password currently accepted.

  • Step 3: Wire validation into create_user

Edit l4d2web/cli.py. Update the import line:

from l4d2web.auth import hash_password, validate_new_password

Replace lines 34-35 (the empty-string check) with:

policy_error = validate_new_password(password)
if policy_error is not None:
    raise click.ClickException(policy_error)
  • Step 4: Run — expect pass

Run: pytest l4d2web/tests/test_auth.py -v -k cli Expected: all PASS, including the existing empty-password test (which is now caught by validate_new_password).

  • Step 5: Full suite

Run: pytest l4d2web/tests -q Expected: PASS.

  • Step 6: Commit
git add l4d2web/cli.py l4d2web/tests/test_auth.py
git commit -m "cli: apply min-length password policy in create-user"

Final verification

  • Run the full test suite
pytest l4d2web/tests -q

Expected: all PASS.

  • Run a manual smoke
rm -f l4d2web.db
alembic -c l4d2web/alembic.ini upgrade head
LEFT4ME_ADMIN_PASSWORD='changeme1' flask --app l4d2web.app:create_app create-user smoke --admin
SECRET_KEY=devdevdevdev flask --app l4d2web.app:create_app run --port 5001

Then in a browser:

  1. Log in as smoke / changeme1.
  2. Click the smoke username in the header → lands on /profile.
  3. Submit with wrong current password → error banner.
  4. Submit with mismatched confirm → error banner.
  5. Submit with 7-character new password → error banner.
  6. Submit with valid form → success banner, still logged in.
  7. Open a second incognito window, log in again as smoke / <new password> → success.
  8. Change password again from the first window.
  9. Reload /dashboard in the second window → redirected to /login.
  • Static checks (if configured)
ruff check l4d2web

Expected: clean.