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 筛选,分页查询 | | 详细审计日志 | 按事件/用户/IP 筛选,分页查询 |
| 级联代理 | 连接建立时自动代理至目标(可扩展 upstream) | | 级联代理 | 连接建立时自动代理至目标(可扩展 upstream) |
| 负载均衡 | 多实例配置后按端口轮询 | | 负载均衡 | 多实例配置后按端口轮询 |
| 管理员认证 | 环境变量 `SM_ADMIN_PASSWORD` 保护 | | 管理员认证 | 多管理员账户存数据库(`AdminUser` 表,bcrypt 哈希),支持角色权限 |
| 角色权限 | superadmin / admin / viewer 三级角色,细粒度权限位控制 |
| 管理员管理 | Web/API 增删改、启停、改角色、重置密码(带保护校验) |
| 防爆破 | 自动阻断频繁登录尝试(Fail2Ban 机制) | | 防爆破 | 自动阻断频繁登录尝试(Fail2Ban 机制) |
| 密码安全 | bcrypt 哈希存储 SOCKS5 用户密码(兼容旧明文自动迁移) | | 密码安全 | bcrypt 哈希存储 SOCKS5 用户密码(兼容旧明文自动迁移) |
| 会话管理 | 自定义 Cookie 域名/安全/名称,支持 HTTPS 部署 | | 会话管理 | 自定义 Cookie 域名/安全/名称,支持 HTTPS 部署 |
@@ -66,6 +68,35 @@ python3 run.py
> ⚠️ 首次启动时如果 `SM_ADMIN_PASSWORD` 未设置,会自动写入默认密码 `admin123` 到 `.env` 文件。**生产环境请务必修改此密码。** > ⚠️ 首次启动时如果 `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 — 全部路由。""" """REST API v1 — 全部路由。"""
import os import os
from flask import Blueprint, request, jsonify, current_app 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 database import db
from models import Instance, User, AuditLog from models import Instance, User, AuditLog
from services import user_service, stats_service, backup_service 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 keep = request.json.get("keep", 10) if request.is_json else 10
backup_service.cleanup_old_backups(keep=int(keep)) backup_service.cleanup_old_backups(keep=int(keep))
return jsonify({"ok": True}) 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(): def create_app():
# 确保密码已设置(首次启动自动设置默认密码)
from auth import get_or_set_password
get_or_set_password()
app = Flask(__name__) app = Flask(__name__)
app.config.from_object(Config) app.config.from_object(Config)
app.config["SECRET_KEY"] = os.environ.get( app.config["SECRET_KEY"] = os.environ.get(
@@ -100,9 +96,12 @@ def create_app():
db.init_app(app) db.init_app(app)
app.db = db app.db = db
# 全局服务注册 # 建表 + 种子数据(必须在 app_context 中)
with app.app_context(): with app.app_context():
db.create_all() db.create_all()
# 种子初始管理员
from auth import seed_initial_admin
seed_initial_admin()
# 初始化全局 db.app # 初始化全局 db.app
from database import db as _db from database import db as _db
_db.app = app _db.app = app
+160 -41
View File
@@ -1,20 +1,29 @@
"""登录认证""" """登录认证 + 权限系统。
v2 版本:后台管理员账户存在数据库(AdminUser 表),支持多账户、角色权限。
首次启动时,如果 AdminUser 表为空,会从 SM_ADMIN_USER / SM_ADMIN_PASSWORD
环境变量(或 .env)创建初始超级管理员。
"""
import os import os
import hashlib
import logging import logging
import secrets import secrets
from datetime import datetime, timezone
from functools import wraps 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") log = logging.getLogger("socks.auth")
bp = Blueprint("auth", __name__) 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") pwd = os.environ.get("SM_ADMIN_PASSWORD")
user = os.environ.get("SM_ADMIN_USER", "admin") 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: try:
for line in open(_ENV): for line in open(_ENV):
line = line.strip() line = line.strip()
@@ -29,37 +38,62 @@ def _get_admin_creds():
return (user, pwd or "") 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(): def get_or_set_password():
"""启动时检查密码,如果为空则设置默认值并打印提示。""" """兼容旧版启动流程(run.py 调用)。
import os 现在做的是:确保有初始管理员存在。
u, p = _get_admin_creds() """
if not p or not p.strip(): admin = seed_initial_admin()
default_pwd = "admin123" if admin:
os.environ["SM_ADMIN_PASSWORD"] = default_pwd return (admin.username, "(见数据库)")
os.environ["SM_ADMIN_USER"] = u # 至少返回一个用户名用于显示
_ENV = os.environ.get("SM_ENV_PATH", os.path.join(os.path.dirname(__file__), ".env")) u, p = _read_env_creds()
try: return (u, p or "")
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)
def get_admin_password_hash(): # ── 当前登录用户 ------------------------------------------------------------
"""获取管理员密码哈希(用于安全比较)。"""
_, p = _get_admin_creds() def current_admin():
return hashlib.sha256(p.encode("utf-8")).hexdigest() """返回当前登录的 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): def login_required(f):
@wraps(f) @wraps(f)
def decorated(*args, **kwargs): 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/"): if request.path.startswith("/api/"):
return {"error": "unauthorized"}, 401 return {"error": "unauthorized"}, 401
return redirect(url_for("auth.login", next=request.url)) return redirect(url_for("auth.login", next=request.url))
@@ -67,32 +101,66 @@ def login_required(f):
return decorated 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"]) @bp.route("/login", methods=["GET", "POST"])
def login(): def login():
if session.get("logged_in"): if session.get("logged_in") and session.get("admin_id"):
return redirect(url_for("web.index")) return redirect(url_for("web.index"))
if request.method == "POST": 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_user = request.form.get("username", "").strip()
form_pwd = request.form.get("password", "") form_pwd = request.form.get("password", "")
# 防爆破检查 # 防爆破
client_ip = request.remote_addr or "unknown" client_ip = request.remote_addr or "unknown"
from services.user_service import UserService _fail2ban_record(client_ip, check_only=True)
us = UserService() if _is_banned(client_ip):
blocked, remaining = us.check_fail2ban(client_ip)
if blocked:
log.warning("防爆破已阻断来自 %s 的登录尝试", client_ip) log.warning("防爆破已阻断来自 %s 的登录尝试", client_ip)
return {"error": "登录过于频繁,请稍后再试"}, 429 return {"error": "登录过于频繁,请稍后再试"}, 429
# 使用常量时间比较防止时序攻击 admin = AdminUser.query.filter_by(username=form_user).first()
if form_user == u and secrets.compare_digest(form_pwd, p): if admin and admin.enabled and admin.check_password(form_pwd):
us.record_auth_attempt(client_ip, success=True) # 登录成功
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.permanent = True
session["logged_in"] = 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")) 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 {"error": "用户名或密码错误"}, 401
return _render_login() return _render_login()
@@ -103,6 +171,57 @@ def logout():
return redirect(url_for("auth.login")) 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(): def _render_login():
return """<!doctype html> return """<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"> <html lang="zh-CN"><head><meta charset="utf-8">
+61
View File
@@ -4,6 +4,67 @@ from database import db
import bcrypt 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 实例 ────────────────────────────────────────────────── # ── SOCKS5 实例 ──────────────────────────────────────────────────
class Instance(db.Model): class Instance(db.Model):
__tablename__ = "instances" __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> <div class="sidebar-section">系统</div>
<a class="nav-link {% if nav.system %}active{% endif %}" href="/system"> <a class="nav-link {% if nav.system %}active{% endif %}" href="/system">
<i class="bi bi-shield-lock"></i> 系统管理</a> <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>
</div> </div>
+154 -3
View File
@@ -1,6 +1,6 @@
"""Web 管理面板路由。""" """Web 管理面板路由。"""
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app 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 database import db
from models import Instance, User from models import Instance, User
from services import stats_service, backup_service from services import stats_service, backup_service
@@ -25,6 +25,8 @@ def inject_nav():
page_name = "活跃连接" page_name = "活跃连接"
elif p.startswith("/logs"): elif p.startswith("/logs"):
page_name = "审计日志" page_name = "审计日志"
elif p.startswith("/system/admins"):
page_name = "管理员账户"
elif p.startswith("/system"): elif p.startswith("/system"):
page_name = "系统管理" page_name = "系统管理"
else: else:
@@ -36,7 +38,8 @@ def inject_nav():
"users": p.startswith("/users"), "users": p.startswith("/users"),
"stats": p.startswith("/stats"), "stats": p.startswith("/stats"),
"logs": p.startswith("/logs"), "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, "page_name": page_name,
"logged_in": flask_session.get("logged_in", False), "logged_in": flask_session.get("logged_in", False),
@@ -307,18 +310,23 @@ def logs():
# ── 系统管理 ─────────────────────────────────────────────────── # ── 系统管理 ───────────────────────────────────────────────────
@bp.route("/system") @bp.route("/system")
@login_required @login_required
@permission_required("system:read")
def system(): def system():
import platform, time import platform, time
from auth import current_admin
backups = backup_service.list_backups() backups = backup_service.list_backups()
admin = current_admin()
return render_template("system.html", backups=backups, return render_template("system.html", backups=backups,
sys_version=platform.python_version(), sys_version=platform.python_version(),
sys_platform=platform.platform(), sys_platform=platform.platform(),
sys_hostname=platform.node(), 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"]) @bp.route("/system/backup", methods=["POST"])
@login_required @login_required
@permission_required("system:write")
def create_backup(): def create_backup():
backup_service.create_backup() backup_service.create_backup()
flash("备份已完成", "success") flash("备份已完成", "success")
@@ -327,6 +335,7 @@ def create_backup():
@bp.route("/system/restore/<int:bid>", methods=["POST"]) @bp.route("/system/restore/<int:bid>", methods=["POST"])
@login_required @login_required
@permission_required("system:write")
def restore_backup(bid): def restore_backup(bid):
result = backup_service.restore_backup(bid) result = backup_service.restore_backup(bid)
if "error" in result: if "error" in result:
@@ -334,3 +343,145 @@ def restore_backup(bid):
else: else:
flash("恢复完成,请刷新页面", "success") flash("恢复完成,请刷新页面", "success")
return redirect(url_for("web.system")) 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"))