feat(auth): add password reset flow
Add admin-generated reset links, reset UI, timezone-aware expiry display, and registration captcha coverage.
This commit is contained in:
+153
-25
@@ -1,7 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
@@ -21,9 +23,10 @@ from sqlalchemy.orm import joinedload
|
||||
|
||||
from .config import BASE_DIR
|
||||
from .extensions import db
|
||||
from .models import AppSetting, UploadRecord, User
|
||||
from .models import AppSetting, PasswordResetToken, UploadRecord, User
|
||||
from .prediction import PredictionError, run_prediction
|
||||
from .security import new_captcha
|
||||
from .time_utils import format_datetime_for_timezone
|
||||
|
||||
bp = Blueprint("main", __name__)
|
||||
REFERENCE_PDF_NAME = "20260630标准文本——供水管道健康状态与剩余寿命评估技术导则.pdf"
|
||||
@@ -35,10 +38,55 @@ def render_auth_template(mode: str, status_code: int = 200, captcha: str = ""):
|
||||
return render_template("login.html", mode=mode, captcha=captcha), status_code
|
||||
|
||||
|
||||
def render_login_error(message: str, status_code: int = 400):
|
||||
flash(message, "error")
|
||||
def refresh_captcha() -> str:
|
||||
session["captcha"] = new_captcha()
|
||||
return render_auth_template("login", status_code, session["captcha"])
|
||||
return session["captcha"]
|
||||
|
||||
|
||||
def render_auth_error(mode: str, message: str, status_code: int = 400):
|
||||
flash(message, "error")
|
||||
return render_auth_template(mode, status_code, refresh_captcha())
|
||||
|
||||
|
||||
def captcha_is_valid() -> bool:
|
||||
captcha_input = request.form.get("captcha", "").strip().upper()
|
||||
return bool(captcha_input and captcha_input == session.get("captcha", ""))
|
||||
|
||||
|
||||
def password_reset_token_hash(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def password_reset_expiry() -> datetime:
|
||||
minutes = max(int(current_app.config["PASSWORD_RESET_TOKEN_MINUTES"]), 1)
|
||||
return datetime.utcnow() + timedelta(minutes=minutes)
|
||||
|
||||
|
||||
def format_app_datetime(value: datetime | None) -> str:
|
||||
return format_datetime_for_timezone(value, current_app.config["APP_TIMEZONE"])
|
||||
|
||||
|
||||
def active_password_reset_token(token: str) -> PasswordResetToken | None:
|
||||
reset_token = PasswordResetToken.query.filter_by(
|
||||
token_hash=password_reset_token_hash(token)
|
||||
).first()
|
||||
if (
|
||||
reset_token is None
|
||||
or reset_token.used_at is not None
|
||||
or reset_token.expires_at <= datetime.utcnow()
|
||||
):
|
||||
return None
|
||||
return reset_token
|
||||
|
||||
|
||||
def render_password_reset_unavailable(status_code: int = 400):
|
||||
flash("重置链接无效或已过期,请联系管理员重新生成。", "error")
|
||||
return render_template(
|
||||
"password_reset.html",
|
||||
reset_token=None,
|
||||
token="",
|
||||
token_available=False,
|
||||
), status_code
|
||||
|
||||
|
||||
def require_admin() -> None:
|
||||
@@ -71,6 +119,14 @@ def paginated_uploads(query, endpoint: str):
|
||||
return pagination, None
|
||||
|
||||
|
||||
def password_reset_users():
|
||||
return (
|
||||
User.query.filter(User.is_admin.is_(False))
|
||||
.order_by(User.username.asc(), User.id.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def prediction_result_payload(artifacts, record: UploadRecord) -> dict:
|
||||
image_url = url_for("static", filename=f"images/{artifacts.image_filename}")
|
||||
importance_url = (
|
||||
@@ -104,19 +160,16 @@ def login():
|
||||
return redirect(url_for("main.home"))
|
||||
|
||||
if request.method == "GET":
|
||||
session["captcha"] = new_captcha()
|
||||
return render_auth_template("login", captcha=session["captcha"])
|
||||
return render_auth_template("login", captcha=refresh_captcha())
|
||||
|
||||
username = request.form.get("username", "").strip()
|
||||
password = request.form.get("password", "")
|
||||
captcha_input = request.form.get("captcha", "").strip().upper()
|
||||
|
||||
if captcha_input != session.get("captcha", ""):
|
||||
return render_login_error("验证码错误")
|
||||
if not captcha_is_valid():
|
||||
return render_auth_error("login", "验证码错误")
|
||||
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if not user or not user.check_password(password):
|
||||
return render_login_error("用户名或密码错误")
|
||||
return render_auth_error("login", "用户名或密码错误")
|
||||
|
||||
login_user(user, remember=bool(request.form.get("remember")))
|
||||
return redirect(url_for("main.home"))
|
||||
@@ -125,32 +178,69 @@ def login():
|
||||
@bp.route("/register", methods=["GET", "POST"])
|
||||
def register():
|
||||
if request.method == "GET":
|
||||
return render_auth_template("register")
|
||||
|
||||
if not registration_allowed():
|
||||
flash("当前未开放自助注册,请联系管理员。", "error")
|
||||
return render_auth_template("register", 403)
|
||||
return render_auth_template("register", captcha=refresh_captcha())
|
||||
|
||||
username = request.form.get("username", "").strip()
|
||||
password = request.form.get("password", "")
|
||||
|
||||
if not captcha_is_valid():
|
||||
return render_auth_error("register", "验证码错误")
|
||||
if not registration_allowed():
|
||||
return render_auth_error("register", "当前未开放自助注册,请联系管理员。", 403)
|
||||
|
||||
if not username:
|
||||
flash("用户名不能为空", "error")
|
||||
return render_auth_template("register", 400)
|
||||
return render_auth_error("register", "用户名不能为空")
|
||||
if len(password) < 6:
|
||||
flash("密码至少需要 6 位", "error")
|
||||
return render_auth_template("register", 400)
|
||||
return render_auth_error("register", "密码至少需要 6 位")
|
||||
if User.query.filter_by(username=username).first():
|
||||
flash("用户名已存在", "error")
|
||||
return render_auth_template("register", 400)
|
||||
return render_auth_error("register", "用户名已存在")
|
||||
|
||||
user = User(username=username, is_admin=False)
|
||||
user.set_password(password)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
flash("注册成功,请登录", "info")
|
||||
session["captcha"] = new_captcha()
|
||||
return render_auth_template("login", captcha=session["captcha"])
|
||||
return render_auth_template("login", captcha=refresh_captcha())
|
||||
|
||||
|
||||
@bp.route("/password-reset/<token>", methods=["GET", "POST"])
|
||||
def password_reset(token: str):
|
||||
reset_token = active_password_reset_token(token)
|
||||
if reset_token is None:
|
||||
return render_password_reset_unavailable()
|
||||
|
||||
if request.method == "GET":
|
||||
return render_template(
|
||||
"password_reset.html",
|
||||
reset_token=reset_token,
|
||||
token=token,
|
||||
token_available=True,
|
||||
)
|
||||
|
||||
password = request.form.get("password", "")
|
||||
password_confirm = request.form.get("password_confirm", "")
|
||||
if len(password) < 6:
|
||||
flash("密码至少需要 6 位", "error")
|
||||
return render_template(
|
||||
"password_reset.html",
|
||||
reset_token=reset_token,
|
||||
token=token,
|
||||
token_available=True,
|
||||
), 400
|
||||
if password != password_confirm:
|
||||
flash("两次输入的密码不一致", "error")
|
||||
return render_template(
|
||||
"password_reset.html",
|
||||
reset_token=reset_token,
|
||||
token=token,
|
||||
token_available=True,
|
||||
), 400
|
||||
|
||||
reset_token.user.set_password(password)
|
||||
reset_token.used_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
flash("密码已重置,请使用新密码登录", "info")
|
||||
return render_auth_template("login", captcha=refresh_captcha())
|
||||
|
||||
|
||||
@bp.route("/logout", methods=["POST"])
|
||||
@@ -196,6 +286,7 @@ def admin_dashboard():
|
||||
"admin.html",
|
||||
pagination=pagination,
|
||||
records=pagination.items,
|
||||
password_reset_users=password_reset_users(),
|
||||
registration_allowed=registration_allowed(),
|
||||
)
|
||||
|
||||
@@ -223,6 +314,43 @@ def update_registration_setting():
|
||||
return redirect(url_for("main.admin_dashboard"))
|
||||
|
||||
|
||||
@bp.route("/admin/users/<int:user_id>/password-reset-link", methods=["POST"])
|
||||
@login_required
|
||||
def create_password_reset_link(user_id: int):
|
||||
require_admin()
|
||||
|
||||
user = db.session.get(User, user_id)
|
||||
if user is None:
|
||||
return jsonify({"error": "用户不存在"}), 404
|
||||
if user.is_admin:
|
||||
return jsonify({"error": "管理员账号不支持通过此入口重置密码"}), 403
|
||||
|
||||
now = datetime.utcnow()
|
||||
PasswordResetToken.query.filter_by(user_id=user.id, used_at=None).update(
|
||||
{"used_at": now}
|
||||
)
|
||||
|
||||
token = secrets.token_urlsafe(32)
|
||||
reset_token = PasswordResetToken(
|
||||
user_id=user.id,
|
||||
created_by_id=current_user.id,
|
||||
token_hash=password_reset_token_hash(token),
|
||||
expires_at=password_reset_expiry(),
|
||||
)
|
||||
db.session.add(reset_token)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"message": f"已生成 {user.username} 的密码重置链接",
|
||||
"reset_url": url_for("main.password_reset", token=token, _external=True),
|
||||
"expires_at": format_app_datetime(reset_token.expires_at),
|
||||
"user_id": user.id,
|
||||
"username": user.username,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/download/<int:record_id>/<file_type>")
|
||||
@login_required
|
||||
def download_file(record_id: int, file_type: str):
|
||||
|
||||
Reference in New Issue
Block a user