feat: harden account security flows

This commit is contained in:
2026-08-04 14:29:51 +08:00
parent 630252e2ff
commit c71b1351d6
14 changed files with 892 additions and 56 deletions
+20 -4
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import os
import secrets
from datetime import timedelta
from pathlib import Path
from dotenv import dotenv_values
@@ -45,20 +46,33 @@ def app_env() -> str:
return os.environ.get("APP_ENV", "development").strip().lower() or "development"
def database_url() -> str:
"""返回数据库地址,并将相对 SQLite 路径固定到项目目录。"""
value = os.environ.get("DATABASE_URL", "").strip()
if not value:
return f"sqlite:///{DATA_DIR / 'pipe_survival_0331.db'}"
prefix = "sqlite:///"
if value.startswith(prefix) and not value.startswith("sqlite:////"):
return f"sqlite:///{BASE_DIR / value.removeprefix(prefix)}"
return value
class Config:
APP_ENV = app_env()
DEBUG = env_bool("DEBUG", APP_ENV in {"dev", "development", "local"})
SECRET_KEY = os.environ.get("SECRET_KEY") or secrets.token_hex(32)
SECRET_KEY_GENERATED = not bool(os.environ.get("SECRET_KEY"))
SQLALCHEMY_DATABASE_URI = os.environ.get(
"DATABASE_URL",
f"sqlite:///{DATA_DIR / 'pipe_survival_0331.db'}",
)
SQLALCHEMY_DATABASE_URI = database_url()
SQLALCHEMY_TRACK_MODIFICATIONS = False
MAX_CONTENT_LENGTH = env_int("MAX_UPLOAD_BYTES", 16 * 1024 * 1024)
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
SESSION_COOKIE_SECURE = env_bool("SESSION_COOKIE_SECURE", APP_ENV == "production")
# 仅在用户主动勾选“保持登录状态”时使用。
REMEMBER_COOKIE_DURATION = timedelta(days=7)
REMEMBER_COOKIE_HTTPONLY = True
REMEMBER_COOKIE_SECURE = SESSION_COOKIE_SECURE
REMEMBER_COOKIE_SAMESITE = "Lax"
FUSION_MODEL_CORE_DIR = os.environ.get(
"FUSION_MODEL_CORE_DIR",
str(BASE_DIR / "model_core"),
@@ -74,6 +88,8 @@ class Config:
EMAIL_CODE_MINUTES = env_int("EMAIL_CODE_MINUTES", 10)
EMAIL_CODE_RESEND_SECONDS = env_int("EMAIL_CODE_RESEND_SECONDS", 60)
EMAIL_CODE_MAX_ATTEMPTS = env_int("EMAIL_CODE_MAX_ATTEMPTS", 5)
# 邮箱验证码通过后,可用于高风险操作的短时授权。
FRESH_AUTH_MINUTES = env_int("FRESH_AUTH_MINUTES", 5)
TRUSTED_DEVICE_DAYS = env_int("TRUSTED_DEVICE_DAYS", 30)
+34
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import logging
from html import escape
from typing import Any
import resend
@@ -15,6 +16,39 @@ class EmailDeliveryError(RuntimeError):
"""Raised when Resend rejects or cannot deliver an email request."""
def render_transactional_email(*, title: str, content: str) -> str:
"""Render a conservative table-based email layout for broad client support."""
return f"""<!doctype html>
<html lang="zh-CN"><body style="margin:0;background:#f3f5f8;color:#172033;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',Arial,sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="padding:32px 16px;background:#f3f5f8;"><tr><td align="center">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:600px;background:#ffffff;border:1px solid #e2e8f0;border-radius:12px;overflow:hidden;">
<tr><td style="padding:24px 32px;background:#005eb8;color:#ffffff;font-size:18px;font-weight:700;">供水管道健康评估系统</td></tr>
<tr><td style="padding:32px;"><h1 style="margin:0 0 12px;font-size:22px;line-height:1.4;color:#172033;">{escape(title)}</h1>{content}</td></tr>
<tr><td style="padding:18px 32px;border-top:1px solid #e2e8f0;color:#64748b;font-size:12px;line-height:1.7;">此邮件由系统自动发送,请勿直接回复。<br>如非本人操作,请忽略此邮件并及时检查账户安全。</td></tr>
</table>
</td></tr></table>
</body></html>"""
def verification_code_email(*, code: str, minutes: int, purpose: str) -> str:
"""Render the verification email without exposing dynamic HTML to callers."""
content = f"""
<p style="margin:0;color:#475569;font-size:15px;line-height:1.8;">你正在进行{escape(purpose)}。请在 {minutes} 分钟内输入下方验证码。</p>
<div style="margin:24px 0;padding:18px;border-radius:8px;background:#eaf3ff;color:#005eb8;text-align:center;font-size:30px;font-weight:700;letter-spacing:8px;">{escape(code)}</div>
<p style="margin:0;color:#64748b;font-size:13px;line-height:1.8;">验证码仅可使用一次,请勿向任何人透露。</p>"""
return render_transactional_email(title="邮箱验证码", content=content)
def password_reset_notice_email(*, username: str, reset_url: str) -> str:
"""Render an administrator-initiated password reset notification."""
safe_url = escape(reset_url, quote=True)
content = f"""
<p style="margin:0;color:#475569;font-size:15px;line-height:1.8;">{escape(username)},管理员已要求你重置密码。</p>
<p style="margin:16px 0 24px;color:#475569;font-size:15px;line-height:1.8;">请通过下方按钮进入找回密码流程,系统会向本邮箱发送一次性验证码。</p>
<p style="margin:0;"><a href="{safe_url}" style="display:inline-block;padding:12px 20px;border-radius:6px;background:#005eb8;color:#ffffff;font-size:14px;font-weight:700;text-decoration:none;">重置密码</a></p>"""
return render_transactional_email(title="请重置密码", content=content)
def send_transactional_email(*, to: str, subject: str, html: str) -> dict[str, Any]:
"""Send one application-generated email through Resend."""
api_key = current_app.config["RESEND_API_KEY"]
+93 -20
View File
@@ -4,14 +4,20 @@ import hashlib
import os
import re
import secrets
from datetime import timedelta
from datetime import datetime, timedelta
from flask import Blueprint, abort, current_app, flash, jsonify, redirect, render_template, request, send_file, session, url_for
from flask_login import current_user, login_required, login_user, logout_user
from sqlalchemy.orm import joinedload
from .config import BASE_DIR
from .email import EmailConfigurationError, EmailDeliveryError, send_transactional_email
from .email import (
EmailConfigurationError,
EmailDeliveryError,
password_reset_notice_email,
send_transactional_email,
verification_code_email,
)
from .extensions import db
from .models import AppSetting, EmailVerificationCode, TrustedDevice, UploadRecord, User
from .prediction import PredictionError, run_prediction
@@ -52,7 +58,47 @@ def valid_email(value: str) -> bool:
def valid_password(password: str) -> bool:
return 12 <= len(password) <= 128
return (
12 <= len(password) <= 128
and not any(character.isspace() for character in password)
and any(character.islower() for character in password)
and any(character.isupper() for character in password)
and any(character.isdigit() for character in password)
and any(not character.isalnum() for character in password)
)
PASSWORD_RULE_MESSAGE = "密码须为12至128位,包含大写字母、小写字母、数字和特殊字符,且不能含空格。"
def grant_fresh_authorization(user: User, purpose: str) -> None:
"""Grant a short, session-bound authorization after successful email MFA."""
session[f"fresh_auth_{purpose}"] = {
"user_id": user.id,
"auth_version": user.auth_version,
"expires_at": (
utc_now() + timedelta(minutes=current_app.config["FRESH_AUTH_MINUTES"])
).isoformat(),
}
def has_fresh_authorization(user: User, purpose: str) -> bool:
authorization = session.get(f"fresh_auth_{purpose}")
if not isinstance(authorization, dict):
return False
try:
expires_at = datetime.fromisoformat(authorization["expires_at"])
except (KeyError, TypeError, ValueError):
return False
return (
authorization.get("user_id") == user.id
and authorization.get("auth_version") == user.auth_version
and expires_at > utc_now()
)
def consume_fresh_authorization(purpose: str) -> None:
session.pop(f"fresh_auth_{purpose}", None)
def render_auth_template(mode: str, status_code: int = 200, captcha: str = ""):
@@ -136,10 +182,10 @@ def issue_email_code(email: str, purpose: str) -> bool:
send_transactional_email(
to=email,
subject=f"供水管道健康评估系统:{EMAIL_CODE_LABELS.get(purpose, '身份验证')}验证码",
html=(
"<p>你的验证码是:</p>"
f"<p style='font-size:28px;letter-spacing:6px'><strong>{code}</strong></p>"
f"<p>验证码 {current_app.config['EMAIL_CODE_MINUTES']} 分钟内有效,请勿向他人透露。</p>"
html=verification_code_email(
code=code,
minutes=current_app.config["EMAIL_CODE_MINUTES"],
purpose=EMAIL_CODE_LABELS.get(purpose, "身份验证"),
),
)
except (EmailConfigurationError, EmailDeliveryError):
@@ -212,7 +258,13 @@ def login_response(user: User, remember: bool, trust_device: bool = False):
def verification_page(title: str, purpose: str):
return render_template("verification.html", title=title, purpose=purpose, email=session.get("pending_email", ""))
return render_template(
"verification.html",
title=title,
purpose=purpose,
email=session.get("pending_email", ""),
resend_seconds=current_app.config["EMAIL_CODE_RESEND_SECONDS"],
)
@bp.route("/")
@@ -248,7 +300,7 @@ def register():
if not registration_allowed(): return render_auth_error("register", "当前未开放自助注册,请联系管理员。", 403)
if not username or len(username) > 100: return render_auth_error("register", "显示名不能为空且不能超过100个字符")
if not valid_email(email): return render_auth_error("register", "请输入有效的邮箱地址")
if not valid_password(password): return render_auth_error("register", "密码长度应为12至128位")
if not valid_password(password): return render_auth_error("register", PASSWORD_RULE_MESSAGE)
if User.query.filter((User.username == username) | (User.email == email)).first(): return render_auth_error("register", "显示名或邮箱已被使用")
user = User(username=username, email=email, is_admin=False, is_active_account=False)
user.set_password(password); db.session.add(user); db.session.commit()
@@ -271,7 +323,9 @@ def verify_email(purpose: str):
user.email_verified_at = utc_now(); user.is_active_account = True; db.session.commit(); session.clear(); flash("邮箱验证成功,请登录。", "info"); return redirect(url_for("main.login"))
if purpose == "login":
remember = bool(session.get("pending_remember")); session.clear(); return login_response(user, remember, trust_device=True)
if purpose == "reset": session["reset_verified_user_id"] = user.id; return redirect(url_for("main.set_password"))
if purpose == "reset":
grant_fresh_authorization(user, "password_reset")
return redirect(url_for("main.set_password"))
if purpose == "change_email_old":
new_email = session.get("new_email", "")
session.update(pending_email=new_email, pending_purpose="change_email_new")
@@ -282,7 +336,8 @@ def verify_email(purpose: str):
if User.query.filter(User.email == email, User.id != user.id).first():
flash("该邮箱已被使用。", "error"); return redirect(url_for("main.account_security"))
user.email = email; user.email_verified_at = utc_now(); user.revoke_authentication(); TrustedDevice.query.filter_by(user_id=user.id).delete(); db.session.commit(); logout_user(); session.clear(); flash("邮箱已更新,请重新登录。", "info"); return redirect(url_for("main.login"))
session["password_change_verified"] = True; return redirect(url_for("main.account_security"))
grant_fresh_authorization(user, "password_change")
return redirect(url_for("main.account_security"))
@bp.route("/verify/<purpose>/resend", methods=["POST"])
@@ -307,20 +362,33 @@ def forgot_password():
@bp.route("/set-password", methods=["GET", "POST"])
def set_password():
user = db.session.get(User, session.get("reset_verified_user_id"))
if not user: return redirect(url_for("main.forgot_password"))
authorization = session.get("fresh_auth_password_reset")
user = db.session.get(User, authorization.get("user_id")) if isinstance(authorization, dict) else None
if not user or not has_fresh_authorization(user, "password_reset"):
consume_fresh_authorization("password_reset")
return redirect(url_for("main.forgot_password"))
if request.method == "GET": return render_template("set_password.html", title="设置新密码", action=url_for("main.set_password"))
password, confirm = request.form.get("password", ""), request.form.get("password_confirm", "")
if not valid_password(password) or password != confirm: flash("密码应为12至128位,且两次输入一致。", "error"); return render_template("set_password.html", title="设置新密码", action=url_for("main.set_password")), 400
user.set_password(password); user.revoke_authentication(); TrustedDevice.query.filter_by(user_id=user.id).delete(); db.session.commit(); session.clear(); flash("密码已重置,请重新登录。", "info"); return redirect(url_for("main.login"))
if not valid_password(password) or password != confirm: flash(f"{PASSWORD_RULE_MESSAGE} 两次输入必须一致。", "error"); return render_template("set_password.html", title="设置新密码", action=url_for("main.set_password")), 400
user.set_password(password); user.revoke_authentication(); TrustedDevice.query.filter_by(user_id=user.id).delete(); db.session.commit(); consume_fresh_authorization("password_reset"); session.clear(); flash("密码已重置,请重新登录。", "info"); return redirect(url_for("main.login"))
@bp.route("/account/security", methods=["GET", "POST"])
@login_required
def account_security():
if request.method == "GET": return render_template("account_security.html")
if request.method == "GET":
return render_template(
"account_security.html",
password_change_authorized=has_fresh_authorization(
current_user,
"password_change",
),
)
action = request.form.get("action")
if action == "revoke_devices":
if not current_user.check_password(request.form.get("current_password", "")):
flash("当前密码不正确,未撤销受信设备。", "error")
return redirect(url_for("main.account_security"))
current_user.revoke_authentication(); TrustedDevice.query.filter_by(user_id=current_user.id).delete(); db.session.commit(); logout_user(); flash("所有受信设备已撤销,请重新登录。", "info"); return redirect(url_for("main.login"))
if not current_user.check_password(request.form.get("current_password", "")):
flash("当前密码不正确。", "error"); return redirect(url_for("main.account_security"))
@@ -339,10 +407,12 @@ def account_security():
@bp.route("/account/change-password", methods=["POST"])
@login_required
def change_password():
if not session.pop("password_change_verified", False): abort(403)
if not has_fresh_authorization(current_user, "password_change"):
consume_fresh_authorization("password_change")
abort(403)
password, confirm = request.form.get("password", ""), request.form.get("password_confirm", "")
if not valid_password(password) or password != confirm or current_user.check_password(password): flash("密码应为12至128位、两次一致且不能与当前密码相同。", "error"); return redirect(url_for("main.account_security"))
current_user.set_password(password); current_user.revoke_authentication(); TrustedDevice.query.filter_by(user_id=current_user.id).delete(); db.session.commit(); logout_user(); flash("密码已更新,请重新登录。", "info"); return redirect(url_for("main.login"))
if not valid_password(password) or password != confirm or current_user.check_password(password): flash(f"{PASSWORD_RULE_MESSAGE} 两次输入必须一致且不能与当前密码相同。", "error"); return redirect(url_for("main.account_security"))
current_user.set_password(password); current_user.revoke_authentication(); TrustedDevice.query.filter_by(user_id=current_user.id).delete(); db.session.commit(); consume_fresh_authorization("password_change"); logout_user(); flash("密码已更新,请重新登录。", "info"); return redirect(url_for("main.login"))
@bp.route("/logout", methods=["POST"])
@@ -379,7 +449,10 @@ def admin_password_reset(user_id: int):
send_transactional_email(
to=user.email,
subject="供水管道健康评估系统:请重置密码",
html=f"<p>{user.username},管理员已要求你重置密码。</p><p>请访问 <a href='{url_for('main.forgot_password', _external=True)}'>找回密码</a>,系统会将一次性验证码发送到本邮箱。</p>",
html=password_reset_notice_email(
username=user.username,
reset_url=url_for("main.forgot_password", _external=True),
),
)
except (EmailConfigurationError, EmailDeliveryError):
return jsonify({"error": "邮件发送失败"}), 503