2025-11-14 15:46:18 +08:00
|
|
|
|
"""Auth blueprint: login page, authentication endpoints, and helpers."""
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
from functools import wraps
|
|
|
|
|
|
from typing import Any, Callable, Dict, Iterable, Optional, Tuple
|
|
|
|
|
|
from urllib.parse import urlparse
|
|
|
|
|
|
|
|
|
|
|
|
from flask import (
|
|
|
|
|
|
Blueprint,
|
|
|
|
|
|
Response,
|
|
|
|
|
|
jsonify,
|
|
|
|
|
|
redirect,
|
|
|
|
|
|
render_template,
|
|
|
|
|
|
request,
|
|
|
|
|
|
session,
|
|
|
|
|
|
url_for,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-19 15:14:42 +08:00
|
|
|
|
from lawrisk.services.auth_service import (
|
|
|
|
|
|
get_user_by_username,
|
|
|
|
|
|
update_user_account,
|
|
|
|
|
|
verify_password,
|
|
|
|
|
|
)
|
2025-11-14 15:46:18 +08:00
|
|
|
|
|
|
|
|
|
|
|
2025-12-20 11:25:34 +08:00
|
|
|
|
auth_bp = Blueprint("auth", __name__, url_prefix="/fs-ai-asistant/api/workflow/lawrisk")
|
2025-11-14 15:46:18 +08:00
|
|
|
|
|
|
|
|
|
|
SESSION_USER_KEY = "auth_user"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _safe_next_url(candidate: Optional[str]) -> str:
|
2025-11-18 09:39:18 +08:00
|
|
|
|
"""
|
|
|
|
|
|
Sanitize next URL to prevent security issues.
|
|
|
|
|
|
|
|
|
|
|
|
For security reasons, we don't preserve query parameters that might
|
|
|
|
|
|
contain sensitive data like passwords. We only preserve the path.
|
|
|
|
|
|
"""
|
2025-11-14 15:46:18 +08:00
|
|
|
|
if not candidate:
|
|
|
|
|
|
return "/"
|
|
|
|
|
|
raw = str(candidate).strip()
|
|
|
|
|
|
if not raw:
|
|
|
|
|
|
return "/"
|
|
|
|
|
|
parsed = urlparse(raw)
|
|
|
|
|
|
if parsed.netloc:
|
|
|
|
|
|
safe_path = parsed.path or "/"
|
|
|
|
|
|
else:
|
|
|
|
|
|
safe_path = raw if raw.startswith("/") else f"/{raw}"
|
2025-11-18 09:39:18 +08:00
|
|
|
|
|
|
|
|
|
|
# For security: strip ALL query parameters to prevent password leakage
|
|
|
|
|
|
# Only preserve path, never query strings
|
2025-11-14 15:46:18 +08:00
|
|
|
|
return safe_path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _scrub_user_payload(user: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
|
|
|
department = None
|
|
|
|
|
|
if user.get("service_department_id"):
|
|
|
|
|
|
department = {
|
|
|
|
|
|
"id": user.get("service_department_id"),
|
|
|
|
|
|
"name": user.get("service_department_name"),
|
|
|
|
|
|
"code": user.get("service_department_code"),
|
|
|
|
|
|
"region_id": user.get("service_department_region_id"),
|
2025-11-27 17:13:49 +08:00
|
|
|
|
"region_name": user.get("service_department_region_name"),
|
2025-11-14 15:46:18 +08:00
|
|
|
|
"parent_id": user.get("service_department_parent_id"),
|
|
|
|
|
|
"phone": user.get("service_department_phone"),
|
|
|
|
|
|
"role": user.get("department_role"),
|
|
|
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
|
|
|
"id": user.get("id"),
|
|
|
|
|
|
"username": user.get("username"),
|
|
|
|
|
|
"display_name": user.get("display_name"),
|
|
|
|
|
|
"role": user.get("role"),
|
|
|
|
|
|
"grade": user.get("grade"),
|
|
|
|
|
|
"department": department,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def ensure_admin_access(
|
|
|
|
|
|
allowed_roles: Optional[Iterable[str]] = None,
|
|
|
|
|
|
*,
|
|
|
|
|
|
prefer_json: bool = False,
|
|
|
|
|
|
) -> Tuple[Optional[Dict[str, Any]], Optional[Response]]:
|
|
|
|
|
|
"""Ensure the current session belongs to an admin role."""
|
|
|
|
|
|
user = get_current_user()
|
|
|
|
|
|
wants_html = (
|
|
|
|
|
|
(not prefer_json)
|
|
|
|
|
|
and request.method == "GET"
|
|
|
|
|
|
and request.accept_mimetypes.accept_html
|
|
|
|
|
|
and (
|
|
|
|
|
|
request.accept_mimetypes["text/html"]
|
|
|
|
|
|
>= request.accept_mimetypes["application/json"]
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
if not user:
|
|
|
|
|
|
if wants_html:
|
2025-11-18 09:39:18 +08:00
|
|
|
|
# Sanitize URL to prevent password leakage in redirect
|
|
|
|
|
|
safe_next = _safe_next_url(request.url)
|
|
|
|
|
|
login_url = url_for("auth.login_page", next=safe_next)
|
2025-11-14 15:46:18 +08:00
|
|
|
|
return None, redirect(login_url)
|
|
|
|
|
|
return None, (jsonify({"error": "authentication required"}), 401)
|
|
|
|
|
|
|
|
|
|
|
|
default_roles = ("admin",) if allowed_roles is None else allowed_roles
|
|
|
|
|
|
allowed = {str(role).lower() for role in default_roles if role}
|
|
|
|
|
|
current_role = str(user.get("role") or "").lower()
|
|
|
|
|
|
|
|
|
|
|
|
if allowed and current_role not in allowed:
|
|
|
|
|
|
if wants_html:
|
2025-11-18 09:39:18 +08:00
|
|
|
|
# Sanitize URL to prevent password leakage in redirect
|
|
|
|
|
|
safe_next = _safe_next_url(request.url)
|
|
|
|
|
|
login_url = url_for("auth.login_page", next=safe_next, force=1)
|
2025-11-14 15:46:18 +08:00
|
|
|
|
return None, redirect(login_url)
|
|
|
|
|
|
return None, (jsonify({"error": "admin privileges required"}), 403)
|
|
|
|
|
|
|
|
|
|
|
|
return user, None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-10 19:06:06 +08:00
|
|
|
|
def is_superuser(user: Optional[Dict[str, Any]]) -> bool:
|
|
|
|
|
|
"""Check if the given user is a superuser with full system access.
|
|
|
|
|
|
|
|
|
|
|
|
Superuser criteria (any match):
|
|
|
|
|
|
1. role equals 'admin'
|
|
|
|
|
|
2. username equals 'fssjsj'
|
|
|
|
|
|
3. grade equals 100 (fallback check)
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
user: User dictionary from get_current_user()
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
True if user is a superuser, False otherwise
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not user:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
# Primary check: role-based
|
|
|
|
|
|
if user.get("role") == "admin":
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
# Special exception: specific username
|
|
|
|
|
|
if user.get("username") == "fssjsj":
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
# Fallback check: grade-based
|
|
|
|
|
|
if user.get("grade") == 100:
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-11-14 15:46:18 +08:00
|
|
|
|
def get_current_user() -> Optional[Dict[str, Any]]:
|
|
|
|
|
|
data = session.get(SESSION_USER_KEY)
|
|
|
|
|
|
if not isinstance(data, dict):
|
|
|
|
|
|
return None
|
|
|
|
|
|
return data
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def login_required(view: Callable[..., Response]) -> Callable[..., Response]:
|
|
|
|
|
|
@wraps(view)
|
|
|
|
|
|
def wrapper(*args: Any, **kwargs: Any) -> Response:
|
|
|
|
|
|
user = get_current_user()
|
|
|
|
|
|
if not user:
|
|
|
|
|
|
wants_html = request.method == "GET" and request.accept_mimetypes.accept_html and (
|
|
|
|
|
|
request.accept_mimetypes["text/html"] >= request.accept_mimetypes["application/json"]
|
|
|
|
|
|
)
|
|
|
|
|
|
if wants_html:
|
2025-11-18 09:39:18 +08:00
|
|
|
|
# Sanitize URL to prevent password leakage in redirect
|
|
|
|
|
|
safe_next = _safe_next_url(request.url)
|
|
|
|
|
|
login_url = url_for("auth.login_page", next=safe_next)
|
2025-11-14 15:46:18 +08:00
|
|
|
|
return redirect(login_url)
|
|
|
|
|
|
return jsonify({"error": "authentication required"}), 401
|
|
|
|
|
|
return view(*args, **kwargs)
|
|
|
|
|
|
|
|
|
|
|
|
return wrapper
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _truthy_param(value: Optional[str]) -> bool:
|
|
|
|
|
|
if value is None:
|
|
|
|
|
|
return False
|
|
|
|
|
|
return str(value).strip().lower() in {"1", "true", "yes", "on", "force", "reauth"}
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-20 11:25:34 +08:00
|
|
|
|
@auth_bp.get("/login")
|
2025-11-14 15:46:18 +08:00
|
|
|
|
def login_page() -> Response:
|
|
|
|
|
|
user = get_current_user()
|
|
|
|
|
|
force_login = _truthy_param(request.args.get("force") or request.args.get("reauth"))
|
|
|
|
|
|
if user and not force_login:
|
|
|
|
|
|
return redirect(request.args.get("next") or "/")
|
|
|
|
|
|
return render_template("login.html")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@auth_bp.post("/auth/login")
|
|
|
|
|
|
def login_action() -> Response:
|
|
|
|
|
|
payload = request.get_json(silent=True) or request.form
|
|
|
|
|
|
username = (payload.get("username", "") if payload else "").strip().lower()
|
|
|
|
|
|
password = (payload.get("password", "") if payload else "").strip()
|
|
|
|
|
|
next_url = _safe_next_url((payload.get("next") if payload else None) or request.args.get("next"))
|
|
|
|
|
|
|
|
|
|
|
|
if not username or not password:
|
|
|
|
|
|
return jsonify({"error": "username and password are required"}), 400
|
|
|
|
|
|
|
|
|
|
|
|
user = get_user_by_username(username)
|
|
|
|
|
|
if not user or not verify_password(password, user.get("password_hash", "")):
|
|
|
|
|
|
return jsonify({"error": "invalid credentials"}), 401
|
|
|
|
|
|
if not user.get("is_active", True):
|
|
|
|
|
|
return jsonify({"error": "account disabled"}), 403
|
|
|
|
|
|
|
|
|
|
|
|
sanitized = _scrub_user_payload(user)
|
|
|
|
|
|
session[SESSION_USER_KEY] = sanitized
|
|
|
|
|
|
|
2025-11-18 15:45:30 +08:00
|
|
|
|
# Smart redirect: only apply redirect logic if no explicit next parameter was provided
|
|
|
|
|
|
# For security, all users use the same default redirect to avoid exposing permission levels
|
|
|
|
|
|
explicit_next = (payload.get("next") if payload else None) or request.args.get("next")
|
|
|
|
|
|
if not explicit_next or next_url == "/":
|
2025-12-20 11:25:34 +08:00
|
|
|
|
# Default redirect path: admin to db_admin, others to root
|
|
|
|
|
|
if sanitized.get("role") == "admin":
|
|
|
|
|
|
next_url = "/fs-ai-asistant/api/workflow/lawrisk/db_admin"
|
|
|
|
|
|
else:
|
|
|
|
|
|
# For non-admin, redirect to root or homepage if available
|
|
|
|
|
|
next_url = "/fs-ai-asistant/api/workflow/lawrisk/"
|
2025-11-18 09:39:18 +08:00
|
|
|
|
|
2025-11-14 15:46:18 +08:00
|
|
|
|
# Form submissions expect redirects; API clients expect JSON
|
|
|
|
|
|
if request.content_type and "application/x-www-form-urlencoded" in request.content_type:
|
|
|
|
|
|
return redirect(next_url)
|
|
|
|
|
|
|
|
|
|
|
|
return jsonify({"message": "login successful", "user": sanitized, "redirect": next_url})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@auth_bp.post("/auth/logout")
|
|
|
|
|
|
def logout_action() -> Response:
|
|
|
|
|
|
session.pop(SESSION_USER_KEY, None)
|
|
|
|
|
|
if request.content_type and "application/x-www-form-urlencoded" in request.content_type:
|
|
|
|
|
|
return redirect(url_for("auth.login_page"))
|
|
|
|
|
|
return jsonify({"message": "logged out"})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@auth_bp.get("/auth/me")
|
|
|
|
|
|
def current_user_endpoint() -> Response:
|
|
|
|
|
|
user = get_current_user()
|
|
|
|
|
|
if not user:
|
|
|
|
|
|
return jsonify({"authenticated": False}), 401
|
|
|
|
|
|
return jsonify({"authenticated": True, "user": user})
|
2026-03-19 15:14:42 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _validate_password_complexity(password: str) -> Optional[str]:
|
|
|
|
|
|
"""验证密码复杂度:最小8位,必须包含字母和数字"""
|
|
|
|
|
|
if len(password) < 8:
|
|
|
|
|
|
return "密码长度至少为 8 位"
|
|
|
|
|
|
if not (any(c.isalpha() for c in password) and any(c.isdigit() for c in password)):
|
|
|
|
|
|
return "密码必须同时包含字母和数字"
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@auth_bp.post("/v2/auth/change-password")
|
|
|
|
|
|
@login_required
|
|
|
|
|
|
def change_password_endpoint() -> Response:
|
|
|
|
|
|
"""Allow authenticated users to change their own password."""
|
|
|
|
|
|
try:
|
|
|
|
|
|
payload = request.get_json(silent=True) or {}
|
|
|
|
|
|
current_password = (payload.get("current_password", "") if payload else "").strip()
|
|
|
|
|
|
new_password = (payload.get("new_password", "") if payload else "").strip()
|
|
|
|
|
|
|
|
|
|
|
|
# Validate required fields
|
|
|
|
|
|
if not current_password:
|
|
|
|
|
|
return jsonify({"error": "请输入当前密码"}), 400
|
|
|
|
|
|
if not new_password:
|
|
|
|
|
|
return jsonify({"error": "请输入新密码"}), 400
|
|
|
|
|
|
|
|
|
|
|
|
# Get current user from session
|
|
|
|
|
|
user = get_current_user()
|
|
|
|
|
|
if not user:
|
|
|
|
|
|
return jsonify({"error": "用户未登录"}), 401
|
|
|
|
|
|
|
|
|
|
|
|
print(f"DEBUG: Session user = {user}")
|
|
|
|
|
|
|
|
|
|
|
|
# Get complete user data including password hash
|
|
|
|
|
|
username = user.get("username")
|
|
|
|
|
|
if not username:
|
|
|
|
|
|
return jsonify({"error": "无效的用户信息"}), 400
|
|
|
|
|
|
|
|
|
|
|
|
# 使用 get_user_by_username 获取完整用户数据(包括密码哈希)
|
|
|
|
|
|
user_data = get_user_by_username(username)
|
|
|
|
|
|
if not user_data:
|
|
|
|
|
|
return jsonify({"error": "用户不存在"}), 404
|
|
|
|
|
|
|
|
|
|
|
|
# Debug logging
|
|
|
|
|
|
print(f"DEBUG: username = {username}")
|
|
|
|
|
|
print(f"DEBUG: password_hash exists = {'password_hash' in user_data}")
|
|
|
|
|
|
print(f"DEBUG: password_hash length = {len(user_data.get('password_hash', ''))}")
|
|
|
|
|
|
print(f"DEBUG: current_password provided = {bool(current_password)}")
|
|
|
|
|
|
|
|
|
|
|
|
# Verify current password
|
|
|
|
|
|
password_hash = user_data.get("password_hash", "")
|
|
|
|
|
|
if not password_hash:
|
|
|
|
|
|
print("ERROR: password_hash is empty!")
|
|
|
|
|
|
return jsonify({"error": "系统错误:无法获取密码哈希"}), 500
|
|
|
|
|
|
|
|
|
|
|
|
if not verify_password(current_password, password_hash):
|
|
|
|
|
|
print("ERROR: Password verification failed!")
|
|
|
|
|
|
print(f"DEBUG: Hash preview: {password_hash[:20]}..." if len(password_hash) > 20 else f"DEBUG: Hash: {password_hash}")
|
|
|
|
|
|
return jsonify({"error": "当前密码错误"}), 401
|
|
|
|
|
|
|
|
|
|
|
|
print("DEBUG: Password verified successfully!")
|
|
|
|
|
|
|
|
|
|
|
|
# Get user_id for update operation
|
|
|
|
|
|
user_id = user_data.get("id")
|
|
|
|
|
|
if not user_id:
|
|
|
|
|
|
return jsonify({"error": "无法获取用户ID"}), 400
|
|
|
|
|
|
|
|
|
|
|
|
# Validate new password complexity
|
|
|
|
|
|
complexity_error = _validate_password_complexity(new_password)
|
|
|
|
|
|
if complexity_error:
|
|
|
|
|
|
return jsonify({"error": complexity_error}), 400
|
|
|
|
|
|
|
|
|
|
|
|
# Check if new password is same as current
|
|
|
|
|
|
if verify_password(new_password, user_data.get("password_hash", "")):
|
|
|
|
|
|
return jsonify({"error": "新密码不能与当前密码相同"}), 400
|
|
|
|
|
|
|
|
|
|
|
|
# Update password
|
|
|
|
|
|
print(f"DEBUG: Attempting to update password for user_id = {user_id}")
|
|
|
|
|
|
update_result = update_user_account(
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
password=new_password,
|
|
|
|
|
|
)
|
|
|
|
|
|
print(f"DEBUG: Update result = {update_result}")
|
|
|
|
|
|
|
|
|
|
|
|
if not update_result:
|
|
|
|
|
|
print("ERROR: Password update returned None/False")
|
|
|
|
|
|
return jsonify({"error": "密码修改失败,请稍后重试"}), 500
|
|
|
|
|
|
|
|
|
|
|
|
# Note: update_user_account already logs the operation internally
|
|
|
|
|
|
print("DEBUG: Password change completed successfully")
|
|
|
|
|
|
return jsonify({"message": "密码修改成功"}), 200
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
# Log full error details
|
|
|
|
|
|
import traceback
|
|
|
|
|
|
print(f"ERROR: Exception in change_password_endpoint")
|
|
|
|
|
|
print(f"ERROR: Type: {type(e).__name__}")
|
|
|
|
|
|
print(f"ERROR: Message: {str(e)}")
|
|
|
|
|
|
print(f"ERROR: Traceback:\n{traceback.format_exc()}")
|
|
|
|
|
|
return jsonify({"error": "密码修改失败,请稍后重试"}), 500
|