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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user