feat: add secure invitation and reset links
This commit is contained in:
+2
-1
@@ -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
@@ -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
@@ -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"))
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user