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
+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