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
View File
@@ -39,6 +39,8 @@ RESEND_FROM_EMAIL=no-reply@waternetwork.cn
EMAIL_CODE_MINUTES=10
EMAIL_CODE_RESEND_SECONDS=60
EMAIL_CODE_MAX_ATTEMPTS=5
# PASSWORD_RESET_TOKEN_MINUTES:密码重置和管理员注册链接的有效期(分钟)。
PASSWORD_RESET_TOKEN_MINUTES=10
# FRESH_AUTH_MINUTES:改密、重置密码等敏感操作的短时授权有效期(分钟)。
FRESH_AUTH_MINUTES=5
# TRUSTED_DEVICE_DAYS:受信设备有效期(天)。
+1 -1
View File
@@ -37,4 +37,4 @@ RUN mkdir -p data static/images uploads
EXPOSE 5005
CMD ["conda", "run", "--no-capture-output", "-n", "demo", "gunicorn", "--bind", "0.0.0.0:5005", "--workers", "2", "--threads", "4", "--timeout", "120", "main:app"]
CMD ["conda", "run", "--no-capture-output", "-n", "demo", "gunicorn", "--preload", "--bind", "0.0.0.0:5005", "--workers", "2", "--threads", "4", "--timeout", "120", "main:app"]
+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")
+5 -8
View File
@@ -1,8 +1,6 @@
services:
pipeline-lifetime:
build:
context: .
image: pipeline-lifetime:latest
image: pipeline-lifetime:20260805.1
container_name: pipeline-lifetime
restart: unless-stopped
environment:
@@ -10,19 +8,19 @@ services:
DEBUG: ${DEBUG:-false}
SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?Set ADMIN_PASSWORD in .env}
ADMIN_EMAIL: ${ADMIN_EMAIL:?Set ADMIN_EMAIL in .env}
DATABASE_URL: ${DATABASE_URL:-sqlite:////app/data/pipe_survival_0331.db}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
ADMIN_EMAIL: ${ADMIN_EMAIL:-}
DATABASE_URL: ${DATABASE_URL:-sqlite:////app/data/pipe_survival.db}
APP_TIMEZONE: ${APP_TIMEZONE:-Asia/Shanghai}
MAX_UPLOAD_BYTES: ${MAX_UPLOAD_BYTES:-16777216}
FUSION_MODEL_CORE_DIR: ${FUSION_MODEL_CORE_DIR:-/app/model_core}
ALLOW_REGISTRATION: ${ALLOW_REGISTRATION:-false}
PASSWORD_RESET_TOKEN_MINUTES: ${PASSWORD_RESET_TOKEN_MINUTES:-30}
RESEND_API_KEY: ${RESEND_API_KEY:-}
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-}
EMAIL_CODE_MINUTES: ${EMAIL_CODE_MINUTES:-10}
EMAIL_CODE_RESEND_SECONDS: ${EMAIL_CODE_RESEND_SECONDS:-60}
EMAIL_CODE_MAX_ATTEMPTS: ${EMAIL_CODE_MAX_ATTEMPTS:-5}
PASSWORD_RESET_TOKEN_MINUTES: ${PASSWORD_RESET_TOKEN_MINUTES:-10}
FRESH_AUTH_MINUTES: ${FRESH_AUTH_MINUTES:-5}
TRUSTED_DEVICE_DAYS: ${TRUSTED_DEVICE_DAYS:-30}
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-true}
@@ -32,4 +30,3 @@ services:
- ./data:/app/data
- ./data/uploads:/app/uploads
- ./data/images:/app/static/images
- ./example.xlsx:/app/example.xlsx:ro
+21
View File
@@ -0,0 +1,21 @@
{% extends "base.html" %}
{% block title %}接受注册邀请{% endblock %}
{% block content %}
<section class="mx-auto max-w-md rounded-xl border border-line bg-white p-5 shadow-panel sm:p-6">
<span class="material-symbols-outlined text-4xl text-primary" aria-hidden="true">mail</span>
<h1 class="mt-3 text-2xl font-extrabold">接受注册邀请</h1>
<p class="mt-2 text-sm leading-6 text-textSub">受邀邮箱:{{ email }}。请设置显示名和登录密码以激活账号。</p>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<p class="mt-4 rounded-lg p-3 text-sm {{ 'bg-dangerSoft text-dangerText' if category == 'error' else 'bg-blueSoft text-primaryDeep' }}">{{ message }}</p>
{% endfor %}
{% endwith %}
<form method="post" class="mt-5 space-y-4" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label class="block text-sm font-semibold">显示名<input name="username" required maxlength="100" autocomplete="username" class="mt-2 w-full rounded-lg border border-line px-3 py-2" placeholder="请输入显示名"></label>
<label class="block text-sm font-semibold">设置密码<input name="password" type="password" required minlength="12" autocomplete="new-password" class="mt-2 w-full rounded-lg border border-line px-3 py-2" placeholder="12 位以上,包含大小写、数字和特殊字符"></label>
<label class="block text-sm font-semibold">确认密码<input name="password_confirm" type="password" required minlength="12" autocomplete="new-password" class="mt-2 w-full rounded-lg border border-line px-3 py-2" placeholder="再次输入密码"></label>
<button class="ui-btn ui-btn-primary w-full">完成注册</button>
</form>
</section>
{% endblock %}
+11
View File
@@ -22,6 +22,17 @@
</form>
</section>
<section class="mt-6 rounded-xl border border-line bg-white p-5 shadow-panel">
<h2 class="font-extrabold">邀请注册</h2>
<p class="mt-1 text-sm text-textSub">即使关闭自助注册,也可向指定邮箱发送一次性注册链接。</p>
<form method="post" action="{{ url_for('main.admin_send_registration_invitation') }}" class="mt-4 flex flex-col gap-2 sm:flex-row" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label class="sr-only" for="inviteEmail">受邀邮箱</label>
<input id="inviteEmail" name="email" type="email" required autocomplete="email" class="min-w-0 flex-1 rounded-lg border border-line px-3 py-2 text-sm" placeholder="受邀人的邮箱地址">
<button class="ui-btn ui-btn-sm ui-btn-primary shrink-0">发送注册链接</button>
</form>
</section>
<section class="mt-6 rounded-xl border border-line bg-white p-5 shadow-panel">
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
+23 -1
View File
@@ -1 +1,23 @@
{% extends "base.html" %}{% block content %}<section class="mx-auto max-w-md rounded-xl border border-line bg-white p-5 shadow-panel sm:p-6"><h1 class="text-2xl font-extrabold">找回密码</h1><p class="mt-2 text-sm text-textSub">输入邮箱后,如账户存在会收到验证码。</p><form method="post" class="mt-5 space-y-4"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><input name="email" type="email" required class="w-full rounded-lg border border-line px-3 py-2" placeholder="邮箱"><button class="ui-btn ui-btn-primary w-full">发送验证码</button></form></section>{% endblock %}
{% extends "base.html" %}
{% block title %}找回密码{% endblock %}
{% block content %}
<section class="mx-auto max-w-md rounded-xl border border-line bg-white p-5 shadow-panel sm:p-6">
<h1 class="text-2xl font-extrabold">找回密码</h1>
<p class="mt-2 text-sm text-textSub">输入邮箱后,如账户存在会收到一次性重置链接。</p>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<p class="mt-4 rounded-lg p-3 text-sm {{ 'bg-dangerSoft text-dangerText' if category == 'error' else 'bg-blueSoft text-primaryDeep' }}">{{ message }}</p>
{% endfor %}
{% endwith %}
<form method="post" class="mt-5 space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label class="sr-only" for="resetEmail">登录邮箱</label>
<input id="resetEmail" name="email" type="email" required autocomplete="email" class="w-full rounded-lg border border-line px-3 py-2" placeholder="登录邮箱">
<button class="ui-btn ui-btn-primary w-full">发送重置链接</button>
</form>
</section>
{% endblock %}
+20
View File
@@ -0,0 +1,20 @@
{% extends "base.html" %}
{% block title %}重置密码{% endblock %}
{% block content %}
<section class="mx-auto max-w-md rounded-xl border border-line bg-white p-5 shadow-panel sm:p-6">
<span class="material-symbols-outlined text-4xl text-primary" aria-hidden="true">lock_reset</span>
<h1 class="mt-3 text-2xl font-extrabold">设置新密码</h1>
<p class="mt-2 text-sm leading-6 text-textSub">请设置新的登录密码。提交后,其他登录状态和受信设备将被撤销。</p>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<p class="mt-4 rounded-lg p-3 text-sm {{ 'bg-dangerSoft text-dangerText' if category == 'error' else 'bg-blueSoft text-primaryDeep' }}">{{ message }}</p>
{% endfor %}
{% endwith %}
<form method="post" class="mt-5 space-y-4" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label class="block text-sm font-semibold">新密码<input name="password" type="password" required minlength="12" autocomplete="new-password" class="mt-2 w-full rounded-lg border border-line px-3 py-2" placeholder="12 位以上,包含大小写、数字和特殊字符"></label>
<label class="block text-sm font-semibold">确认新密码<input name="password_confirm" type="password" required minlength="12" autocomplete="new-password" class="mt-2 w-full rounded-lg border border-line px-3 py-2" placeholder="再次输入新密码"></label>
<button class="ui-btn ui-btn-primary w-full">保存新密码</button>
</form>
</section>
{% endblock %}
+45 -10
View File
@@ -5,6 +5,7 @@ import unittest
from datetime import timedelta
from tempfile import TemporaryDirectory
from unittest.mock import patch
from urllib.parse import urlparse
from flask import session
@@ -12,7 +13,7 @@ from app import create_app
from app.config import Config
from app.email import EmailDeliveryError
from app.extensions import db
from app.models import EmailVerificationCode, TrustedDevice, User
from app.models import EmailVerificationCode, PasswordResetToken, RegistrationInvitation, TrustedDevice, User
from app.routes import grant_fresh_authorization, has_fresh_authorization, valid_password
from app.time_utils import utc_now
@@ -40,6 +41,8 @@ class EmailAuthenticationTest(unittest.TestCase):
self.assertEqual(html.count("data-code-digit\n"), 6)
self.assertIn("60 秒后可重新发送", html)
self.assertIn('id="resendButton"', html)
self.assertIn("al****ce@example.com", html)
self.assertNotIn("alice@example.com", html)
def test_fresh_authorization_is_bound_to_user_and_expires(self):
with TemporaryDirectory() as directory:
@@ -296,8 +299,7 @@ class EmailAuthenticationTest(unittest.TestCase):
with app.app_context(): self.assertEqual(TrustedDevice.query.count(), 1)
@patch("app.routes.send_transactional_email")
@patch("app.routes.secrets.randbelow", return_value=123456)
def test_password_reset_revokes_trusted_devices(self, _random, _send):
def test_password_reset_link_revokes_trusted_devices(self, send):
with TemporaryDirectory() as directory:
app = self.create_app(directory)
with app.app_context():
@@ -305,16 +307,49 @@ class EmailAuthenticationTest(unittest.TestCase):
client = app.test_client()
response = self.form(client, "/forgot-password", email="alice@example.com")
self.assertEqual(response.status_code, 302)
self.assertEqual(response.location, "/verify/reset")
verify = client.get("/verify/reset")
response = client.post("/verify/reset", data={"csrf_token": self.csrf(verify), "code": "123456"})
self.assertEqual(response.location, "/set-password")
page = client.get("/set-password")
response = client.post("/set-password", data={"csrf_token": self.csrf(page), "password": "New-password-1234!", "password_confirm": "New-password-1234!"})
self.assertEqual(response.location, "/forgot-password")
self.assertEqual(send.call_count, 1)
html = send.call_args.kwargs["html"]
reset_path = urlparse(re.search(r'href="([^"]+)"', html).group(1)).path
page = client.get(reset_path)
response = client.post(reset_path, data={"csrf_token": self.csrf(page), "password": "New-password-1234!", "password_confirm": "New-password-1234!"})
self.assertEqual(response.status_code, 302)
with app.app_context():
user = User.query.filter_by(email="alice@example.com").one()
self.assertTrue(user.check_password("New-password-1234!")); self.assertEqual(TrustedDevice.query.count(), 0); self.assertEqual(user.auth_version, 2)
self.assertTrue(user.check_password("New-password-1234!")); self.assertEqual(TrustedDevice.query.count(), 0); self.assertEqual(user.auth_version, 2); self.assertIsNotNone(PasswordResetToken.query.one().used_at)
@patch("app.routes.send_transactional_email")
def test_admin_can_invite_user_while_self_registration_is_disabled(self, send):
with TemporaryDirectory() as directory:
app = self.create_app(directory)
with app.app_context():
admin = User(username="Admin", email="admin@example.com", is_admin=True, is_active_account=True)
admin.set_password("Password-1234!")
db.session.add(admin)
db.session.commit()
admin_id = admin.id
client = app.test_client()
with app.app_context():
self.login_as(client, db.session.get(User, admin_id))
admin_page = client.get("/admin")
response = client.post(
"/admin/invitations",
data={"csrf_token": self.csrf(admin_page), "email": "invitee@example.com"},
)
self.assertEqual(response.status_code, 302)
self.assertEqual(send.call_count, 1)
with app.app_context():
self.assertEqual(RegistrationInvitation.query.count(), 1)
invite_html = send.call_args.kwargs["html"]
invite_path = urlparse(re.search(r'href="([^"]+)"', invite_html).group(1)).path
page = client.get(invite_path)
response = client.post(invite_path, data={"csrf_token": self.csrf(page), "username": "Invited User", "password": "Invited-password-1234!", "password_confirm": "Invited-password-1234!"})
self.assertEqual(response.status_code, 302)
with app.app_context():
invited = User.query.filter_by(email="invitee@example.com").one()
self.assertTrue(invited.is_active_account)
self.assertIsNotNone(invited.email_verified_at)
self.assertIsNotNone(RegistrationInvitation.query.one().used_at)
@patch("app.routes.send_transactional_email")
@patch("app.routes.secrets.randbelow", return_value=123456)
+12
View File
@@ -9,6 +9,7 @@ from app.config import Config
from app.email import (
EmailConfigurationError,
password_reset_notice_email,
registration_invitation_email,
send_transactional_email,
verification_code_email,
)
@@ -31,10 +32,21 @@ class TransactionalEmailTest(unittest.TestCase):
html = password_reset_notice_email(
username="<管理员>",
reset_url="https://example.com/reset?x=1&y=2",
minutes=10,
)
self.assertIn("&lt;管理员&gt;", html)
self.assertIn("x=1&amp;y=2", html)
def test_invitation_email_has_one_time_registration_copy(self):
html = registration_invitation_email(
invitation_url="https://example.com/invite/token",
minutes=10,
)
self.assertIn("接受邀请并注册", html)
self.assertIn("仅可使用一次", html)
self.assertIn("https://example.com/invite/token", html)
def create_test_app(self, temp_dir: str, *, configured: bool):
class TestConfig(Config):
TESTING = True