Implements the change-password endpoint: - Per-IP rate limit reusing services/rate_limit - Required fields, mismatched-confirm, policy, wrong-current branches each redirect with a specific ?error= key - Rotates digest + password_changed_at, then re-stamps the current session marker so this browser stays logged in while other sessions get rejected by load_current_user Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
91 lines
2.9 KiB
Python
91 lines
2.9 KiB
Python
from flask import Blueprint, Response, redirect, render_template, request, session
|
|
from sqlalchemy import select
|
|
|
|
from l4d2web.auth import (
|
|
current_user,
|
|
hash_password,
|
|
require_login,
|
|
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
|
|
|
|
|
|
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.",
|
|
}
|
|
|
|
|
|
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.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,
|
|
)
|
|
|
|
|
|
@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:
|
|
error_key = "empty" if policy_error == "password must not be empty" else "too_short"
|
|
return _redirect_with_error(error_key)
|
|
|
|
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()
|
|
|
|
session["pw_changed_at"] = new_marker
|
|
|
|
return redirect("/profile?success=1")
|