Files
socks-manager/auth.py
T
Your Name 9890cfc488 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
2026-09-09 14:33:59 +08:00

254 lines
8.9 KiB
Python

"""登录认证 + 权限系统。
v2 版本:后台管理员账户存在数据库(AdminUser 表),支持多账户、角色权限。
首次启动时,如果 AdminUser 表为空,会从 SM_ADMIN_USER / SM_ADMIN_PASSWORD
环境变量(或 .env)创建初始超级管理员。
"""
import os
import logging
import secrets
from datetime import datetime, timezone
from functools import wraps
from flask import Blueprint, request, session, redirect, url_for, current_app, abort
log = logging.getLogger("socks.auth")
bp = Blueprint("auth", __name__)
# ── 工具函数 ----------------------------------------------------------------
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):
try:
for line in open(_ENV):
line = line.strip()
if line and not line.startswith("#"):
k, _, v = line.partition("=")
if k.strip() == "SM_ADMIN_PASSWORD":
pwd = v.strip()
elif k.strip() == "SM_ADMIN_USER":
user = v.strip()
except Exception:
pass
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():
"""兼容旧版启动流程(run.py 调用)。
现在做的是:确保有初始管理员存在。
"""
admin = seed_initial_admin()
if admin:
return (admin.username, "(见数据库)")
# 至少返回一个用户名用于显示
u, p = _read_env_creds()
return (u, p or "")
# ── 当前登录用户 ------------------------------------------------------------
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") or not session.get("admin_id"):
if request.path.startswith("/api/"):
return {"error": "unauthorized"}, 401
return redirect(url_for("auth.login", next=request.url))
return f(*args, **kwargs)
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") and session.get("admin_id"):
return redirect(url_for("web.index"))
if request.method == "POST":
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"
_fail2ban_record(client_ip, check_only=True)
if _is_banned(client_ip):
log.warning("防爆破已阻断来自 %s 的登录尝试", client_ip)
return {"error": "登录过于频繁,请稍后再试"}, 429
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["admin_id"] = admin.id
session["user"] = admin.username
session["role"] = admin.role
return redirect(request.args.get("next") or url_for("web.index"))
# 登录失败
_record_fail2ban(client_ip)
return {"error": "用户名或密码错误"}, 401
return _render_login()
@bp.route("/logout")
def logout():
session.clear()
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">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>登录 · SOCKS Manager</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body{background:#0b0e14;color:#e2e8f0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif}
.card{background:#161922;border:1px solid #374151;border-radius:.75rem}
.form-control{background:#1a1d2b;border:1px solid #4b5563;color:#e2e8f0;border-radius:.5rem}
.form-control:focus{border-color:#818cf8;box-shadow:0 0 0 3px rgba(129,140,248,.15);background:#1a1d2b;color:#e2e8f0}
.btn-primary{background:#818cf8;border-color:#818cf8;border-radius:.5rem}
.btn-primary:hover{background:#6366f1;border-color:#6366f1}
h4{color:#818cf8;font-weight:700}
.form-label{color:#cbd5e0 !important;font-size:13px;font-weight:500}
.card .text-muted{color:#9ca3af !important}
</style></head>
<body class="d-flex align-items-center justify-content-center" style="min-height:100vh">
<div class="card shadow-lg p-5" style="width:380px">
<h4 class="text-center mb-2">SOCKS Manager</h4>
<p class="text-center text-muted small mb-4">SOCKS5 代理管理平台</p>
<form method="post">
<div class="mb-3"><label class="form-label small">用户名</label>
<input name="username" class="form-control" value="admin" autofocus required></div>
<div class="mb-4"><label class="form-label small">密码</label>
<input name="password" type="password" class="form-control" required></div>
<button type="submit" class="btn btn-primary w-100">登 录</button>
</form>
</div></body></html>"""