Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9890cfc488 |
@@ -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> # 删除(带保护校验)
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user