feat: 登录权限跳转安全优化与权限控制系统完善

## 主要修改

### 🔒 安全优化 (auth.py)
- 统一所有用户登录跳转路径,防止权限暴露
- 所有用户默认跳转到 /fs-ai-asistant/api/workflow/lawrisk/db_admin
- 移除基于权限等级的多路径跳转逻辑
- 移除调试字段 TEST_MARKER

### 🛡️ 权限控制系统 (licensing_repo.py)
- 实现基于用户等级的权限过滤
- 超级管理员(grade=100)和市级管理员(grade>=90): 查看所有区域数据
- 区级管理员(grade<90): 只能查看自己区域数据
- 添加详细的权限拒绝日志记录

### 👥 用户管理增强 (auth_service.py, v2.py)
- 添加 delete_user_account 函数
- 实现用户删除API端点
- 防止删除最后一个管理员账号的安全检查

### 🎨 UI优化 (super_admin.html)
- 更新界面文案: "新的服务部门" → "绑定服务部门"

## 测试验证
-  所有用户统一跳转验证通过
-  权限控制逻辑验证通过
-  用户删除功能验证通过
-  自定义next参数支持正常

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Codex Agent 2025-11-18 15:45:30 +08:00
parent 9ca9a3642f
commit a6fe2f4370
5 changed files with 136 additions and 13 deletions

View File

@ -168,11 +168,12 @@ def login_action() -> Response:
sanitized = _scrub_user_payload(user)
session[SESSION_USER_KEY] = sanitized
# Smart redirect based on user role if no next_url specified
if not payload.get("next") and not request.args.get("next"):
user_role = str(user.get("role", "")).lower()
if user_role == "admin":
next_url = "/static/super_admin.html"
# 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 == "/":
# Default redirect path for all authenticated users (统一跳转,避免权限暴露)
next_url = "/fs-ai-asistant/api/workflow/lawrisk/db_admin"
# Form submissions expect redirects; API clients expect JSON
if request.content_type and "application/x-www-form-urlencoded" in request.content_type:

View File

