Add secure password reset email delivery

This commit is contained in:
rob 2026-07-21 12:42:05 -03:00
parent 6a88fe2e0f
commit 9bedf70371
3 changed files with 309 additions and 80 deletions

View File

@ -11,8 +11,9 @@ import secrets
from dataclasses import dataclass
from datetime import date, datetime, timedelta, timezone
from typing import Any, Dict, Iterable, List, Optional, Tuple
from urllib.parse import urlsplit
from flask import Flask, Response, g, jsonify, request
from flask import Flask, Response, current_app, g, jsonify, request
import yaml
from functools import wraps
from argon2 import PasswordHasher
@ -67,6 +68,11 @@ rate_limiter = RateLimiter()
password_hasher = PasswordHasher(memory_cost=65536, time_cost=3, parallelism=4)
def _naive_utc_now() -> datetime:
"""Return UTC without tzinfo for compatibility with existing DB values."""
return datetime.now(timezone.utc).replace(tzinfo=None)
@dataclass(frozen=True)
class Semver:
major: int
@ -275,6 +281,14 @@ def run_ai_scrutiny_review(scrutiny_report: dict, config: dict, tool_name: str,
def create_app() -> Flask:
app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = MAX_BODY_BYTES
app.config.update(
CMDFORGE_ENV=os.environ.get("CMDFORGE_ENV", "development"),
PUBLIC_BASE_URL=os.environ.get("CMDFORGE_PUBLIC_URL", "").rstrip("/"),
MAIL_TRANSPORT=os.environ.get("MAIL_TRANSPORT", "disabled"),
MAIL_FROM=os.environ.get("MAIL_FROM", ""),
MAIL_TIMEOUT=os.environ.get("MAIL_TIMEOUT", "10"),
RESEND_API_KEY=os.environ.get("RESEND_API_KEY", ""),
)
# Initialize database schema once at startup
with connect_db() as init_conn:
@ -2300,7 +2314,7 @@ def create_app() -> Flask:
# Generate a secure token
token, token_hash = generate_token()
expires_at = datetime.utcnow() + timedelta(hours=1)
expires_at = _naive_utc_now() + timedelta(hours=1)
g.db.execute(
"""
@ -2311,10 +2325,44 @@ def create_app() -> Flask:
)
g.db.commit()
# Send email (logs to console in dev mode)
# Use a configured canonical URL in production. Falling back to the
# request URL is development-only because Host headers are untrusted.
from cmdforge.web.email import send_password_reset_email
base_url = str(current_app.config.get("PUBLIC_BASE_URL", "")).rstrip("/")
environment = str(current_app.config.get("CMDFORGE_ENV", "development")).lower()
if not base_url and environment != "production":
base_url = request.url_root.rstrip("/")
send_password_reset_email(publisher["email"], token, base_url)
parsed_base = urlsplit(base_url)
base_url_is_safe = bool(
base_url
and parsed_base.scheme in {"http", "https"}
and parsed_base.netloc
and parsed_base.username is None
and parsed_base.password is None
and not parsed_base.query
and not parsed_base.fragment
and (environment != "production" or parsed_base.scheme == "https")
)
try:
delivered = base_url_is_safe and send_password_reset_email(
publisher["email"], token, base_url
)
except Exception:
# Preserve the non-enumerating response while ensuring the token is
# invalidated below. Flask logs the exception without email/token
# values because they are not part of this message.
current_app.logger.exception("Password reset delivery raised unexpectedly")
delivered = False
if not delivered:
# Never leave a usable reset token in the database when its only
# intended recipient could not receive it.
g.db.execute(
"UPDATE password_reset_tokens SET used_at = CURRENT_TIMESTAMP WHERE token_hash = ?",
[token_hash],
)
g.db.commit()
return success_response
@ -2346,7 +2394,7 @@ def create_app() -> Flask:
try:
expires_at = datetime.fromisoformat(row["expires_at"])
if datetime.utcnow() > expires_at:
if _naive_utc_now() > expires_at:
return error_response("TOKEN_EXPIRED", "This reset token has expired", 400)
except ValueError:
return error_response("INVALID_TOKEN", "Invalid token data", 400)
@ -2388,7 +2436,7 @@ def create_app() -> Flask:
try:
expires_at = datetime.fromisoformat(row["expires_at"])
if datetime.utcnow() > expires_at:
if _naive_utc_now() > expires_at:
return error_response("TOKEN_EXPIRED", "This reset token has expired", 400)
except ValueError:
return error_response("INVALID_TOKEN", "Invalid token data", 400)

View File

@ -1,72 +1,103 @@
"""Email sending utilities for CmdForge web.
"""Transactional email utilities for the CmdForge web application.
In development mode, emails are logged to the console instead of being sent.
To enable real email sending, set MAIL_ENABLED=true and configure SMTP settings.
Production delivery uses Resend's HTTPS API. Email bodies and reset URLs are
never written to logs. Console delivery is an explicit development-only mode.
"""
from __future__ import annotations
import logging
from html import escape
from typing import Optional
import requests
from flask import current_app
logger = logging.getLogger(__name__)
RESEND_ENDPOINT = "https://api.resend.com/emails"
SUPPORTED_TRANSPORTS = {"disabled", "console", "resend"}
def _transport() -> str:
transport = str(current_app.config.get("MAIL_TRANSPORT", "disabled")).strip().lower()
if transport not in SUPPORTED_TRANSPORTS:
logger.error("Unsupported email transport configured; delivery disabled")
return "disabled"
return transport
def send_email(to: str, subject: str, html_body: str, text_body: Optional[str] = None) -> bool:
"""Send an email.
"""Deliver an email without logging its recipient or contents.
In dev mode (MAIL_ENABLED=false or unset), logs the email to console.
In production (MAIL_ENABLED=true), sends via SMTP.
Args:
to: Recipient email address
subject: Email subject
html_body: HTML content of the email
text_body: Plain text content (optional)
Returns:
True if email was sent/logged successfully, False otherwise
``MAIL_TRANSPORT`` must be one of ``disabled``, ``console``, or ``resend``.
Console mode records only that a message was suppressed and reports no
delivery, so reset tokens are invalidated. It is also rejected in
production. Resend mode requires ``RESEND_API_KEY`` and ``MAIL_FROM``.
"""
mail_enabled = current_app.config.get("MAIL_ENABLED", False)
transport = _transport()
environment = str(current_app.config.get("CMDFORGE_ENV", "development")).lower()
if mail_enabled:
# Future: implement real SMTP sending
# smtp_host = current_app.config.get("MAIL_SERVER", "localhost")
# smtp_port = current_app.config.get("MAIL_PORT", 587)
# smtp_user = current_app.config.get("MAIL_USERNAME")
# smtp_pass = current_app.config.get("MAIL_PASSWORD")
# smtp_tls = current_app.config.get("MAIL_USE_TLS", True)
logger.warning("MAIL_ENABLED is true but SMTP is not implemented yet. Falling back to console logging.")
if transport == "disabled":
logger.warning("Email delivery is disabled")
return False
# Dev mode: log to console
logger.info("=" * 60)
logger.info("[EMAIL] To: %s", to)
logger.info("[EMAIL] Subject: %s", subject)
logger.info("[EMAIL] Body:")
if transport == "console":
if environment == "production":
logger.error("Console email transport is not permitted in production")
return False
logger.info("Development email suppressed (subject length=%d)", len(subject))
return False
api_key = str(current_app.config.get("RESEND_API_KEY") or "").strip()
sender = str(current_app.config.get("MAIL_FROM") or "").strip()
if not api_key or not sender:
logger.error("Resend email transport is missing required configuration")
return False
try:
timeout = float(current_app.config.get("MAIL_TIMEOUT", 10.0))
except (TypeError, ValueError):
logger.error("Invalid email timeout configured")
return False
if timeout <= 0 or timeout > 60:
logger.error("Email timeout must be greater than zero and at most 60 seconds")
return False
payload = {
"from": sender,
"to": [to],
"subject": subject,
"html": html_body,
}
if text_body:
logger.info(text_body)
else:
logger.info(html_body)
logger.info("=" * 60)
payload["text"] = text_body
try:
response = requests.post(
RESEND_ENDPOINT,
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json=payload,
timeout=timeout,
)
response.raise_for_status()
except requests.RequestException as exc:
# Do not log the response body, recipient, request headers, or message;
# providers can echo sensitive request data in error responses.
logger.error("Transactional email delivery failed (%s)", type(exc).__name__)
return False
logger.info("Transactional email accepted by provider")
return True
def send_password_reset_email(to: str, token: str, base_url: str) -> bool:
"""Send a password reset email.
Args:
to: Recipient email address
token: The password reset token
base_url: Base URL of the application (e.g., https://cmdforge.brrd.tech)
Returns:
True if email was sent/logged successfully
"""
"""Send a one-hour password reset link using the configured transport."""
reset_url = f"{base_url.rstrip('/')}/reset-password?token={token}"
html_reset_url = escape(reset_url, quote=True)
subject = "Reset your CmdForge password"
html_body = f"""
@ -86,28 +117,15 @@ def send_password_reset_email(to: str, token: str, base_url: str) -> bool:
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">CmdForge</div>
</div>
<div class="header"><div class="logo">CmdForge</div></div>
<p>Hello,</p>
<p>You requested to reset your password for your CmdForge account. Click the button below to set a new password:</p>
<p style="text-align: center; margin: 30px 0;">
<a href="{reset_url}" class="button">Reset Password</a>
</p>
<p style="text-align: center; margin: 30px 0;"><a href="{html_reset_url}" class="button">Reset Password</a></p>
<p>Or copy and paste this link into your browser:</p>
<p class="link">{reset_url}</p>
<p class="link">{html_reset_url}</p>
<p><strong>This link will expire in 1 hour.</strong></p>
<p>If you didn't request this password reset, you can safely ignore this email. Your password will remain unchanged.</p>
<div class="footer">
<p>This email was sent by CmdForge. If you have questions, visit our website or contact support.</p>
</div>
<div class="footer"><p>This email was sent by CmdForge.</p></div>
</div>
</body>
</html>
@ -115,20 +133,10 @@ def send_password_reset_email(to: str, token: str, base_url: str) -> bool:
text_body = f"""Reset your CmdForge password
Hello,
You requested to reset your password for your CmdForge account.
Reset your password by visiting this link:
Visit this link to set a new password:
{reset_url}
This link will expire in 1 hour.
If you didn't request this password reset, you can safely ignore this email.
Your password will remain unchanged.
---
CmdForge
This link will expire in 1 hour. If you did not request this reset, ignore this email.
"""
return send_email(to, subject, html_body, text_body)

173
tests/test_email.py Normal file
View File

@ -0,0 +1,173 @@
"""Tests for transactional email and password-reset delivery safety."""
from __future__ import annotations
import hashlib
import logging
from unittest.mock import patch
import requests
from flask import Flask
from cmdforge.registry.app import create_app, password_hasher
from cmdforge.registry.db import connect_db
from cmdforge.web.email import send_email
def _email_app(**config) -> Flask:
app = Flask(__name__)
defaults = {
"CMDFORGE_ENV": "development",
"MAIL_TRANSPORT": "disabled",
"MAIL_FROM": "CmdForge <noreply@example.com>",
"MAIL_TIMEOUT": 10,
"RESEND_API_KEY": "",
}
defaults.update(config)
app.config.update(defaults)
return app
def test_disabled_delivery_does_not_log_message_or_recipient(caplog):
app = _email_app()
with app.app_context(), caplog.at_level(logging.INFO):
assert not send_email(
"private@example.com", "Reset subject", "secret reset body", "secret text"
)
assert "private@example.com" not in caplog.text
assert "secret reset body" not in caplog.text
assert "secret text" not in caplog.text
def test_console_transport_is_rejected_in_production(caplog):
app = _email_app(CMDFORGE_ENV="production", MAIL_TRANSPORT="console")
with app.app_context(), caplog.at_level(logging.INFO):
assert not send_email("private@example.com", "subject", "body")
assert "private@example.com" not in caplog.text
assert "body" not in caplog.text
def test_resend_transport_sends_expected_request(monkeypatch):
app = _email_app(MAIL_TRANSPORT="resend", RESEND_API_KEY="test-api-key")
captured = {}
class Response:
def raise_for_status(self):
return None
def fake_post(url, **kwargs):
captured["url"] = url
captured.update(kwargs)
return Response()
monkeypatch.setattr(requests, "post", fake_post)
with app.app_context():
assert send_email("person@example.com", "subject", "<p>body</p>", "body")
assert captured["url"] == "https://api.resend.com/emails"
assert captured["headers"]["Authorization"] == "Bearer test-api-key"
assert captured["json"]["to"] == ["person@example.com"]
assert captured["timeout"] == 10.0
def test_resend_failure_does_not_log_sensitive_data(monkeypatch, caplog):
app = _email_app(MAIL_TRANSPORT="resend", RESEND_API_KEY="test-api-key")
def fail_post(*args, **kwargs):
raise requests.ConnectionError("provider unavailable")
monkeypatch.setattr(requests, "post", fail_post)
with app.app_context(), caplog.at_level(logging.INFO):
assert not send_email("private@example.com", "subject", "reset-secret")
assert "private@example.com" not in caplog.text
assert "reset-secret" not in caplog.text
assert "test-api-key" not in caplog.text
def _registry_client(tmp_path, monkeypatch):
db_path = tmp_path / "registry.db"
monkeypatch.setenv("CMDFORGE_REGISTRY_DB", str(db_path))
monkeypatch.setenv("CMDFORGE_ENV", "production")
monkeypatch.setenv("CMDFORGE_PUBLIC_URL", "https://cmdforge.example")
app = create_app()
app.config["TESTING"] = True
with connect_db() as db:
db.execute(
"""
INSERT INTO publishers (email, password_hash, slug, display_name)
VALUES (?, ?, ?, ?)
""",
["owner@example.com", password_hasher.hash("initial-password"), "owner", "Owner"],
)
db.commit()
return app.test_client(), db_path
def test_reset_uses_canonical_url_and_leaves_delivered_token_valid(tmp_path, monkeypatch):
client, db_path = _registry_client(tmp_path, monkeypatch)
captured = {}
def delivered(to, token, base_url):
captured.update(to=to, token=token, base_url=base_url)
return True
with patch("cmdforge.web.email.send_password_reset_email", delivered):
response = client.post(
"/api/v1/password-reset/request",
json={"email": "owner@example.com"},
headers={"Host": "attacker.example"},
)
assert response.status_code == 200
assert captured["base_url"] == "https://cmdforge.example"
with connect_db() as db:
row = db.execute(
"SELECT token_hash, used_at FROM password_reset_tokens"
).fetchone()
assert row["token_hash"] == hashlib.sha256(captured["token"].encode()).hexdigest()
assert row["used_at"] is None
def test_reset_invalidates_token_when_delivery_fails(tmp_path, monkeypatch):
client, db_path = _registry_client(tmp_path, monkeypatch)
with patch("cmdforge.web.email.send_password_reset_email", return_value=False):
response = client.post(
"/api/v1/password-reset/request", json={"email": "owner@example.com"}
)
assert response.status_code == 200
with connect_db() as db:
row = db.execute("SELECT used_at FROM password_reset_tokens").fetchone()
assert row["used_at"] is not None
def test_reset_invalidates_token_without_safe_production_base_url(tmp_path, monkeypatch):
client, db_path = _registry_client(tmp_path, monkeypatch)
client.application.config["PUBLIC_BASE_URL"] = "http://insecure.example"
with patch("cmdforge.web.email.send_password_reset_email") as sender:
response = client.post(
"/api/v1/password-reset/request", json={"email": "owner@example.com"}
)
assert response.status_code == 200
sender.assert_not_called()
with connect_db() as db:
row = db.execute("SELECT used_at FROM password_reset_tokens").fetchone()
assert row["used_at"] is not None
def test_unknown_email_creates_no_token_and_sends_nothing(tmp_path, monkeypatch):
client, db_path = _registry_client(tmp_path, monkeypatch)
with patch("cmdforge.web.email.send_password_reset_email") as sender:
response = client.post(
"/api/v1/password-reset/request", json={"email": "missing@example.com"}
)
assert response.status_code == 200
sender.assert_not_called()
with connect_db() as db:
count = db.execute("SELECT COUNT(*) FROM password_reset_tokens").fetchone()[0]
assert count == 0