Harden auth, CSRF, and email log UX

This commit is contained in:
2025-12-26 19:05:20 +08:00
parent 3214cbbd91
commit f90b0a4f11
47 changed files with 583 additions and 198 deletions

View File

@@ -9,15 +9,18 @@ import time
import database
import email_service
from app_logger import get_logger
from app_security import get_client_ip, require_ip_not_locked, validate_password
from app_security import get_rate_limit_ip, require_ip_not_locked, validate_email, validate_password, validate_username
from flask import Blueprint, jsonify, redirect, render_template, request, url_for
from flask_login import login_required, login_user, logout_user
from routes.pages import render_app_spa_or_legacy
from services.accounts_service import load_user_accounts
from services.models import User
from services.state import (
check_ip_rate_limit,
check_ip_request_rate,
check_login_captcha_required,
clear_login_failures,
record_failed_captcha,
record_login_failure,
safe_cleanup_expired_captcha,
safe_delete_captcha,
safe_set_captcha,
@@ -33,23 +36,26 @@ api_auth_bp = Blueprint("api_auth", __name__)
@require_ip_not_locked
def register():
"""用户注册"""
data = request.json
data = request.json or {}
username = data.get("username", "").strip()
password = data.get("password", "").strip()
email = data.get("email", "").strip()
email = data.get("email", "").strip().lower()
captcha_session = data.get("captcha_session", "")
captcha_code = data.get("captcha", "").strip()
if not username or not password:
return jsonify({"error": "用户名和密码不能为空"}), 400
is_valid, error_msg = validate_username(username)
if not is_valid:
return jsonify({"error": error_msg}), 400
is_valid, error_msg = validate_password(password)
if not is_valid:
return jsonify({"error": error_msg}), 400
client_ip = get_client_ip()
allowed, error_msg = check_ip_rate_limit(client_ip)
client_ip = get_rate_limit_ip()
allowed, error_msg = check_ip_request_rate(client_ip, "register")
if not allowed:
return jsonify({"error": error_msg}), 429
@@ -66,8 +72,10 @@ def register():
if email_verify_enabled and not email:
return jsonify({"error": "启用邮箱验证后,邮箱为必填项"}), 400
if email and "@" not in email:
return jsonify({"error": "邮箱格式不正确"}), 400
if email:
is_valid, error_msg = validate_email(email)
if not is_valid:
return jsonify({"error": error_msg}), 400
system_config = database.get_system_config()
auto_approve_enabled = system_config.get("auto_approve_enabled", 0) == 1
@@ -159,17 +167,20 @@ def verify_email(token):
@require_ip_not_locked
def resend_verify_email():
"""重发验证邮件"""
data = request.json
email = data.get("email", "").strip()
data = request.json or {}
email = data.get("email", "").strip().lower()
captcha_session = data.get("captcha_session", "")
captcha_code = data.get("captcha", "").strip()
if not email:
return jsonify({"error": "请输入邮箱"}), 400
client_ip = get_client_ip()
is_valid, error_msg = validate_email(email)
if not is_valid:
return jsonify({"error": error_msg}), 400
allowed, error_msg = check_ip_rate_limit(client_ip)
client_ip = get_rate_limit_ip()
allowed, error_msg = check_ip_request_rate(client_ip, "email")
if not allowed:
return jsonify({"error": error_msg}), 429
@@ -213,17 +224,20 @@ def get_email_verify_status():
@require_ip_not_locked
def forgot_password():
"""发送密码重置邮件"""
data = request.json
email = data.get("email", "").strip()
data = request.json or {}
email = data.get("email", "").strip().lower()
captcha_session = data.get("captcha_session", "")
captcha_code = data.get("captcha", "").strip()
if not email:
return jsonify({"error": "请输入邮箱"}), 400
client_ip = get_client_ip()
is_valid, error_msg = validate_email(email)
if not is_valid:
return jsonify({"error": error_msg}), 400
allowed, error_msg = check_ip_rate_limit(client_ip)
client_ip = get_rate_limit_ip()
allowed, error_msg = check_ip_request_rate(client_ip, "email")
if not allowed:
return jsonify({"error": error_msg}), 429
@@ -267,7 +281,7 @@ def reset_password_page(token):
@api_auth_bp.route("/api/reset-password-confirm", methods=["POST"])
def reset_password_confirm():
"""确认密码重置"""
data = request.json
data = request.json or {}
token = data.get("token", "").strip()
new_password = data.get("new_password", "").strip()
@@ -292,9 +306,9 @@ def reset_password_confirm():
@api_auth_bp.route("/api/reset_password_request", methods=["POST"])
def request_password_reset():
"""用户申请重置密码(需要审核)"""
data = request.json
data = request.json or {}
username = data.get("username", "").strip()
email = data.get("email", "").strip()
email = data.get("email", "").strip().lower()
new_password = data.get("new_password", "").strip()
if not username or not new_password:
@@ -304,6 +318,11 @@ def request_password_reset():
if not is_valid:
return jsonify({"error": error_msg}), 400
if email:
is_valid, error_msg = validate_email(email)
if not is_valid:
return jsonify({"error": error_msg}), 400
user = database.get_user_by_username(username)
if user:
@@ -389,25 +408,36 @@ def generate_captcha():
@require_ip_not_locked
def login():
"""用户登录"""
data = request.json
data = request.json or {}
username = data.get("username", "").strip()
password = data.get("password", "").strip()
captcha_session = data.get("captcha_session", "")
captcha_code = data.get("captcha", "").strip()
need_captcha = data.get("need_captcha", False)
if need_captcha:
client_ip = get_rate_limit_ip()
allowed, error_msg = check_ip_request_rate(client_ip, "login")
if not allowed:
return jsonify({"error": error_msg, "need_captcha": True}), 429
captcha_required = check_login_captcha_required(client_ip) or bool(need_captcha)
if captcha_required:
if not captcha_session or not captcha_code:
return jsonify({"error": "请填写验证码", "need_captcha": True}), 400
success, message = safe_verify_and_consume_captcha(captcha_session, captcha_code)
if not success:
return jsonify({"error": message}), 400
record_login_failure(client_ip)
return jsonify({"error": message, "need_captcha": True}), 400
user = database.verify_user(username, password)
if not user:
return jsonify({"error": "用户名或密码错误", "need_captcha": True}), 401
record_login_failure(client_ip)
return jsonify({"error": "用户名或密码错误", "need_captcha": check_login_captcha_required(client_ip)}), 401
if user["status"] != "approved":
return jsonify({"error": "账号未审核,请等待管理员审核", "need_captcha": False}), 401
clear_login_failures(client_ip)
user_obj = User(user["id"])
login_user(user_obj)
load_user_accounts(user["id"])