feat: add secure invitation and reset links
This commit is contained in:
@@ -39,6 +39,8 @@ RESEND_FROM_EMAIL=no-reply@waternetwork.cn
|
|||||||
EMAIL_CODE_MINUTES=10
|
EMAIL_CODE_MINUTES=10
|
||||||
EMAIL_CODE_RESEND_SECONDS=60
|
EMAIL_CODE_RESEND_SECONDS=60
|
||||||
EMAIL_CODE_MAX_ATTEMPTS=5
|
EMAIL_CODE_MAX_ATTEMPTS=5
|
||||||
|
# PASSWORD_RESET_TOKEN_MINUTES:密码重置和管理员注册链接的有效期(分钟)。
|
||||||
|
PASSWORD_RESET_TOKEN_MINUTES=10
|
||||||
# FRESH_AUTH_MINUTES:改密、重置密码等敏感操作的短时授权有效期(分钟)。
|
# FRESH_AUTH_MINUTES:改密、重置密码等敏感操作的短时授权有效期(分钟)。
|
||||||
FRESH_AUTH_MINUTES=5
|
FRESH_AUTH_MINUTES=5
|
||||||
# TRUSTED_DEVICE_DAYS:受信设备有效期(天)。
|
# TRUSTED_DEVICE_DAYS:受信设备有效期(天)。
|
||||||
|
|||||||
+1
-1
@@ -37,4 +37,4 @@ RUN mkdir -p data static/images uploads
|
|||||||
|
|
||||||
EXPOSE 5005
|
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
@@ -82,7 +82,8 @@ class Config:
|
|||||||
ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD")
|
ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD")
|
||||||
ADMIN_EMAIL = os.environ.get("ADMIN_EMAIL", "").strip().lower()
|
ADMIN_EMAIL = os.environ.get("ADMIN_EMAIL", "").strip().lower()
|
||||||
APP_TIMEZONE = os.environ.get("APP_TIMEZONE", "Asia/Shanghai").strip() or "Asia/Shanghai"
|
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_API_KEY = os.environ.get("RESEND_API_KEY", "").strip()
|
||||||
RESEND_FROM_EMAIL = os.environ.get("RESEND_FROM_EMAIL", "").strip()
|
RESEND_FROM_EMAIL = os.environ.get("RESEND_FROM_EMAIL", "").strip()
|
||||||
EMAIL_CODE_MINUTES = env_int("EMAIL_CODE_MINUTES", 10)
|
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)
|
return render_transactional_email(title="邮箱验证码", content=content)
|
||||||
|
|
||||||
|
|
||||||
def password_reset_notice_email(*, username: str, reset_url: str) -> str:
|
def password_reset_notice_email(*, username: str, reset_url: str, minutes: int) -> str:
|
||||||
"""Render an administrator-initiated password reset notification."""
|
"""Render a one-time password reset link email."""
|
||||||
safe_url = escape(reset_url, quote=True)
|
safe_url = escape(reset_url, quote=True)
|
||||||
content = f"""
|
content = f"""
|
||||||
<p style="margin:0;color:#475569;font-size:15px;line-height:1.8;">{escape(username)},管理员已要求你重置密码。</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;">请通过下方按钮进入找回密码流程,系统会向本邮箱发送一次性验证码。</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>"""
|
<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]:
|
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:
|
def upgrade_schema() -> None:
|
||||||
"""Create new tables and add authentication columns without losing existing data."""
|
"""Create new tables and add authentication columns without losing existing data."""
|
||||||
db.create_all()
|
with db.engine.begin() as connection:
|
||||||
inspector = inspect(db.engine)
|
# Gunicorn workers can import the application simultaneously. Acquiring
|
||||||
columns = {item["name"] for item in inspector.get_columns("users")}
|
# SQLite's write lock before inspecting or creating tables serializes the
|
||||||
additions = {
|
# first-start migration and prevents duplicate CREATE TABLE attempts.
|
||||||
"email": "VARCHAR(254)",
|
if db.engine.dialect.name == "sqlite":
|
||||||
"email_verified_at": "DATETIME",
|
connection.exec_driver_sql("BEGIN IMMEDIATE")
|
||||||
"is_active_account": "BOOLEAN NOT NULL DEFAULT 0",
|
db.metadata.create_all(bind=connection)
|
||||||
"auth_version": "INTEGER NOT NULL DEFAULT 1",
|
inspector = inspect(connection)
|
||||||
}
|
columns = {item["name"] for item in inspector.get_columns("users")}
|
||||||
for name, sql_type in additions.items():
|
additions = {
|
||||||
if name not in columns:
|
"email": "VARCHAR(254)",
|
||||||
db.session.execute(text(f"ALTER TABLE users ADD COLUMN {name} {sql_type}"))
|
"email_verified_at": "DATETIME",
|
||||||
db.session.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS ix_users_email_unique ON users (email) WHERE email IS NOT NULL"))
|
"is_active_account": "BOOLEAN NOT NULL DEFAULT 0",
|
||||||
db.session.commit()
|
"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])
|
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):
|
class EmailVerificationCode(db.Model):
|
||||||
__tablename__ = "email_verification_codes"
|
__tablename__ = "email_verification_codes"
|
||||||
|
|
||||||
|
|||||||
+228
-35
@@ -16,11 +16,20 @@ from .email import (
|
|||||||
EmailConfigurationError,
|
EmailConfigurationError,
|
||||||
EmailDeliveryError,
|
EmailDeliveryError,
|
||||||
password_reset_notice_email,
|
password_reset_notice_email,
|
||||||
|
registration_invitation_email,
|
||||||
send_transactional_email,
|
send_transactional_email,
|
||||||
verification_code_email,
|
verification_code_email,
|
||||||
)
|
)
|
||||||
from .extensions import db
|
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 .prediction import PredictionError, run_prediction
|
||||||
from .security import new_captcha
|
from .security import new_captcha
|
||||||
from .time_utils import format_datetime_for_timezone, utc_now
|
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 = {
|
EMAIL_CODE_PURPOSES = {
|
||||||
"register",
|
"register",
|
||||||
"login",
|
"login",
|
||||||
"reset",
|
|
||||||
"change_password",
|
"change_password",
|
||||||
"change_email_old",
|
"change_email_old",
|
||||||
"change_email_new",
|
"change_email_new",
|
||||||
@@ -43,7 +51,6 @@ EMAIL_CODE_PURPOSES = {
|
|||||||
EMAIL_CODE_LABELS = {
|
EMAIL_CODE_LABELS = {
|
||||||
"register": "完成注册",
|
"register": "完成注册",
|
||||||
"login": "登录确认",
|
"login": "登录确认",
|
||||||
"reset": "重置密码",
|
|
||||||
"change_password": "修改密码",
|
"change_password": "修改密码",
|
||||||
"change_email_old": "确认原邮箱",
|
"change_email_old": "确认原邮箱",
|
||||||
"change_email_new": "确认新邮箱",
|
"change_email_new": "确认新邮箱",
|
||||||
@@ -59,6 +66,98 @@ def valid_email(value: str) -> bool:
|
|||||||
return len(value) <= 254 and bool(EMAIL_RE.fullmatch(value))
|
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:
|
def valid_password(password: str) -> bool:
|
||||||
return (
|
return (
|
||||||
12 <= len(password) <= 128
|
12 <= len(password) <= 128
|
||||||
@@ -73,6 +172,14 @@ def valid_password(password: str) -> bool:
|
|||||||
PASSWORD_RULE_MESSAGE = "密码须为12至128位,包含大写字母、小写字母、数字和特殊字符,且不能含空格。"
|
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:
|
def grant_fresh_authorization(user: User, purpose: str) -> None:
|
||||||
"""Grant a short, session-bound authorization after successful email MFA."""
|
"""Grant a short, session-bound authorization after successful email MFA."""
|
||||||
session[f"fresh_auth_{purpose}"] = {
|
session[f"fresh_auth_{purpose}"] = {
|
||||||
@@ -332,7 +439,7 @@ def verification_page(title: str, purpose: str):
|
|||||||
"verification.html",
|
"verification.html",
|
||||||
title=title,
|
title=title,
|
||||||
purpose=purpose,
|
purpose=purpose,
|
||||||
email=session.get("pending_email", ""),
|
email=masked_email(session.get("pending_email", "")),
|
||||||
resend_seconds=current_app.config["EMAIL_CODE_RESEND_SECONDS"],
|
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"))
|
trust_device = bool(request.form.get("trust_device"))
|
||||||
session.clear()
|
session.clear()
|
||||||
return login_response(user, remember, trust_device=trust_device)
|
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":
|
if purpose == "change_email_old":
|
||||||
new_email = session.get("new_email", "")
|
new_email = session.get("new_email", "")
|
||||||
session.update(pending_email=new_email, pending_purpose="change_email_new")
|
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 purpose == "change_email_new":
|
||||||
if User.query.filter(User.email == email, User.id != user.id).first():
|
if User.query.filter(User.email == email, User.id != user.id).first():
|
||||||
flash("该邮箱已被使用。", "error"); return redirect(url_for("main.account_security"))
|
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")
|
grant_fresh_authorization(user, "password_change")
|
||||||
return redirect(url_for("main.account_security"))
|
return redirect(url_for("main.account_security"))
|
||||||
|
|
||||||
@@ -451,29 +555,89 @@ def forgot_password():
|
|||||||
if request.method == "GET": return render_template("forgot_password.html")
|
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()
|
email = normal_email(request.form.get("email", "")); user = User.query.filter_by(email=email, is_active_account=True).first()
|
||||||
if user:
|
if user:
|
||||||
session.update(pending_email=email, pending_user_id=user.id, pending_purpose="reset")
|
token = issue_password_reset_token(user, user)
|
||||||
reason = issue_email_code(email, "reset")
|
try:
|
||||||
if reason in {None, "resend_wait"}:
|
send_transactional_email(
|
||||||
if reason == "resend_wait":
|
to=user.email,
|
||||||
flash("邮箱验证码已发送,请直接输入验证码继续。", "info")
|
subject="【管道健康】重置密码",
|
||||||
return redirect(url_for("main.verify_email", purpose="reset"))
|
html=password_reset_notice_email(
|
||||||
flash(email_code_issue_message(reason), "error")
|
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"))
|
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"))
|
return redirect(url_for("main.login"))
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/set-password", methods=["GET", "POST"])
|
@bp.route("/invite/<token>", methods=["GET", "POST"])
|
||||||
def set_password():
|
def accept_registration_invitation(token: str):
|
||||||
authorization = session.get("fresh_auth_password_reset")
|
invitation = active_registration_invitation(token)
|
||||||
user = db.session.get(User, authorization.get("user_id")) if isinstance(authorization, dict) else None
|
if invitation is None:
|
||||||
if not user or not has_fresh_authorization(user, "password_reset"):
|
flash("该注册链接无效、已过期或已使用。请联系管理员重新邀请。", "error")
|
||||||
consume_fresh_authorization("password_reset")
|
return redirect(url_for("main.login"))
|
||||||
return redirect(url_for("main.forgot_password"))
|
if User.query.filter_by(email=invitation.email).first():
|
||||||
if request.method == "GET": return render_template("set_password.html", title="设置新密码", action=url_for("main.set_password"))
|
flash("该邮箱已注册,请直接登录或联系管理员。", "error")
|
||||||
password, confirm = request.form.get("password", ""), request.form.get("password_confirm", "")
|
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
|
if request.method == "GET":
|
||||||
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"))
|
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"])
|
@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", "")):
|
if not current_user.check_password(request.form.get("current_password", "")):
|
||||||
flash("当前密码不正确,未撤销受信设备。", "error")
|
flash("当前密码不正确,未撤销受信设备。", "error")
|
||||||
return redirect(url_for("main.account_security"))
|
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", "")):
|
if not current_user.check_password(request.form.get("current_password", "")):
|
||||||
flash("当前密码不正确。", "error"); return redirect(url_for("main.account_security"))
|
flash("当前密码不正确。", "error"); return redirect(url_for("main.account_security"))
|
||||||
if action == "change_email":
|
if action == "change_email":
|
||||||
@@ -517,7 +681,7 @@ def change_password():
|
|||||||
abort(403)
|
abort(403)
|
||||||
password, confirm = request.form.get("password", ""), request.form.get("password_confirm", "")
|
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"))
|
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"])
|
@bp.route("/logout", methods=["POST"])
|
||||||
@@ -569,6 +733,35 @@ def admin_dashboard():
|
|||||||
def update_registration_setting():
|
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"))
|
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"])
|
@bp.route("/admin/users/<int:user_id>/password-reset", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def admin_password_reset(user_id: int):
|
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)
|
user = db.session.get(User, user_id)
|
||||||
if not user or user.is_admin:
|
if not user or user.is_admin:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
token = issue_password_reset_token(user, current_user)
|
||||||
try:
|
try:
|
||||||
send_transactional_email(
|
send_transactional_email(
|
||||||
to=user.email,
|
to=user.email,
|
||||||
subject="【管道健康】密码重置通知",
|
subject="【管道健康】重置密码",
|
||||||
html=password_reset_notice_email(
|
html=password_reset_notice_email(
|
||||||
username=user.username,
|
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):
|
except (EmailConfigurationError, EmailDeliveryError):
|
||||||
@@ -599,8 +794,7 @@ def admin_revoke_trusted_devices(user_id: int):
|
|||||||
user = db.session.get(User, user_id)
|
user = db.session.get(User, user_id)
|
||||||
if user is None or user.is_admin:
|
if user is None or user.is_admin:
|
||||||
abort(404)
|
abort(404)
|
||||||
user.revoke_authentication()
|
revoke_user_authentication(user)
|
||||||
TrustedDevice.query.filter_by(user_id=user.id).delete()
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(f"已撤销 {user.username} 的所有受信设备。", "info")
|
flash(f"已撤销 {user.username} 的所有受信设备。", "info")
|
||||||
return admin_dashboard_redirect()
|
return admin_dashboard_redirect()
|
||||||
@@ -614,8 +808,7 @@ def admin_update_account_status(user_id: int):
|
|||||||
if user is None or user.is_admin:
|
if user is None or user.is_admin:
|
||||||
abort(404)
|
abort(404)
|
||||||
user.is_active_account = request.form.get("is_active") == "true"
|
user.is_active_account = request.form.get("is_active") == "true"
|
||||||
user.revoke_authentication()
|
revoke_user_authentication(user)
|
||||||
TrustedDevice.query.filter_by(user_id=user.id).delete()
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
message = "已启用账号" if user.is_active_account else "已停用账号并撤销所有会话"
|
message = "已启用账号" if user.is_active_account else "已停用账号并撤销所有会话"
|
||||||
flash(f"{user.username}:{message}。", "info")
|
flash(f"{user.username}:{message}。", "info")
|
||||||
|
|||||||
+5
-8
@@ -1,8 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
pipeline-lifetime:
|
pipeline-lifetime:
|
||||||
build:
|
image: pipeline-lifetime:20260805.1
|
||||||
context: .
|
|
||||||
image: pipeline-lifetime:latest
|
|
||||||
container_name: pipeline-lifetime
|
container_name: pipeline-lifetime
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
@@ -10,19 +8,19 @@ services:
|
|||||||
DEBUG: ${DEBUG:-false}
|
DEBUG: ${DEBUG:-false}
|
||||||
SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env}
|
SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env}
|
||||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?Set ADMIN_PASSWORD in .env}
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
|
||||||
ADMIN_EMAIL: ${ADMIN_EMAIL:?Set ADMIN_EMAIL in .env}
|
ADMIN_EMAIL: ${ADMIN_EMAIL:-}
|
||||||
DATABASE_URL: ${DATABASE_URL:-sqlite:////app/data/pipe_survival_0331.db}
|
DATABASE_URL: ${DATABASE_URL:-sqlite:////app/data/pipe_survival.db}
|
||||||
APP_TIMEZONE: ${APP_TIMEZONE:-Asia/Shanghai}
|
APP_TIMEZONE: ${APP_TIMEZONE:-Asia/Shanghai}
|
||||||
MAX_UPLOAD_BYTES: ${MAX_UPLOAD_BYTES:-16777216}
|
MAX_UPLOAD_BYTES: ${MAX_UPLOAD_BYTES:-16777216}
|
||||||
FUSION_MODEL_CORE_DIR: ${FUSION_MODEL_CORE_DIR:-/app/model_core}
|
FUSION_MODEL_CORE_DIR: ${FUSION_MODEL_CORE_DIR:-/app/model_core}
|
||||||
ALLOW_REGISTRATION: ${ALLOW_REGISTRATION:-false}
|
ALLOW_REGISTRATION: ${ALLOW_REGISTRATION:-false}
|
||||||
PASSWORD_RESET_TOKEN_MINUTES: ${PASSWORD_RESET_TOKEN_MINUTES:-30}
|
|
||||||
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
||||||
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-}
|
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-}
|
||||||
EMAIL_CODE_MINUTES: ${EMAIL_CODE_MINUTES:-10}
|
EMAIL_CODE_MINUTES: ${EMAIL_CODE_MINUTES:-10}
|
||||||
EMAIL_CODE_RESEND_SECONDS: ${EMAIL_CODE_RESEND_SECONDS:-60}
|
EMAIL_CODE_RESEND_SECONDS: ${EMAIL_CODE_RESEND_SECONDS:-60}
|
||||||
EMAIL_CODE_MAX_ATTEMPTS: ${EMAIL_CODE_MAX_ATTEMPTS:-5}
|
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}
|
FRESH_AUTH_MINUTES: ${FRESH_AUTH_MINUTES:-5}
|
||||||
TRUSTED_DEVICE_DAYS: ${TRUSTED_DEVICE_DAYS:-30}
|
TRUSTED_DEVICE_DAYS: ${TRUSTED_DEVICE_DAYS:-30}
|
||||||
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-true}
|
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-true}
|
||||||
@@ -32,4 +30,3 @@ services:
|
|||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
- ./data/uploads:/app/uploads
|
- ./data/uploads:/app/uploads
|
||||||
- ./data/images:/app/static/images
|
- ./data/images:/app/static/images
|
||||||
- ./example.xlsx:/app/example.xlsx:ro
|
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -22,6 +22,17 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</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">
|
<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 class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -5,6 +5,7 @@ import unittest
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from tempfile import TemporaryDirectory
|
from tempfile import TemporaryDirectory
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from flask import session
|
from flask import session
|
||||||
|
|
||||||
@@ -12,7 +13,7 @@ from app import create_app
|
|||||||
from app.config import Config
|
from app.config import Config
|
||||||
from app.email import EmailDeliveryError
|
from app.email import EmailDeliveryError
|
||||||
from app.extensions import db
|
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.routes import grant_fresh_authorization, has_fresh_authorization, valid_password
|
||||||
from app.time_utils import utc_now
|
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.assertEqual(html.count("data-code-digit\n"), 6)
|
||||||
self.assertIn("60 秒后可重新发送", html)
|
self.assertIn("60 秒后可重新发送", html)
|
||||||
self.assertIn('id="resendButton"', 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):
|
def test_fresh_authorization_is_bound_to_user_and_expires(self):
|
||||||
with TemporaryDirectory() as directory:
|
with TemporaryDirectory() as directory:
|
||||||
@@ -296,8 +299,7 @@ class EmailAuthenticationTest(unittest.TestCase):
|
|||||||
with app.app_context(): self.assertEqual(TrustedDevice.query.count(), 1)
|
with app.app_context(): self.assertEqual(TrustedDevice.query.count(), 1)
|
||||||
|
|
||||||
@patch("app.routes.send_transactional_email")
|
@patch("app.routes.send_transactional_email")
|
||||||
@patch("app.routes.secrets.randbelow", return_value=123456)
|
def test_password_reset_link_revokes_trusted_devices(self, send):
|
||||||
def test_password_reset_revokes_trusted_devices(self, _random, _send):
|
|
||||||
with TemporaryDirectory() as directory:
|
with TemporaryDirectory() as directory:
|
||||||
app = self.create_app(directory)
|
app = self.create_app(directory)
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
@@ -305,16 +307,49 @@ class EmailAuthenticationTest(unittest.TestCase):
|
|||||||
client = app.test_client()
|
client = app.test_client()
|
||||||
response = self.form(client, "/forgot-password", email="alice@example.com")
|
response = self.form(client, "/forgot-password", email="alice@example.com")
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
self.assertEqual(response.location, "/verify/reset")
|
self.assertEqual(response.location, "/forgot-password")
|
||||||
verify = client.get("/verify/reset")
|
self.assertEqual(send.call_count, 1)
|
||||||
response = client.post("/verify/reset", data={"csrf_token": self.csrf(verify), "code": "123456"})
|
html = send.call_args.kwargs["html"]
|
||||||
self.assertEqual(response.location, "/set-password")
|
reset_path = urlparse(re.search(r'href="([^"]+)"', html).group(1)).path
|
||||||
page = client.get("/set-password")
|
page = client.get(reset_path)
|
||||||
response = client.post("/set-password", data={"csrf_token": self.csrf(page), "password": "New-password-1234!", "password_confirm": "New-password-1234!"})
|
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)
|
self.assertEqual(response.status_code, 302)
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
user = User.query.filter_by(email="alice@example.com").one()
|
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.send_transactional_email")
|
||||||
@patch("app.routes.secrets.randbelow", return_value=123456)
|
@patch("app.routes.secrets.randbelow", return_value=123456)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from app.config import Config
|
|||||||
from app.email import (
|
from app.email import (
|
||||||
EmailConfigurationError,
|
EmailConfigurationError,
|
||||||
password_reset_notice_email,
|
password_reset_notice_email,
|
||||||
|
registration_invitation_email,
|
||||||
send_transactional_email,
|
send_transactional_email,
|
||||||
verification_code_email,
|
verification_code_email,
|
||||||
)
|
)
|
||||||
@@ -31,10 +32,21 @@ class TransactionalEmailTest(unittest.TestCase):
|
|||||||
html = password_reset_notice_email(
|
html = password_reset_notice_email(
|
||||||
username="<管理员>",
|
username="<管理员>",
|
||||||
reset_url="https://example.com/reset?x=1&y=2",
|
reset_url="https://example.com/reset?x=1&y=2",
|
||||||
|
minutes=10,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertIn("<管理员>", html)
|
self.assertIn("<管理员>", html)
|
||||||
self.assertIn("x=1&y=2", html)
|
self.assertIn("x=1&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):
|
def create_test_app(self, temp_dir: str, *, configured: bool):
|
||||||
class TestConfig(Config):
|
class TestConfig(Config):
|
||||||
TESTING = True
|
TESTING = True
|
||||||
|
|||||||
Reference in New Issue
Block a user