diff --git a/README.md b/README.md index ae5633d..3d41fcc 100644 --- a/README.md +++ b/README.md @@ -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/ # 更新 {role?, password?, enabled?} +DELETE /api/admins/ # 删除(带保护校验) +``` + ## 环境变量 | 变量 | 默认值 | 说明 | diff --git a/api/v1.py b/api/v1.py index 07a2cec..345d346 100644 --- a/api/v1.py +++ b/api/v1.py @@ -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/", 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/", 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)) diff --git a/app.py b/app.py index 45b8a80..8e44765 100644 --- a/app.py +++ b/app.py @@ -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 diff --git a/auth.py b/auth.py index 089ef19..b83cbf1 100644 --- a/auth.py +++ b/auth.py @@ -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 """ diff --git a/models.py b/models.py index 7d02bda..940b832 100644 --- a/models.py +++ b/models.py @@ -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"" + + # ── SOCKS5 实例 ────────────────────────────────────────────────── class Instance(db.Model): __tablename__ = "instances" diff --git a/templates/admins.html b/templates/admins.html new file mode 100644 index 0000000..6293352 --- /dev/null +++ b/templates/admins.html @@ -0,0 +1,203 @@ +{% extends "base.html" %} +{% block title %}管理员账户 · SOCKS Manager{% endblock %} + +{% block content %} + + +{% set can_write = current_admin and current_admin.has_permission('admins:write') %} + + +
+
新增管理员
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+ + +
+
角色权限说明
+
+
+
+
+
+ 超级管理员 + 全部权限,含管理员管理 +
+
+
+
+
+
+ 管理员 + 用户/实例/系统管理,可查看管理员 +
+
+
+
+
+
+ 只读用户 + 仅查看,不可修改 +
+
+
+
+
+
+ + +
+
账户列表
+
+ + + + {% if can_write %}{% endif %} + + + {% for a in admins %} + + + + + + + {% if can_write %} + + {% endif %} + + {% endfor %} + +
用户名角色状态最近登录创建时间操作
+ {{ a.username }} + {% if current_admin and current_admin.id == a.id %} + 当前 + {% endif %} + + {% if can_write %} +
+ +
+ {% else %} + {{ a.role_label }} + {% endif %} +
+ {% if a.enabled %} + 启用 + {% else %} + 禁用 + {% endif %} + + {% if a.last_login_at %} + {{ a.last_login_at.strftime('%Y-%m-%d %H:%M') }}
+ {{ a.last_login_ip or '-' }} + {% else %}-{% endif %} +
{{ a.created_at.strftime('%Y-%m-%d') if a.created_at else '-' }} + {% if not (current_admin and current_admin.id == a.id) %} +
+ +
+ + +
+ +
+ {% else %} + + {% endif %} +
+
+
+ + +
+
修改我的密码
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+ + + +{% endblock %} + +{% block extra_script %} + +{% endblock %} diff --git a/templates/base.html b/templates/base.html index 350d971..4e6bf8f 100644 --- a/templates/base.html +++ b/templates/base.html @@ -217,6 +217,8 @@ canvas { max-width:100%; } 系统管理 + + 管理员账户 diff --git a/web/routes.py b/web/routes.py index a3bd7e4..788674a 100644 --- a/web/routes.py +++ b/web/routes.py @@ -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/", 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//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//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//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//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"))