@ -46,6 +46,7 @@ from lawrisk.services.auth_service import (
list_users,
create_user,
update_user_account,
delete_user_account,
)
from lawrisk.services.template_service import (
get_permit_template_path,
@ -133,7 +134,12 @@ def lawrisk_regions():
@v2_bp.route('/getPermits', methods=['GET', 'POST'])
def lawrisk_get_permits():
"""Get permits for a specific region."""
"""Get permits for a specific region, filtered by user permissions."""
import logging
logger = logging.getLogger(__name__)
logger.info("=" * 80)
logger.info("API CALLED: lawrisk_get_permits")
logger.info("=" * 80)
if request.method == "GET":
region_value = request.args.get("region") or request.args.get("region_id")
else:
@ -148,8 +154,12 @@ def lawrisk_get_permits():
region_token = region_value.strip() if isinstance(region_value, str) else str(region_value)
# Get current user for permission filtering
current_user = get_current_user()
print(f"DEBUG lawrisk_get_permits: current_user = {current_user}")
try:
permits = list_permits_for_region(region_token)
permits = list_permits_for_region(region_token, current_user=current_user)
return jsonify({"success": True, "data": {"region": region_token, "permits": permits}})
except Exception as exc:
print(f"lawrisk_get_permits error: {exc}")
@ -303,6 +313,23 @@ def admin_update_user(user_id: str):
return jsonify({"success": True, "data": {"user": updated}})
@v2_bp.route('/admin/users/<user_id>', methods=['DELETE'])
def admin_delete_user(user_id: str):
"""Delete a user account."""
_, error = _admin_guard(prefer_json=True)
if error:
return error
try:
deleted = delete_user_account(user_id)
except ValueError as exc:
return jsonify({"success": False, "message": str(exc)}), 400
except Exception as exc:
return jsonify({"success": False, "message": str(exc)}), 500
if not deleted:
return jsonify({"success": False, "message": "用户不存在"}), 404
return jsonify({"success": True, "message": "用户已删除"})
@v2_bp.route('/admin/service-departments', methods=['GET'])
def admin_list_service_departments():
"""Return all service departments in a flat list."""

View File

@ -521,6 +521,49 @@ def update_user_account(
return get_user_by_id(user_token)
def delete_user_account(user_id: str) -> bool:
"""
Delete a user account by ID.
Args:
user_id: The unique identifier of the user to delete
Returns:
True if the user was successfully deleted, False if user not found
Raises:
ValueError: If user_id is empty or if attempting to delete the last admin user
"""
user_token = (user_id or "").strip()
if not user_token:
raise ValueError("user_id 不能为空")
with _auth_conn() as conn:
cur = conn.cursor()
# Check if user exists
cur.execute("SELECT id, username, role FROM auth_users WHERE id = %s", (user_token,))
user = cur.fetchone()
if not user:
return False
# Prevent deleting the last admin user
if user[2] == "admin":
cur.execute("SELECT COUNT(*) FROM auth_users WHERE role = 'admin' AND id != %s", (user_token,))
other_admins = cur.fetchone()[0]
if other_admins == 0:
raise ValueError("不能删除最后一个管理员账号")
# Delete the user
cur.execute("DELETE FROM auth_users WHERE id = %s", (user_token,))
if cur.rowcount == 0:
conn.rollback()
return False
conn.commit()
return True
def verify_password(password: str, password_hash: str) -> bool:
if not password or not password_hash:
return False

View File

@ -2756,20 +2756,72 @@ def load_business_scopes(region_id: str) -> List[Dict[str, str]]:
return scopes
def list_permits_for_region(region: str) -> List[Dict[str, str]]:
"""Return all permits available within a region (accepts id or name)."""
def list_permits_for_region(region: str, current_user: Optional[Dict[str, Any]] = None) -> List[Dict[str, str]]:
"""Return all permits available within a region (accepts id or name).
Args:
region: Region ID or name
current_user: Current authenticated user (optional). If provided,
will filter permits based on user's permission level.
- grade >= 90 or admin: can view all regions
- grade < 90: can only view permits from their assigned region
Returns:
List of permits visible to the user based on their permission level
"""
sql = """
SELECT DISTINCT p.id, p.name
FROM region_theme_permits rtp
JOIN permits p ON p.id = rtp.permit_id
JOIN regions r ON r.id = rtp.region_id
WHERE rtp.region_id::text = %s OR LOWER(r.name) = LOWER(%s)
ORDER BY p.name
WHERE (rtp.region_id::text = %s OR LOWER(r.name) = LOWER(%s))
"""
params = [region, region]
# Apply permission filtering based on user grade and department
if current_user and isinstance(current_user, dict):
user_grade = current_user.get('grade', 0)
user_role = current_user.get('role', '')
user_department = current_user.get('department', {})
username = current_user.get('username', 'unknown')
# Super admin (grade=100) or city-level admin (grade>=90) can view all
# Only apply restriction for district-level users (grade < 90 and not admin)
if user_role != 'admin' and user_grade < 90:
# Check if user has department and region assignment
user_region_id = user_department.get('region_id') if user_department else None
# If user has no region_id or region_id is None, deny access
if not user_region_id or user_region_id == 'None':
logger.warning(
f"Permission denied: User {username} (grade={user_grade}, role={user_role}) "
f"has no valid region assignment (region_id={user_region_id}), denying access to all permits"
)
return []
# User can only view permits from their assigned region
# Check if requested region matches user's region
with _lic_pg_conn() as conn_check:
cur_check = conn_check.cursor()
cur_check.execute("SELECT id FROM regions WHERE id::text = %s OR LOWER(name) = LOWER(%s)", (region, region))
region_row = cur_check.fetchone()
if region_row:
requested_region_id = str(region_row[0])
# If user is requesting a different region, deny access
if requested_region_id != user_region_id:
logger.info(
f"Permission denied: User {username} (grade={user_grade}, user_region={user_region_id}) "
f"attempted to access permits from region {requested_region_id}"
)
return []
sql += " ORDER BY p.name"
permits: List[Dict[str, str]] = []
with _lic_pg_conn() as conn:
cur = conn.cursor()
cur.execute(sql, (region, region))
cur.execute(sql, params)
for permit_id, permit_name in cur.fetchall():
permits.append({"id": str(permit_id), "name": str(permit_name)})
return permits

View File

@ -951,7 +951,7 @@
<label>新密码(留空表示不修改)
<input type="password" name="password" placeholder="可选">
</label>
<label>新的服务部门
<label>绑定服务部门
<select name="service_department_id" id="userUpdateDept">
<option value="">保持不变 / 清除绑定</option>
</select>