diff --git a/.env.example b/.env.example index e7d86cc..9f74c9a 100644 --- a/.env.example +++ b/.env.example @@ -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:受信设备有效期(天)。 diff --git a/Dockerfile b/Dockerfile index 626da5c..e43c00f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/app/config.py b/app/config.py index 2d3deb5..7823ace 100644 --- a/app/config.py +++ b/app/config.py @@ -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) diff --git a/app/email.py b/app/email.py index a386903..38dbaac 100644 --- a/app/email.py +++ b/app/email.py @@ -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""" -

{escape(username)},管理员已要求你重置密码。

-

请通过下方按钮进入找回密码流程,系统会向本邮箱发送一次性验证码。

+

{escape(username)},请通过下方按钮设置新密码。

+

该链接仅可使用一次,并将在 {minutes} 分钟后失效。

重置密码

""" - 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""" +

管理员邀请你加入供水管道健康评估系统。

+

请通过下方按钮设置显示名和密码。该链接仅可使用一次,并将在 {minutes} 分钟后失效。

+

接受邀请并注册

""" + return render_transactional_email(title="管理员邀请你注册", content=content) def send_transactional_email(*, to: str, subject: str, html: str) -> dict[str, Any]: diff --git a/app/migrations.py b/app/migrations.py index 3102fc1..dd51fa4 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -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")) diff --git a/app/models.py b/app/models.py index 4bf40e5..ac22946 100644 --- a/app/models.py +++ b/app/models.py @@ -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" diff --git a/app/routes.py b/app/routes.py index 10d2fcd..32a5352 100644 --- a/app/routes.py +++ b/app/routes.py @@ -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/", 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/", 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//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") diff --git a/docker-compose.yml b/docker-compose.yml index 9f0a486..804f859 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/templates/accept_invitation.html b/templates/accept_invitation.html new file mode 100644 index 0000000..b140c7b --- /dev/null +++ b/templates/accept_invitation.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} +{% block title %}接受注册邀请{% endblock %} +{% block content %} +
+ +

接受注册邀请

+

受邀邮箱:{{ email }}。请设置显示名和登录密码以激活账号。

+ {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} +

{{ message }}

+ {% endfor %} + {% endwith %} +
+ + + + + +
+
+{% endblock %} diff --git a/templates/admin.html b/templates/admin.html index 5579d32..500e674 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -22,6 +22,17 @@ +
+

邀请注册

+

即使关闭自助注册,也可向指定邮箱发送一次性注册链接。

+
+ + + + +
+
+
diff --git a/templates/forgot_password.html b/templates/forgot_password.html index 5188fbe..7265b15 100644 --- a/templates/forgot_password.html +++ b/templates/forgot_password.html @@ -1 +1,23 @@ -{% extends "base.html" %}{% block content %}

找回密码

输入邮箱后,如账户存在会收到验证码。

{% endblock %} +{% extends "base.html" %} + +{% block title %}找回密码{% endblock %} + +{% block content %} +
+

找回密码

+

输入邮箱后,如账户存在会收到一次性重置链接。

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} +

{{ message }}

+ {% endfor %} + {% endwith %} + +
+ + + + +
+
+{% endblock %} diff --git a/templates/reset_password.html b/templates/reset_password.html new file mode 100644 index 0000000..190b1ed --- /dev/null +++ b/templates/reset_password.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} +{% block title %}重置密码{% endblock %} +{% block content %} +
+ +

设置新密码

+

请设置新的登录密码。提交后,其他登录状态和受信设备将被撤销。

+ {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} +

{{ message }}

+ {% endfor %} + {% endwith %} +
+ + + + +
+
+{% endblock %} diff --git a/tests/test_auth_registration.py b/tests/test_auth_registration.py index d0698b8..3ea8dc8 100644 --- a/tests/test_auth_registration.py +++ b/tests/test_auth_registration.py @@ -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) diff --git a/tests/test_email.py b/tests/test_email.py index 5cc5f20..d8b25b1 100644 --- a/tests/test_email.py +++ b/tests/test_email.py @@ -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("<管理员>", 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): class TestConfig(Config): TESTING = True