feat: add system admin account management with RBAC

- AdminUser model: multi-admin accounts with bcrypt hashing (admin_users table)
- Three roles: superadmin / admin / viewer with granular permission bits
- auth.py: login migrated from env-var single admin to DB-backed accounts;
  seed_initial_admin() auto-creates first superadmin from SM_ADMIN_PASSWORD
- Web UI: /system/admins page (add/edit role/toggle/reset pwd/delete)
  + change-my-password; sidebar entry; permission-guarded routes
- REST API: /api/admins CRUD with protection checks
- Protections: cannot delete/disable self; keep >=1 enabled superadmin;
  disabled accounts fail permission checks immediately
- README: document roles, permission bits, API
This commit is contained in:
Your Name
2026-09-09 14:33:59 +08:00
parent 3232159d29
commit 9890cfc488
8 changed files with 719 additions and 51 deletions
+32 -1
View File
@@ -39,7 +39,9 @@
| 详细审计日志 | 按事件/用户/IP 筛选,分页查询 |
| 级联代理 | 连接建立时自动代理至目标(可扩展 upstream) |
| 负载均衡 | 多实例配置后按端口轮询 |
| 管理员认证 | 环境变量 `SM_ADMIN_PASSWORD` 保护 |
| 管理员认证 | 多管理员账户存数据库(`AdminUser` 表,bcrypt 哈希),支持角色权限 |
| 角色权限 | superadmin / admin / viewer 三级角色,细粒度权限位控制 |
| 管理员管理 | Web/API 增删改、启停、改角色、重置密码(带保护校验) |
| 防爆破 | 自动阻断频繁登录尝试(Fail2Ban 机制) |
| 密码安全 | bcrypt 哈希存储 SOCKS5 用户密码(兼容旧明文自动迁移) |
| 会话管理 | 自定义 Cookie 域名/安全/名称,支持 HTTPS 部署 |
@@ -66,6 +68,35 @@ python3 run.py
> ⚠️ 首次启动时如果 `SM_ADMIN_PASSWORD` 未设置,会自动写入默认密码 `admin123` 到 `.env` 文件。**生产环境请务必修改此密码。**
## 系统账户管理
Web 后台管理员账户存储在数据库(`admin_users` 表),支持多账户与角色权限,通过 **系统管理 → 管理员账户** 管理(也可用 REST API `/api/admins`)。
### 三级角色
| 角色 | 权限 |
|------|------|
| **superadmin** 超级管理员 | 全部权限,含管理员账户管理(增删改/启停/改角色/重置密码) |
| **admin** 管理员 | 用户/实例/系统管理,可查看管理员账户但不能修改 |
| **viewer** 只读用户 | 仅查看仪表盘/实例/用户/日志,无任何写操作 |
### 角色权限位
每个角色对应一组权限位(`users:read``users:write``instances:read``instances:write``system:read``system:write``admins:read``admins:write``logs:read``stats:read`),可在 `models.py``ROLE_PERMISSIONS` 中扩展。
### 安全保护
- **不能删除/禁用当前登录账户**
- **至少保留一个启用的超级管理员**(删除最后一位 superadmin 会被拒绝)
- 登录密码 bcrypt 哈希存储
- 账户禁用后立即失效(权限校验实时检查)
### REST API
```
GET /api/admins # 列出管理员
POST /api/admins # 创建 {username, password, role}
PUT /api/admins/<id> # 更新 {role?, password?, enabled?}
DELETE /api/admins/<id> # 删除(带保护校验)
```
## 环境变量
| 变量 | 默认值 | 说明 |
+103 -1
View File
@@ -1,7 +1,7 @@
"""REST API v1 — 全部路由。"""
import os
from flask import Blueprint, request, jsonify, current_app
from auth import login_required
from auth import login_required, permission_required
from database import db
from models import Instance, User, AuditLog
from services import user_service, stats_service, backup_service
@@ -279,3 +279,105 @@ def cleanup_backups():
keep = request.json.get("keep", 10) if request.is_json else 10
backup_service.cleanup_old_backups(keep=int(keep))
return jsonify({"ok": True})
# ═══════════════════════════════════════════════════════════════
# 后台管理员账户
# ═══════════════════════════════════════════════════════════════
def _serialize_admin(a):
return {
"id": a.id,
"username": a.username,
"role": a.role,
"role_label": a.role_label,
"enabled": a.enabled,
"last_login_at": a.last_login_at.isoformat() if a.last_login_at else None,
"last_login_ip": a.last_login_ip,
"created_at": a.created_at.isoformat() if a.created_at else None,
}
@bp.route("/admins")
@login_required
@permission_required("admins:read")
def list_admins():
from models import AdminUser
admins = AdminUser.query.order_by(AdminUser.id.asc()).all()
return jsonify([_serialize_admin(a) for a in admins])
@bp.route("/admins", methods=["POST"])
@login_required
@permission_required("admins:write")
def create_admin():
from models import AdminUser
data = request.get_json(silent=True) or {}
username = (data.get("username") or "").strip()
password = data.get("password") or ""
role = data.get("role", "viewer")
if not username or not password:
return jsonify({"error": "用户名和密码不能为空"}), 400
if len(password) < 4:
return jsonify({"error": "密码至少 4 位"}), 400
if role not in AdminUser.ROLE_LABELS:
role = "viewer"
if AdminUser.query.filter_by(username=username).first():
return jsonify({"error": "用户名已存在"}), 409
admin = AdminUser()
admin.username = username
admin.role = role
admin.enabled = True
admin.set_password(password)
db.session.add(admin)
db.session.commit()
return jsonify(_serialize_admin(admin)), 201
@bp.route("/admins/<int:aid>", methods=["DELETE"])
@login_required
@permission_required("admins:write")
def delete_admin_api(aid):
from models import AdminUser
from auth import current_admin
admin = AdminUser.query.get_or_404(aid)
curr = current_admin()
if curr and curr.id == admin.id:
return jsonify({"error": "不能删除当前登录账户"}), 400
if admin.role == "superadmin":
super_count = AdminUser.query.filter_by(role="superadmin", enabled=True).count()
if super_count <= 1:
return jsonify({"error": "至少需要保留一个启用的超级管理员"}), 400
db.session.delete(admin)
db.session.commit()
return jsonify({"ok": True, "deleted": admin.username})
@bp.route("/admins/<int:aid>", methods=["PUT"])
@login_required
@permission_required("admins:write")
def update_admin_api(aid):
from models import AdminUser
admin = AdminUser.query.get_or_404(aid)
data = request.get_json(silent=True) or {}
# 角色更新
role = data.get("role")
if role:
if role not in AdminUser.ROLE_LABELS:
return jsonify({"error": "无效的角色"}), 400
admin.role = role
# 密码更新
password = data.get("password")
if password:
if len(password) < 4:
return jsonify({"error": "密码至少 4 位"}), 400
admin.set_password(password)
# 启停
if "enabled" in data:
enabled = bool(data["enabled"])
from auth import current_admin
curr = current_admin()
if curr and curr.id == admin.id and not enabled:
return jsonify({"error": "不能禁用当前登录账户"}), 400
admin.enabled = enabled
db.session.commit()
return jsonify(_serialize_admin(admin))
+4 -5
View File
@@ -78,10 +78,6 @@ log = logging.getLogger("socks.app")
def create_app():
# 确保密码已设置(首次启动自动设置默认密码)
from auth import get_or_set_password
get_or_set_password()
app = Flask(__name__)
app.config.from_object(Config)
app.config["SECRET_KEY"] = os.environ.get(
@@ -100,9 +96,12 @@ def create_app():
db.init_app(app)
app.db = db
# 全局服务注册
# 建表 + 种子数据(必须在 app_context 中)
with app.app_context():
db.create_all()
# 种子初始管理员
from auth import seed_initial_admin
seed_initial_admin()
# 初始化全局 db.app
from database import db as _db
_db.app = app
+160 -41
View File
@@ -1,20 +1,29 @@
"""登录认证"""
"""登录认证 + 权限系统。
v2 版本:后台管理员账户存在数据库(AdminUser 表),支持多账户、角色权限。
首次启动时,如果 AdminUser 表为空,会从 SM_ADMIN_USER / SM_ADMIN_PASSWORD
环境变量(或 .env)创建初始超级管理员。
"""
import os
import hashlib
import logging
import secrets
from datetime import datetime, timezone
from functools import wraps
from flask import Blueprint, request, session, redirect, url_for, current_app
from flask import Blueprint, request, session, redirect, url_for, current_app, abort
log = logging.getLogger("socks.auth")
bp = Blueprint("auth", __name__)
def _get_admin_creds():
_ENV = os.environ.get("SM_ENV_PATH", os.path.join(os.path.dirname(__file__), ".env"))
# ── 工具函数 ----------------------------------------------------------------
def _read_env_creds():
"""从环境变量或 .env 文件读取初始管理员凭据。"""
_ENV = os.environ.get("SM_ENV_PATH",
os.path.join(os.path.dirname(__file__), ".env"))
pwd = os.environ.get("SM_ADMIN_PASSWORD")
user = os.environ.get("SM_ADMIN_USER", "admin")
if not pwd and os.path.exists(_ENV):
if (not pwd) and os.path.exists(_ENV):
try:
for line in open(_ENV):
line = line.strip()
@@ -29,37 +38,62 @@ def _get_admin_creds():
return (user, pwd or "")
def seed_initial_admin():
"""确保至少存在一个超级管理员。
- 如果 admin_users 表为空:从环境变量/.env 创建初始 superadmin
- 如果表非空:什么都不做
返回创建的 admin 对象或 None。
"""
from models import AdminUser
from database import db
if AdminUser.query.count() > 0:
return None
user, pwd = _read_env_creds()
if not pwd or not pwd.strip():
pwd = "admin123"
log.warning("SM_ADMIN_PASSWORD 未设置,使用默认密码 admin123")
admin = AdminUser()
admin.username = user or "admin"
admin.role = "superadmin"
admin.enabled = True
admin.set_password(pwd)
db.session.add(admin)
db.session.commit()
log.info("已创建初始管理员: %s (role=superadmin)", admin.username)
return admin
def get_or_set_password():
"""启动时检查密码,如果为空则设置默认值并打印提示。"""
import os
u, p = _get_admin_creds()
if not p or not p.strip():
default_pwd = "admin123"
os.environ["SM_ADMIN_PASSWORD"] = default_pwd
os.environ["SM_ADMIN_USER"] = u
_ENV = os.environ.get("SM_ENV_PATH", os.path.join(os.path.dirname(__file__), ".env"))
try:
with open(_ENV, "w") as f:
f.write(f"SM_ADMIN_USER={u}\nSM_ADMIN_PASSWORD={default_pwd}\n")
print(f"⚠️ 密码未设置,已使用默认密码: {default_pwd}")
print(f" 配置文件: {_ENV}")
print(f" 请立即修改密码!")
except Exception as e:
print(f"⚠️ 密码未设置,默认: {default_pwd} (无法写入 .env: {e})")
return (u, default_pwd)
return (u, p)
"""兼容旧版启动流程(run.py 调用)。
现在做的是:确保有初始管理员存在。
"""
admin = seed_initial_admin()
if admin:
return (admin.username, "(见数据库)")
# 至少返回一个用户名用于显示
u, p = _read_env_creds()
return (u, p or "")
def get_admin_password_hash():
"""获取管理员密码哈希(用于安全比较)。"""
_, p = _get_admin_creds()
return hashlib.sha256(p.encode("utf-8")).hexdigest()
# ── 当前登录用户 ------------------------------------------------------------
def current_admin():
"""返回当前登录的 AdminUser 对象;未登录返回 None。"""
from models import AdminUser
uid = session.get("admin_id")
if not uid:
return None
return AdminUser.query.get(uid)
def login_required(f):
@wraps(f)
def decorated(*args, **kwargs):
if not session.get("logged_in"):
if not session.get("logged_in") or not session.get("admin_id"):
if request.path.startswith("/api/"):
return {"error": "unauthorized"}, 401
return redirect(url_for("auth.login", next=request.url))
@@ -67,32 +101,66 @@ def login_required(f):
return decorated
def permission_required(perm):
"""权限装饰器:需要指定权限位才能访问。"""
def decorator(f):
@wraps(f)
def decorated(*args, **kwargs):
admin = current_admin()
if not admin:
if request.path.startswith("/api/"):
return {"error": "unauthorized"}, 401
return redirect(url_for("auth.login", next=request.url))
if not admin.enabled:
session.clear()
return {"error": "账户已被禁用"}, 403
if not admin.has_permission(perm):
if request.path.startswith("/api/"):
return {"error": "forbidden"}, 403
abort(403)
return f(*args, **kwargs)
return decorated
return decorator
# ── 登录 / 登出 -------------------------------------------------------------
@bp.route("/login", methods=["GET", "POST"])
def login():
if session.get("logged_in"):
if session.get("logged_in") and session.get("admin_id"):
return redirect(url_for("web.index"))
if request.method == "POST":
u, p = _get_admin_creds()
from models import AdminUser
from database import db
form_user = request.form.get("username", "").strip()
form_pwd = request.form.get("password", "")
# 防爆破检查
# 防爆破
client_ip = request.remote_addr or "unknown"
from services.user_service import UserService
us = UserService()
blocked, remaining = us.check_fail2ban(client_ip)
if blocked:
_fail2ban_record(client_ip, check_only=True)
if _is_banned(client_ip):
log.warning("防爆破已阻断来自 %s 的登录尝试", client_ip)
return {"error": "登录过于频繁,请稍后再试"}, 429
# 使用常量时间比较防止时序攻击
if form_user == u and secrets.compare_digest(form_pwd, p):
us.record_auth_attempt(client_ip, success=True)
admin = AdminUser.query.filter_by(username=form_user).first()
if admin and admin.enabled and admin.check_password(form_pwd):
# 登录成功
admin.last_login_at = datetime.now(timezone.utc)
admin.last_login_ip = client_ip
db.session.commit()
_reset_fail2ban(client_ip)
session.permanent = True
session["logged_in"] = True
session["user"] = u
session["admin_id"] = admin.id
session["user"] = admin.username
session["role"] = admin.role
return redirect(request.args.get("next") or url_for("web.index"))
us.record_auth_attempt(client_ip, success=False)
# 登录失败
_record_fail2ban(client_ip)
return {"error": "用户名或密码错误"}, 401
return _render_login()
@@ -103,6 +171,57 @@ def logout():
return redirect(url_for("auth.login"))
# ── 防爆破(进程内简易实现) ------------------------------------------------
# 注意:多 worker 下每个进程独立,生产环境建议用 Redis 替代。
_fail_attempts = {} # {ip: [timestamps...]}
_ban_until = {} # {ip: timestamp}
_BAN_WINDOW = 600 # 10 分钟窗口
_BAN_MAX = 10 # 最多 10 次失败
_BAN_DURATION = 900 # 封禁 15 分钟
def _is_banned(ip):
now = _now()
if ip in _ban_until and _ban_until[ip] > now:
return True
if ip in _ban_until:
del _ban_until[ip]
return False
def _fail2ban_record(ip, check_only=False):
"""检查 + 清理过期记录(兼容旧版 UserService.check_fail2ban 调用方式)。"""
now = _now()
if ip in _fail_attempts:
_fail_attempts[ip] = [t for t in _fail_attempts[ip] if now - t < _BAN_WINDOW]
return _is_banned(ip), _BAN_MAX - len(_fail_attempts.get(ip, []))
def _record_fail2ban(ip):
now = _now()
if ip not in _fail_attempts:
_fail_attempts[ip] = []
_fail_attempts[ip].append(now)
_fail_attempts[ip] = [t for t in _fail_attempts[ip] if now - t < _BAN_WINDOW]
if len(_fail_attempts[ip]) >= _BAN_MAX:
_ban_until[ip] = now + _BAN_DURATION
log.warning("IP %s 登录失败 %d 次,已封禁 %d",
ip, _BAN_MAX, _BAN_DURATION)
def _reset_fail2ban(ip):
_fail_attempts.pop(ip, None)
_ban_until.pop(ip, None)
def _now():
import time
return time.time()
# ── 登录页 -----------------------------------------------------------------
def _render_login():
return """<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8">
+61
View File
@@ -4,6 +4,67 @@ from database import db
import bcrypt
# ── 后台管理员 ──────────────────────────────────────────────────
class AdminUser(db.Model):
"""Web 后台管理员账户(存数据库,支持多账户、角色权限)。"""
__tablename__ = "admin_users"
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(64), nullable=False, unique=True, index=True)
password = db.Column(db.String(128), nullable=False) # bcrypt 哈希
role = db.Column(db.String(16), default="viewer") # superadmin | admin | viewer
enabled = db.Column(db.Boolean, default=True)
last_login_at = db.Column(db.DateTime, nullable=True)
last_login_ip = db.Column(db.String(64), nullable=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc))
ROLE_LABELS = {
"superadmin": "超级管理员",
"admin": "管理员",
"viewer": "只读用户",
}
# 角色权限位(可扩展)
ROLE_PERMISSIONS = {
"superadmin": {"users:read", "users:write", "instances:read", "instances:write",
"system:read", "system:write", "admins:read", "admins:write",
"logs:read", "stats:read"},
"admin": {"users:read", "users:write", "instances:read", "instances:write",
"system:read", "system:write", "admins:read",
"logs:read", "stats:read"},
"viewer": {"users:read", "instances:read", "system:read",
"logs:read", "stats:read"},
}
def set_password(self, password):
self.password = bcrypt.hashpw(
password.encode("utf-8"), bcrypt.gensalt()
).decode("utf-8")
def check_password(self, password):
if not self.password:
return False
try:
return bcrypt.checkpw(
password.encode("utf-8"), self.password.encode("utf-8")
)
except Exception:
return False
@property
def role_label(self):
return self.ROLE_LABELS.get(self.role, self.role)
def has_permission(self, perm):
perms = self.ROLE_PERMISSIONS.get(self.role, set())
return perm in perms
def __repr__(self):
return f"<AdminUser {self.username} ({self.role})>"
# ── SOCKS5 实例 ──────────────────────────────────────────────────
class Instance(db.Model):
__tablename__ = "instances"
+203
View File
@@ -0,0 +1,203 @@
{% extends "base.html" %}
{% block title %}管理员账户 · SOCKS Manager{% endblock %}
{% block content %}
<div class="page-header">
<h5><i class="bi bi-person-gear"></i> 管理员账户</h5>
</div>
{% set can_write = current_admin and current_admin.has_permission('admins:write') %}
<!-- 新增管理员 -->
<div class="card mb-3">
<div class="card-header"><span><i class="bi bi-person-plus"></i> 新增管理员</span></div>
<div class="card-body">
<form method="post" action="/system/admins/add" class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label small">用户名</label>
<input name="username" class="form-control" placeholder="如: zhangsan" required>
</div>
<div class="col-md-3">
<label class="form-label small">密码</label>
<input name="password" type="password" class="form-control" placeholder="至少 4 位" required>
</div>
<div class="col-md-3">
<label class="form-label small">角色</label>
<select name="role" class="form-select">
{% for r, label in [('superadmin','超级管理员'), ('admin','管理员'), ('viewer','只读用户')] %}
<option value="{{ r }}">{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-3">
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-plus-lg"></i> 创建</button>
</div>
</form>
</div>
</div>
<!-- 角色说明 -->
<div class="card mb-3">
<div class="card-header"><span><i class="bi bi-info-circle"></i> 角色权限说明</span></div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<div class="card" style="background:var(--bg-tertiary)">
<div class="card-body py-2">
<span class="badge badge-danger">超级管理员</span>
<span class="text-muted small">全部权限,含管理员管理</span>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card" style="background:var(--bg-tertiary)">
<div class="card-body py-2">
<span class="badge badge-info">管理员</span>
<span class="text-muted small">用户/实例/系统管理,可查看管理员</span>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card" style="background:var(--bg-tertiary)">
<div class="card-body py-2">
<span class="badge badge-secondary">只读用户</span>
<span class="text-muted small">仅查看,不可修改</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 管理员列表 -->
<div class="card">
<div class="card-header"><span><i class="bi bi-people"></i> 账户列表</span></div>
<div class="card-body p-0">
<table class="table table-dark table-sm">
<thead><tr>
<th>用户名</th><th>角色</th><th>状态</th><th>最近登录</th><th>创建时间</th>
{% if can_write %}<th>操作</th>{% endif %}
</tr></thead>
<tbody>
{% for a in admins %}
<tr>
<td>
{{ a.username }}
{% if current_admin and current_admin.id == a.id %}
<span class="badge badge-info">当前</span>
{% endif %}
</td>
<td>
{% if can_write %}
<form method="post" action="/system/admins/{{ a.id }}/role" class="d-inline">
<select name="role" class="form-select d-inline-block" style="width:auto;padding:.15rem .4rem;font-size:12px"
onchange="this.form.submit()">
{% for r, label in [('superadmin','超级管理员'), ('admin','管理员'), ('viewer','只读用户')] %}
<option value="{{ r }}" {% if a.role == r %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</form>
{% else %}
<span class="badge {{ 'badge-danger' if a.role=='superadmin' else 'badge-info' if a.role=='admin' else 'badge-secondary' }}">{{ a.role_label }}</span>
{% endif %}
</td>
<td>
{% if a.enabled %}
<span class="badge badge-success">启用</span>
{% else %}
<span class="badge badge-danger">禁用</span>
{% endif %}
</td>
<td class="text-muted">
{% if a.last_login_at %}
{{ a.last_login_at.strftime('%Y-%m-%d %H:%M') }}<br>
<small>{{ a.last_login_ip or '-' }}</small>
{% else %}-{% endif %}
</td>
<td class="text-muted">{{ a.created_at.strftime('%Y-%m-%d') if a.created_at else '-' }}</td>
{% if can_write %}
<td>
{% if not (current_admin and current_admin.id == a.id) %}
<form method="post" action="/system/admins/{{ a.id }}/toggle" class="d-inline">
<button class="btn btn-sm {{ 'btn-outline-warning' if a.enabled else 'btn-outline-success' }}"
title="{{ '禁用' if a.enabled else '启用' }}">
<i class="bi {{ 'bi-pause-circle' if a.enabled else 'bi-play-circle' }}"></i></button>
</form>
<!-- 重置密码(模态框) -->
<button class="btn btn-sm btn-outline-primary" onclick="openPwdModal({{ a.id }}, '{{ a.username }}')">
<i class="bi bi-key"></i> 重置密码</button>
<form method="post" action="/system/admins/{{ a.id }}/delete" class="d-inline"
onsubmit="return confirm('确认删除管理员 {{ a.username }}')">
<button class="btn btn-sm btn-outline-danger"><i class="bi bi-trash"></i></button>
</form>
{% else %}
<span class="text-muted small"></span>
{% endif %}
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- 修改当前密码 -->
<div class="card mt-3">
<div class="card-header"><span><i class="bi bi-shield-check"></i> 修改我的密码</span></div>
<div class="card-body">
<form method="post" action="/system/change-password" class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label small">原密码</label>
<input name="old_password" type="password" class="form-control" required>
</div>
<div class="col-md-3">
<label class="form-label small">新密码</label>
<input name="new_password" type="password" class="form-control" required>
</div>
<div class="col-md-3">
<label class="form-label small">确认新密码</label>
<input name="confirm_password" type="password" class="form-control" required>
</div>
<div class="col-md-3">
<button type="submit" class="btn btn-outline-primary w-100">
<i class="bi bi-check-lg"></i> 修改密码</button>
</div>
</form>
</div>
</div>
<!-- 重置密码模态框 -->
<div class="modal fade" id="pwdModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<form method="post" action="" id="pwdForm">
<div class="modal-header">
<h5 class="modal-title">重置密码</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p class="text-muted small" id="pwdTarget"></p>
<label class="form-label small">新密码(至少 4 位)</label>
<input name="password" type="password" class="form-control" required>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">取消</button>
<button type="submit" class="btn btn-primary">确认重置</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_script %}
<script>
function openPwdModal(id, username) {
document.getElementById('pwdForm').action = '/system/admins/' + id + '/password';
document.getElementById('pwdTarget').textContent = '账户: ' + username;
new bootstrap.Modal(document.getElementById('pwdModal')).show();
}
</script>
{% endblock %}
+2
View File
@@ -217,6 +217,8 @@ canvas { max-width:100%; }
<div class="sidebar-section">系统</div>
<a class="nav-link {% if nav.system %}active{% endif %}" href="/system">
<i class="bi bi-shield-lock"></i> 系统管理</a>
<a class="nav-link {% if nav.admins %}active{% endif %}" href="/system/admins">
<i class="bi bi-person-gear"></i> 管理员账户</a>
</div>
</div>
+154 -3
View File
@@ -1,6 +1,6 @@
"""Web 管理面板路由。"""
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app
from auth import login_required
from auth import login_required, permission_required
from database import db
from models import Instance, User
from services import stats_service, backup_service
@@ -25,6 +25,8 @@ def inject_nav():
page_name = "活跃连接"
elif p.startswith("/logs"):
page_name = "审计日志"
elif p.startswith("/system/admins"):
page_name = "管理员账户"
elif p.startswith("/system"):
page_name = "系统管理"
else:
@@ -36,7 +38,8 @@ def inject_nav():
"users": p.startswith("/users"),
"stats": p.startswith("/stats"),
"logs": p.startswith("/logs"),
"system": p.startswith("/system"),
"system": p.startswith("/system") and not p.startswith("/system/admins"),
"admins": p.startswith("/system/admins"),
},
"page_name": page_name,
"logged_in": flask_session.get("logged_in", False),
@@ -307,18 +310,23 @@ def logs():
# ── 系统管理 ───────────────────────────────────────────────────
@bp.route("/system")
@login_required
@permission_required("system:read")
def system():
import platform, time
from auth import current_admin
backups = backup_service.list_backups()
admin = current_admin()
return render_template("system.html", backups=backups,
sys_version=platform.python_version(),
sys_platform=platform.platform(),
sys_hostname=platform.node(),
uptime=time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()))
uptime=time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
current_admin=admin)
@bp.route("/system/backup", methods=["POST"])
@login_required
@permission_required("system:write")
def create_backup():
backup_service.create_backup()
flash("备份已完成", "success")
@@ -327,6 +335,7 @@ def create_backup():
@bp.route("/system/restore/<int:bid>", methods=["POST"])
@login_required
@permission_required("system:write")
def restore_backup(bid):
result = backup_service.restore_backup(bid)
if "error" in result:
@@ -334,3 +343,145 @@ def restore_backup(bid):
else:
flash("恢复完成,请刷新页面", "success")
return redirect(url_for("web.system"))
# ── 管理员账户管理 ────────────────────────────────────────────────
@bp.route("/system/admins")
@login_required
@permission_required("admins:read")
def admins():
from models import AdminUser
from auth import current_admin
admin_list = AdminUser.query.order_by(AdminUser.id.asc()).all()
return render_template("admins.html", admins=admin_list,
current_admin=current_admin())
@bp.route("/system/admins/add", methods=["POST"])
@login_required
@permission_required("admins:write")
def add_admin():
from models import AdminUser
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
role = request.form.get("role", "viewer")
if not username or not password:
flash("用户名和密码不能为空", "danger")
return redirect(url_for("web.admins"))
if role not in AdminUser.ROLE_LABELS:
role = "viewer"
if AdminUser.query.filter_by(username=username).first():
flash("用户名已存在", "danger")
return redirect(url_for("web.admins"))
if len(password) < 4:
flash("密码至少 4 位", "danger")
return redirect(url_for("web.admins"))
admin = AdminUser()
admin.username = username
admin.role = role
admin.enabled = True
admin.set_password(password)
db.session.add(admin)
db.session.commit()
flash(f"已创建管理员 {username}", "success")
return redirect(url_for("web.admins"))
@bp.route("/system/admins/<int:aid>/toggle", methods=["POST"])
@login_required
@permission_required("admins:write")
def toggle_admin(aid):
from models import AdminUser
from auth import current_admin
admin = AdminUser.query.get_or_404(aid)
# 不能禁用自己
curr = current_admin()
if curr and curr.id == admin.id:
flash("不能禁用当前登录账户", "danger")
return redirect(url_for("web.admins"))
admin.enabled = not admin.enabled
db.session.commit()
flash(f"{'已启用' if admin.enabled else '已禁用'} {admin.username}", "info")
return redirect(url_for("web.admins"))
@bp.route("/system/admins/<int:aid>/role", methods=["POST"])
@login_required
@permission_required("admins:write")
def change_admin_role(aid):
from models import AdminUser
admin = AdminUser.query.get_or_404(aid)
role = request.form.get("role", "viewer")
if role not in AdminUser.ROLE_LABELS:
flash("无效的角色", "danger")
return redirect(url_for("web.admins"))
admin.role = role
db.session.commit()
flash(f"{admin.username} 角色已更新为 {admin.role_label}", "success")
return redirect(url_for("web.admins"))
@bp.route("/system/admins/<int:aid>/password", methods=["POST"])
@login_required
@permission_required("admins:write")
def reset_admin_password(aid):
from models import AdminUser
password = request.form.get("password", "")
if not password or len(password) < 4:
flash("密码至少 4 位", "danger")
return redirect(url_for("web.admins"))
admin = AdminUser.query.get_or_404(aid)
admin.set_password(password)
db.session.commit()
flash(f"{admin.username} 密码已重置", "success")
return redirect(url_for("web.admins"))
@bp.route("/system/admins/<int:aid>/delete", methods=["POST"])
@login_required
@permission_required("admins:write")
def delete_admin(aid):
from models import AdminUser
from auth import current_admin
admin = AdminUser.query.get_or_404(aid)
curr = current_admin()
if curr and curr.id == admin.id:
flash("不能删除当前登录账户", "danger")
return redirect(url_for("web.admins"))
# 至少保留一个 superadmin
if admin.role == "superadmin":
super_count = AdminUser.query.filter_by(role="superadmin", enabled=True).count()
if super_count <= 1:
flash("至少需要保留一个启用的超级管理员", "danger")
return redirect(url_for("web.admins"))
db.session.delete(admin)
db.session.commit()
flash(f"已删除管理员 {admin.username}", "warning")
return redirect(url_for("web.admins"))
@bp.route("/system/change-password", methods=["POST"])
@login_required
def change_my_password():
"""当前登录用户修改自己的密码。"""
from models import AdminUser
from auth import current_admin
old_pwd = request.form.get("old_password", "")
new_pwd = request.form.get("new_password", "")
confirm_pwd = request.form.get("confirm_password", "")
admin = current_admin()
if not admin:
return redirect(url_for("auth.logout"))
if not admin.check_password(old_pwd):
flash("原密码错误", "danger")
return redirect(url_for("web.system"))
if not new_pwd or len(new_pwd) < 4:
flash("新密码至少 4 位", "danger")
return redirect(url_for("web.system"))
if new_pwd != confirm_pwd:
flash("两次输入的新密码不一致", "danger")
return redirect(url_for("web.system"))
admin.set_password(new_pwd)
db.session.commit()
flash("密码修改成功", "success")
return redirect(url_for("web.system"))