feat: harden account security flows
This commit is contained in:
+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