feat: add secure invitation and reset links

This commit is contained in:
2026-08-05 11:31:38 +08:00
parent 540b8e0932
commit 8ef0c312ef
14 changed files with 418 additions and 75 deletions
+2 -1
View File
@@ -82,7 +82,8 @@ class Config:
ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD")
ADMIN_EMAIL = os.environ.get("ADMIN_EMAIL", "").strip().lower()
APP_TIMEZONE = os.environ.get("APP_TIMEZONE", "Asia/Shanghai").strip() or "Asia/Shanghai"
PASSWORD_RESET_TOKEN_MINUTES = env_int("PASSWORD_RESET_TOKEN_MINUTES", 30)
# 密码重置和管理员邀请链接均为一次性链接,默认 10 分钟后失效。
PASSWORD_RESET_TOKEN_MINUTES = env_int("PASSWORD_RESET_TOKEN_MINUTES", 10)
RESEND_API_KEY = os.environ.get("RESEND_API_KEY", "").strip()
RESEND_FROM_EMAIL = os.environ.get("RESEND_FROM_EMAIL", "").strip()
EMAIL_CODE_MINUTES = env_int("EMAIL_CODE_MINUTES", 10)
+15 -5
View File
@@ -39,14 +39,24 @@ def verification_code_email(*, code: str, minutes: int, purpose: str) -> str:
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."""
def password_reset_notice_email(*, username: str, reset_url: str, minutes: int) -> str:
"""Render a one-time password reset link email."""
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;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;">该链接仅可使用一次,并将在 {minutes} 分钟后失效。</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)
return render_transactional_email(title="重置账户密码", content=content)
def registration_invitation_email(*, invitation_url: str, minutes: int) -> str:
"""Render a one-time invitation link email for an administrator-created account."""
safe_url = escape(invitation_url, quote=True)
content = f"""
<p style="margin:0;color:#475569;font-size:15px;line-height:1.8;">管理员邀请你加入供水管道健康评估系统。</p>
<p style="margin:16px 0 24px;color:#475569;font-size:15px;line-height:1.8;">请通过下方按钮设置显示名和密码。该链接仅可使用一次,并将在 {minutes} 分钟后失效。</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]:
+19 -14
View File
@@ -6,17 +6,22 @@ from .extensions import db
def upgrade_schema() -> None:
"""Create new tables and add authentication columns without losing existing data."""
db.create_all()
inspector = inspect(db.engine)
columns = {item["name"] for item in inspector.get_columns("users")}
additions = {
"email": "VARCHAR(254)",
"email_verified_at": "DATETIME",
"is_active_account": "BOOLEAN NOT NULL DEFAULT 0",
"auth_version": "INTEGER NOT NULL DEFAULT 1",
}
for name, sql_type in additions.items():
if name not in columns:
db.session.execute(text(f"ALTER TABLE users ADD COLUMN {name} {sql_type}"))
db.session.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS ix_users_email_unique ON users (email) WHERE email IS NOT NULL"))
db.session.commit()
with db.engine.begin() as connection:
# Gunicorn workers can import the application simultaneously. Acquiring
# SQLite's write lock before inspecting or creating tables serializes the
# first-start migration and prevents duplicate CREATE TABLE attempts.
if db.engine.dialect.name == "sqlite":
connection.exec_driver_sql("BEGIN IMMEDIATE")
db.metadata.create_all(bind=connection)
inspector = inspect(connection)
columns = {item["name"] for item in inspector.get_columns("users")}
additions = {
"email": "VARCHAR(254)",
"email_verified_at": "DATETIME",
"is_active_account": "BOOLEAN NOT NULL DEFAULT 0",
"auth_version": "INTEGER NOT NULL DEFAULT 1",
}
for name, sql_type in additions.items():
if name not in columns:
connection.execute(text(f"ALTER TABLE users ADD COLUMN {name} {sql_type}"))
connection.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS ix_users_email_unique ON users (email) WHERE email IS NOT NULL"))
+14
View File
@@ -53,6 +53,20 @@ class PasswordResetToken(db.Model):
created_by = db.relationship("User", foreign_keys=[created_by_id])
class RegistrationInvitation(db.Model):
__tablename__ = "registration_invitations"
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(254), nullable=False, index=True)
token_hash = db.Column(db.String(64), unique=True, nullable=False, index=True)
expires_at = db.Column(db.DateTime, nullable=False, index=True)
used_at = db.Column(db.DateTime)
created_by_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
created_at = db.Column(db.DateTime, default=utc_now, nullable=False)
created_by = db.relationship("User", foreign_keys=[created_by_id])
class EmailVerificationCode(db.Model):
__tablename__ = "email_verification_codes"
+228 -35
View File
@@ -16,11 +16,20 @@ from .email import (
EmailConfigurationError,
EmailDeliveryError,
password_reset_notice_email,
registration_invitation_email,
send_transactional_email,
verification_code_email,
)
from .extensions import db
from .models import AppSetting, EmailVerificationCode, TrustedDevice, UploadRecord, User
from .models import (
AppSetting,
EmailVerificationCode,
PasswordResetToken,
RegistrationInvitation,
TrustedDevice,
UploadRecord,
User,
)
from .prediction import PredictionError, run_prediction
from .security import new_captcha
from .time_utils import format_datetime_for_timezone, utc_now
@@ -35,7 +44,6 @@ EMAIL_RE = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")
EMAIL_CODE_PURPOSES = {
"register",
"login",
"reset",
"change_password",
"change_email_old",
"change_email_new",
@@ -43,7 +51,6 @@ EMAIL_CODE_PURPOSES = {
EMAIL_CODE_LABELS = {
"register": "完成注册",
"login": "登录确认",
"reset": "重置密码",
"change_password": "修改密码",
"change_email_old": "确认原邮箱",
"change_email_new": "确认新邮箱",
@@ -59,6 +66,98 @@ def valid_email(value: str) -> bool:
return len(value) <= 254 and bool(EMAIL_RE.fullmatch(value))
def masked_email(value: str) -> str:
"""Return an email suitable for confirmation screens without exposing it."""
local_part, separator, domain = normal_email(value).partition("@")
if not separator or not local_part or not domain:
return "已绑定邮箱"
if len(local_part) == 1:
visible_local = "*"
elif len(local_part) == 2:
visible_local = f"{local_part[0]}*"
elif len(local_part) <= 4:
visible_local = f"{local_part[0]}**{local_part[-1]}"
else:
visible_local = f"{local_part[:2]}****{local_part[-2:]}"
return f"{visible_local}@{domain}"
def token_hash(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
def issue_password_reset_token(user: User, created_by: User) -> str:
"""Invalidate outstanding links for a user and create a fresh one-time reset link."""
now = utc_now()
PasswordResetToken.query.filter_by(user_id=user.id, used_at=None).update(
{"expires_at": now}, synchronize_session=False
)
token = secrets.token_urlsafe(32)
db.session.add(
PasswordResetToken(
user_id=user.id,
created_by_id=created_by.id,
token_hash=token_hash(token),
expires_at=now + timedelta(minutes=current_app.config["PASSWORD_RESET_TOKEN_MINUTES"]),
)
)
db.session.commit()
return token
def active_password_reset_token(token: str) -> PasswordResetToken | None:
return PasswordResetToken.query.filter(
PasswordResetToken.token_hash == token_hash(token),
PasswordResetToken.used_at.is_(None),
PasswordResetToken.expires_at > utc_now(),
).first()
def issue_registration_invitation(email: str, created_by: User) -> str:
"""Create a fresh invitation and invalidate any previous unused invitation."""
now = utc_now()
RegistrationInvitation.query.filter_by(email=email, used_at=None).update(
{"expires_at": now}, synchronize_session=False
)
token = secrets.token_urlsafe(32)
db.session.add(
RegistrationInvitation(
email=email,
created_by_id=created_by.id,
token_hash=token_hash(token),
expires_at=now + timedelta(minutes=current_app.config["PASSWORD_RESET_TOKEN_MINUTES"]),
)
)
db.session.commit()
return token
def active_registration_invitation(token: str) -> RegistrationInvitation | None:
return RegistrationInvitation.query.filter(
RegistrationInvitation.token_hash == token_hash(token),
RegistrationInvitation.used_at.is_(None),
RegistrationInvitation.expires_at > utc_now(),
).first()
def claim_token(model, token_id: int) -> bool:
"""Atomically consume a still-valid, one-time link token."""
return (
model.query.filter(
model.id == token_id,
model.used_at.is_(None),
model.expires_at > utc_now(),
).update({"used_at": utc_now()}, synchronize_session=False)
== 1
)
def revoke_user_authentication(user: User) -> None:
"""Invalidate browser sessions and trusted-device cookies for a user."""
user.revoke_authentication()
TrustedDevice.query.filter_by(user_id=user.id).delete()
def valid_password(password: str) -> bool:
return (
12 <= len(password) <= 128
@@ -73,6 +172,14 @@ def valid_password(password: str) -> bool:
PASSWORD_RULE_MESSAGE = "密码须为12至128位,包含大写字母、小写字母、数字和特殊字符,且不能含空格。"
def valid_new_password(user: User, password: str, confirmation: str) -> bool:
return (
valid_password(password)
and password == confirmation
and not user.check_password(password)
)
def grant_fresh_authorization(user: User, purpose: str) -> None:
"""Grant a short, session-bound authorization after successful email MFA."""
session[f"fresh_auth_{purpose}"] = {
@@ -332,7 +439,7 @@ def verification_page(title: str, purpose: str):
"verification.html",
title=title,
purpose=purpose,
email=session.get("pending_email", ""),
email=masked_email(session.get("pending_email", "")),
resend_seconds=current_app.config["EMAIL_CODE_RESEND_SECONDS"],
)
@@ -419,9 +526,6 @@ def verify_email(purpose: str):
trust_device = bool(request.form.get("trust_device"))
session.clear()
return login_response(user, remember, trust_device=trust_device)
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")
@@ -431,7 +535,7 @@ def verify_email(purpose: str):
if purpose == "change_email_new":
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"))
user.email = email; user.email_verified_at = utc_now(); revoke_user_authentication(user); db.session.commit(); logout_user(); session.clear(); flash("邮箱已更新,请重新登录。", "info"); return redirect(url_for("main.login"))
grant_fresh_authorization(user, "password_change")
return redirect(url_for("main.account_security"))
@@ -451,29 +555,89 @@ def forgot_password():
if request.method == "GET": return render_template("forgot_password.html")
email = normal_email(request.form.get("email", "")); user = User.query.filter_by(email=email, is_active_account=True).first()
if user:
session.update(pending_email=email, pending_user_id=user.id, pending_purpose="reset")
reason = issue_email_code(email, "reset")
if reason in {None, "resend_wait"}:
if reason == "resend_wait":
flash("邮箱验证码已发送,请直接输入验证码继续。", "info")
return redirect(url_for("main.verify_email", purpose="reset"))
flash(email_code_issue_message(reason), "error")
token = issue_password_reset_token(user, user)
try:
send_transactional_email(
to=user.email,
subject="【管道健康】重置密码",
html=password_reset_notice_email(
username=user.username,
reset_url=url_for("main.reset_password_with_token", token=token, _external=True),
minutes=current_app.config["PASSWORD_RESET_TOKEN_MINUTES"],
),
)
except (EmailConfigurationError, EmailDeliveryError):
current_app.logger.exception("密码重置邮件发送失败")
flash("若该邮箱已注册,重置链接将发送至邮箱。", "info")
return redirect(url_for("main.forgot_password"))
@bp.route("/reset-password/<token>", methods=["GET", "POST"])
def reset_password_with_token(token: str):
reset_token = active_password_reset_token(token)
if reset_token is None or not reset_token.user.is_active_account:
flash("该重置链接无效、已过期或已使用。请重新申请。", "error")
return redirect(url_for("main.forgot_password"))
flash("若该邮箱已注册,验证码将发送至邮箱。", "info")
if request.method == "GET":
return render_template("reset_password.html", token=token)
password = request.form.get("password", "")
confirm = request.form.get("password_confirm", "")
if not valid_new_password(reset_token.user, password, confirm):
flash(f"{PASSWORD_RULE_MESSAGE} 两次输入必须一致,且不能与当前密码相同。", "error")
return render_template("reset_password.html", token=token), 400
if not claim_token(PasswordResetToken, reset_token.id):
db.session.rollback()
flash("该重置链接已失效,请重新申请。", "error")
return redirect(url_for("main.forgot_password"))
reset_token.user.set_password(password)
revoke_user_authentication(reset_token.user)
db.session.commit()
flash("密码已重置,请使用新密码登录。", "info")
return redirect(url_for("main.login"))
@bp.route("/set-password", methods=["GET", "POST"])
def set_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(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("/invite/<token>", methods=["GET", "POST"])
def accept_registration_invitation(token: str):
invitation = active_registration_invitation(token)
if invitation is None:
flash("该注册链接无效、已过期或已使用。请联系管理员重新邀请。", "error")
return redirect(url_for("main.login"))
if User.query.filter_by(email=invitation.email).first():
flash("该邮箱已注册,请直接登录或联系管理员。", "error")
return redirect(url_for("main.login"))
if request.method == "GET":
return render_template("accept_invitation.html", token=token, email=masked_email(invitation.email))
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
confirm = request.form.get("password_confirm", "")
if not username or len(username) > 100:
flash("显示名不能为空且不能超过100个字符。", "error")
return render_template("accept_invitation.html", token=token, email=masked_email(invitation.email)), 400
if User.query.filter(User.username == username).first():
flash("该显示名已被使用。", "error")
return render_template("accept_invitation.html", token=token, email=masked_email(invitation.email)), 400
if not valid_password(password) or password != confirm:
flash(f"{PASSWORD_RULE_MESSAGE} 两次输入必须一致。", "error")
return render_template("accept_invitation.html", token=token, email=masked_email(invitation.email)), 400
if not claim_token(RegistrationInvitation, invitation.id):
db.session.rollback()
flash("该注册链接已失效,请联系管理员重新邀请。", "error")
return redirect(url_for("main.login"))
user = User(
username=username,
email=invitation.email,
email_verified_at=utc_now(),
is_active_account=True,
)
user.set_password(password)
db.session.add(user)
db.session.commit()
session.clear()
flash("注册成功,欢迎使用系统。", "info")
return login_response(user, remember=False)
@bp.route("/account/security", methods=["GET", "POST"])
@@ -492,7 +656,7 @@ def account_security():
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"))
revoke_user_authentication(current_user); 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"))
if action == "change_email":
@@ -517,7 +681,7 @@ def change_password():
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(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"))
current_user.set_password(password); revoke_user_authentication(current_user); db.session.commit(); consume_fresh_authorization("password_change"); logout_user(); flash("密码已更新,请重新登录。", "info"); return redirect(url_for("main.login"))
@bp.route("/logout", methods=["POST"])
@@ -569,6 +733,35 @@ def admin_dashboard():
def update_registration_setting():
require_admin(); enabled = request.form.get("allow_registration") == "on"; AppSetting.set_bool(REGISTRATION_SETTING_KEY, enabled); db.session.commit(); flash("已开放用户自助注册" if enabled else "已关闭用户自助注册", "info"); return redirect(url_for("main.admin_dashboard"))
@bp.route("/admin/invitations", methods=["POST"])
@login_required
def admin_send_registration_invitation():
require_admin()
email = normal_email(request.form.get("email", ""))
if not valid_email(email):
flash("请输入有效的受邀邮箱。", "error")
return admin_dashboard_redirect()
if User.query.filter_by(email=email).first():
flash("该邮箱已注册,不能重复发送邀请。", "error")
return admin_dashboard_redirect()
token = issue_registration_invitation(email, current_user)
try:
send_transactional_email(
to=email,
subject="【管道健康】注册邀请",
html=registration_invitation_email(
invitation_url=url_for("main.accept_registration_invitation", token=token, _external=True),
minutes=current_app.config["PASSWORD_RESET_TOKEN_MINUTES"],
),
)
except (EmailConfigurationError, EmailDeliveryError):
flash("邀请链接已生成,但邮件发送失败,请检查邮件服务配置后重新发送。", "error")
else:
flash("注册链接已发送至受邀邮箱。", "info")
return admin_dashboard_redirect()
@bp.route("/admin/users/<int:user_id>/password-reset", methods=["POST"])
@login_required
def admin_password_reset(user_id: int):
@@ -576,13 +769,15 @@ def admin_password_reset(user_id: int):
user = db.session.get(User, user_id)
if not user or user.is_admin:
abort(404)
token = issue_password_reset_token(user, current_user)
try:
send_transactional_email(
to=user.email,
subject="【管道健康】密码重置通知",
subject="【管道健康】重置密码",
html=password_reset_notice_email(
username=user.username,
reset_url=url_for("main.forgot_password", _external=True),
reset_url=url_for("main.reset_password_with_token", token=token, _external=True),
minutes=current_app.config["PASSWORD_RESET_TOKEN_MINUTES"],
),
)
except (EmailConfigurationError, EmailDeliveryError):
@@ -599,8 +794,7 @@ def admin_revoke_trusted_devices(user_id: int):
user = db.session.get(User, user_id)
if user is None or user.is_admin:
abort(404)
user.revoke_authentication()
TrustedDevice.query.filter_by(user_id=user.id).delete()
revoke_user_authentication(user)
db.session.commit()
flash(f"已撤销 {user.username} 的所有受信设备。", "info")
return admin_dashboard_redirect()
@@ -614,8 +808,7 @@ def admin_update_account_status(user_id: int):
if user is None or user.is_admin:
abort(404)
user.is_active_account = request.form.get("is_active") == "true"
user.revoke_authentication()
TrustedDevice.query.filter_by(user_id=user.id).delete()
revoke_user_authentication(user)
db.session.commit()
message = "已启用账号" if user.is_active_account else "已停用账号并撤销所有会话"
flash(f"{user.username}{message}", "info")