Add secure password reset email delivery
This commit is contained in:
parent
6a88fe2e0f
commit
9bedf70371
|
|
@ -11,8 +11,9 @@ import secrets
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import date, datetime, timedelta, timezone
|
from datetime import date, datetime, timedelta, timezone
|
||||||
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
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
|
import yaml
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from argon2 import PasswordHasher
|
from argon2 import PasswordHasher
|
||||||
|
|
@ -67,6 +68,11 @@ rate_limiter = RateLimiter()
|
||||||
password_hasher = PasswordHasher(memory_cost=65536, time_cost=3, parallelism=4)
|
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)
|
@dataclass(frozen=True)
|
||||||
class Semver:
|
class Semver:
|
||||||
major: int
|
major: int
|
||||||
|
|
@ -275,6 +281,14 @@ def run_ai_scrutiny_review(scrutiny_report: dict, config: dict, tool_name: str,
|
||||||
def create_app() -> Flask:
|
def create_app() -> Flask:
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.config["MAX_CONTENT_LENGTH"] = MAX_BODY_BYTES
|
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
|
# Initialize database schema once at startup
|
||||||
with connect_db() as init_conn:
|
with connect_db() as init_conn:
|
||||||
|
|
@ -2300,7 +2314,7 @@ def create_app() -> Flask:
|
||||||
|
|
||||||
# Generate a secure token
|
# Generate a secure token
|
||||||
token, token_hash = generate_token()
|
token, token_hash = generate_token()
|
||||||
expires_at = datetime.utcnow() + timedelta(hours=1)
|
expires_at = _naive_utc_now() + timedelta(hours=1)
|
||||||
|
|
||||||
g.db.execute(
|
g.db.execute(
|
||||||
"""
|
"""
|
||||||
|
|
@ -2311,10 +2325,44 @@ def create_app() -> Flask:
|
||||||
)
|
)
|
||||||
g.db.commit()
|
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
|
from cmdforge.web.email import send_password_reset_email
|
||||||
base_url = request.url_root.rstrip("/")
|
base_url = str(current_app.config.get("PUBLIC_BASE_URL", "")).rstrip("/")
|
||||||
send_password_reset_email(publisher["email"], token, base_url)
|
environment = str(current_app.config.get("CMDFORGE_ENV", "development")).lower()
|
||||||
|
if not base_url and environment != "production":
|
||||||
|
base_url = request.url_root.rstrip("/")
|
||||||
|
|
||||||
|
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
|
return success_response
|
||||||
|
|
||||||
|
|
@ -2346,7 +2394,7 @@ def create_app() -> Flask:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
expires_at = datetime.fromisoformat(row["expires_at"])
|
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)
|
return error_response("TOKEN_EXPIRED", "This reset token has expired", 400)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return error_response("INVALID_TOKEN", "Invalid token data", 400)
|
return error_response("INVALID_TOKEN", "Invalid token data", 400)
|
||||||
|
|
@ -2388,7 +2436,7 @@ def create_app() -> Flask:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
expires_at = datetime.fromisoformat(row["expires_at"])
|
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)
|
return error_response("TOKEN_EXPIRED", "This reset token has expired", 400)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return error_response("INVALID_TOKEN", "Invalid token data", 400)
|
return error_response("INVALID_TOKEN", "Invalid token data", 400)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
Production delivery uses Resend's HTTPS API. Email bodies and reset URLs are
|
||||||
To enable real email sending, set MAIL_ENABLED=true and configure SMTP settings.
|
never written to logs. Console delivery is an explicit development-only mode.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from html import escape
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
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.
|
``MAIL_TRANSPORT`` must be one of ``disabled``, ``console``, or ``resend``.
|
||||||
In production (MAIL_ENABLED=true), sends via SMTP.
|
Console mode records only that a message was suppressed and reports no
|
||||||
|
delivery, so reset tokens are invalidated. It is also rejected in
|
||||||
Args:
|
production. Resend mode requires ``RESEND_API_KEY`` and ``MAIL_FROM``.
|
||||||
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_enabled = current_app.config.get("MAIL_ENABLED", False)
|
transport = _transport()
|
||||||
|
environment = str(current_app.config.get("CMDFORGE_ENV", "development")).lower()
|
||||||
|
|
||||||
if mail_enabled:
|
if transport == "disabled":
|
||||||
# Future: implement real SMTP sending
|
logger.warning("Email delivery is disabled")
|
||||||
# smtp_host = current_app.config.get("MAIL_SERVER", "localhost")
|
return False
|
||||||
# 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.")
|
|
||||||
|
|
||||||
# Dev mode: log to console
|
if transport == "console":
|
||||||
logger.info("=" * 60)
|
if environment == "production":
|
||||||
logger.info("[EMAIL] To: %s", to)
|
logger.error("Console email transport is not permitted in production")
|
||||||
logger.info("[EMAIL] Subject: %s", subject)
|
return False
|
||||||
logger.info("[EMAIL] Body:")
|
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:
|
if text_body:
|
||||||
logger.info(text_body)
|
payload["text"] = text_body
|
||||||
else:
|
|
||||||
logger.info(html_body)
|
|
||||||
logger.info("=" * 60)
|
|
||||||
|
|
||||||
|
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
|
return True
|
||||||
|
|
||||||
|
|
||||||
def send_password_reset_email(to: str, token: str, base_url: str) -> bool:
|
def send_password_reset_email(to: str, token: str, base_url: str) -> bool:
|
||||||
"""Send a password reset email.
|
"""Send a one-hour password reset link using the configured transport."""
|
||||||
|
|
||||||
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
|
|
||||||
"""
|
|
||||||
reset_url = f"{base_url.rstrip('/')}/reset-password?token={token}"
|
reset_url = f"{base_url.rstrip('/')}/reset-password?token={token}"
|
||||||
|
html_reset_url = escape(reset_url, quote=True)
|
||||||
subject = "Reset your CmdForge password"
|
subject = "Reset your CmdForge password"
|
||||||
|
|
||||||
html_body = f"""
|
html_body = f"""
|
||||||
|
|
@ -86,28 +117,15 @@ def send_password_reset_email(to: str, token: str, base_url: str) -> bool:
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header">
|
<div class="header"><div class="logo">CmdForge</div></div>
|
||||||
<div class="logo">CmdForge</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p>Hello,</p>
|
<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>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="{html_reset_url}" class="button">Reset Password</a></p>
|
||||||
<p style="text-align: center; margin: 30px 0;">
|
|
||||||
<a href="{reset_url}" class="button">Reset Password</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>Or copy and paste this link into your browser:</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><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>
|
<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.</p></div>
|
||||||
<div class="footer">
|
|
||||||
<p>This email was sent by CmdForge. If you have questions, visit our website or contact support.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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
|
text_body = f"""Reset your CmdForge password
|
||||||
|
|
||||||
Hello,
|
Visit this link to set a new password:
|
||||||
|
|
||||||
You requested to reset your password for your CmdForge account.
|
|
||||||
|
|
||||||
Reset your password by visiting this link:
|
|
||||||
{reset_url}
|
{reset_url}
|
||||||
|
|
||||||
This link will expire in 1 hour.
|
This link will expire in 1 hour. If you did not request this reset, ignore this email.
|
||||||
|
|
||||||
If you didn't request this password reset, you can safely ignore this email.
|
|
||||||
Your password will remain unchanged.
|
|
||||||
|
|
||||||
---
|
|
||||||
CmdForge
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return send_email(to, subject, html_body, text_body)
|
return send_email(to, subject, html_body, text_body)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in New Issue