From 6eb9bd0ab3213d95e36c5cf865573b04c66933a9 Mon Sep 17 00:00:00 2001 From: mwiegand Date: Mon, 11 May 2026 21:41:25 +0200 Subject: [PATCH] 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) --- .../2026-05-11-profile-password-change-v1.md | 1260 +++++++++++++++++ 1 file changed, 1260 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-11-profile-password-change-v1.md diff --git a/docs/superpowers/plans/2026-05-11-profile-password-change-v1.md b/docs/superpowers/plans/2026-05-11-profile-password-change-v1.md new file mode 100644 index 0000000..e591721 --- /dev/null +++ b/docs/superpowers/plans/2026-05-11-profile-password-change-v1.md @@ -0,0 +1,1260 @@ +# 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.py` — `GET /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 `` becomes ``. +- `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`: + +```python +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: + +```python +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** + +```bash +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`: + +```python +"""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** + +```bash +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** + +```bash +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`: + +```python +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: + +```python +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** + +```bash +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`: + +```python +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`: + +```python +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: + +```python +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** + +```bash +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`: + +```python +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`): + +```python +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: + +```python +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** + +```bash +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`: + +```python +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): + +```python +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: + +```python +with bob_client.session_transaction() as sess: + sess["user_id"] = target + sess["pw_changed_at"] = .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** + +```bash +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`: + +```python +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`: + +```python +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`: + +```html +{% extends "base.html" %} + +{% block title %}Profile | left4me{% endblock %} + +{% block content %} +
+

Change password

+ + {% if success %} +

Password changed.

+ {% endif %} + {% if error_message %} +

{{ error_message }}

+ {% endif %} + +
+ + + + + +
+
+{% endblock %} +``` + +- [ ] **Step 5: Link the username in the header** + +Edit `l4d2web/templates/base.html` line 27. Replace: + +``` +{{ g.user.username }} +``` + +with: + +``` +
{{ g.user.username }} +``` + +- [ ] **Step 6: Register the blueprint** + +Edit `l4d2web/app.py`. In the import block, add: + +```python +from l4d2web.routes.profile_routes import bp as profile_bp +``` + +In the `register_blueprint` block (immediately after `app.register_blueprint(page_bp)`), add: + +```python +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** + +```bash +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`: + +```python +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`: + +```python +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`: + +```python +from l4d2web.routes.profile_routes import reset_profile_password_rate_limits +``` + +Then in the test branch: + +```python +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** + +```bash +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`: + +```python +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** + +```bash +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`: + +```python +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** + +```bash +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`: + +```python +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: + +```python +from l4d2web.auth import hash_password, validate_new_password +``` + +Replace lines 34-35 (the empty-string check) with: + +```python +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** + +```bash +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** + +```bash +pytest l4d2web/tests -q +``` + +Expected: all PASS. + +- [ ] **Run a manual smoke** + +```bash +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 / ` → success. +8. Change password again from the first window. +9. Reload `/dashboard` in the second window → redirected to `/login`. + +- [ ] **Static checks (if configured)** + +```bash +ruff check l4d2web +``` + +Expected: clean.