feat: harden account security flows
This commit is contained in:
+20
-4
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user