Files

245 lines
9.0 KiB
Python

import hashlib
import secrets
import time
from fastapi import Depends, HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from passlib.context import CryptContext
from app import database
from app.config import get_settings
from app.models import ApiToken, Setting, User
pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
_bearer = HTTPBearer(auto_error=False)
_secret: str | None = None
TOKEN_DAYS = 30
def secret_key() -> str:
global _secret
if _secret:
return _secret
if get_settings().secret_key:
_secret = get_settings().secret_key
return _secret
with database.session() as db:
row = db.get(Setting, "secret_key")
if row is None:
row = Setting(key="secret_key", value=secrets.token_urlsafe(48))
db.add(row)
db.commit()
_secret = row.value
return _secret
def ensure_admin():
"""设置了 ADMIN_PASSWORD 且还没有账号时,创建 admin。"""
pw = get_settings().admin_password
with database.session() as db:
if pw and db.query(User).count() == 0:
db.add(User(username="admin", password_hash=pwd.hash(pw), role="admin", created=int(time.time())))
db.commit()
print("[auth] 已按 ADMIN_PASSWORD 创建管理员 admin")
def make_token(username: str, version: int | None = None) -> str:
return jwt.encode({"sub": username, "ver": version or 0,
"exp": int(time.time()) + TOKEN_DAYS * 86400}, secret_key(), "HS256")
# 普通用户(role=user)只读:除了改自己的密码,所有写操作都要管理员;这些读接口也只给管理员
USER_WRITABLE = {"/api/auth/password"}
ADMIN_ONLY_GET = ("/api/settings", "/api/audit/notify", "/api/audit/oplog", "/api/auth/users", "/api/auth/tokens", "/api/compliance")
API_PREFIX = "rdp_" # 只读 API 令牌的前缀;登录令牌是 JWT(eyJ 开头),两者不会混淆
_api_cache: dict[str, tuple[str | None, float]] = {} # 令牌哈希 -> (名称,不存在为 None, 缓存时间)
_api_used: dict[int, float] = {} # 令牌 id -> 上次写 last_used 的时间(5 分钟写一次)
def new_api_token() -> tuple[str, str]:
"""(令牌原文, SHA-256)"""
t = API_PREFIX + secrets.token_urlsafe(32)
return t, hashlib.sha256(t.encode()).hexdigest()
def forget_api_tokens():
_api_cache.clear()
def _api_token_name(token: str) -> str | None:
h = hashlib.sha256(token.encode()).hexdigest()
hit = _api_cache.get(h)
if hit and time.time() - hit[1] < 30:
return hit[0]
with database.session() as db:
row = db.query(ApiToken).filter(ApiToken.token_hash == h).first()
name = row.name if row else None
if row and time.time() - _api_used.get(row.id, 0) > 300:
_api_used[row.id] = time.time()
row.last_used = int(time.time())
db.commit()
if len(_api_cache) > 1000:
_api_cache.clear()
_api_cache[h] = (name, time.time())
return name
def _read_only(request: Request):
path = request.url.path
if request.method not in ("GET", "HEAD") and path not in USER_WRITABLE:
raise HTTPException(403, "只有管理员可以修改")
if request.method in ("GET", "HEAD") and path.startswith(ADMIN_ONLY_GET):
raise HTTPException(403, "只有管理员可以查看")
_roles: dict[str, tuple[str | None, int, float]] = {} # username -> (role,账号不存在时为 None, token_version, 缓存时间)
def user_info(username: str) -> tuple[str, int] | None:
"""账号的 (角色, token 版本);账号不存在返回 None。缓存 30 秒,改账号后调用 forget_roles()"""
hit = _roles.get(username)
if hit and time.time() - hit[2] < 30:
return (hit[0], hit[1]) if hit[0] is not None else None
with database.session() as db:
u = db.query(User).filter(User.username == username).first()
if u is None:
_roles[username] = (None, 0, time.time())
return None
_roles[username] = ((u.role or "admin"), u.token_version or 0, time.time())
return _roles[username][0], _roles[username][1]
def role_of(username: str) -> str | None:
info = user_info(username)
return info[0] if info else None
def forget_roles():
_roles.clear()
def current_user(request: Request, cred: HTTPAuthorizationCredentials | None = Depends(_bearer)) -> str:
if cred is None:
raise HTTPException(401, "未登录")
if cred.credentials.startswith(API_PREFIX):
# 只读 API 令牌:等同只读用户,连改自己密码都不行
name = _api_token_name(cred.credentials)
if name is None:
raise HTTPException(401, "API 令牌无效或已被删除")
request.state.role = "user"
if request.method not in ("GET", "HEAD"):
raise HTTPException(403, "API 令牌只能读取数据")
_read_only(request)
return "api:" + name
try:
data = jwt.decode(cred.credentials, secret_key(), algorithms=["HS256"])
except JWTError:
raise HTTPException(401, "登录已过期,请重新登录")
user = data.get("sub")
if not user:
raise HTTPException(401, "登录已过期,请重新登录")
info = user_info(user)
if info is None:
raise HTTPException(401, "账号不存在或已被删除,请重新登录")
role, ver = info
if data.get("ver", 0) != ver:
# 改密码 / 管理员改权限后版本号会 +1,旧 token 立即失效
raise HTTPException(401, "登录状态已失效(密码或权限已变更),请重新登录")
request.state.role = role
if role != "admin":
_read_only(request)
return user
def require_admin(request: Request, user: str = Depends(current_user)) -> str:
if getattr(request.state, "role", None) != "admin":
raise HTTPException(403, "只有管理员可以操作")
return user
# ---------------- 登录限速:同一 IP + 用户名连续失败 5 次后锁定 15 分钟 ----------------
MAX_LOGIN_FAILS = 5
LOGIN_LOCK_SECONDS = 900
_login_fails: dict[tuple[str, str], list] = {} # (ip, username) -> [连续失败次数, 锁定到]
# 不看来源地址、只按用户名的总失败数:攻击者换 IP 池爆破同一个账号时兜底
MAX_USER_FAILS = 20
_user_fails: dict[str, list] = {} # username -> [失败次数, 锁定到]
def _fail_key(request: Request, username: str) -> tuple[str, str]:
from app.oplog import client_ip
return client_ip(request), username
def _trim(d: dict):
"""满了淘汰最早的一批(以前整表清空,攻击者用 1 万个假用户名就能把目标账号的锁定全部重置)"""
if len(d) > 10_000:
for k in list(d)[:1000]:
d.pop(k, None)
def check_login_lock(request: Request, username: str):
now = time.time()
u = _user_fails.get(username)
if u and u[0] >= MAX_USER_FAILS:
if now < u[1]:
raise HTTPException(429, f"这个账号失败次数过多,请 {max(int((u[1] - now) / 60), 1)} 分钟后再试")
_user_fails.pop(username, None)
key = _fail_key(request, username)
e = _login_fails.get(key)
if not e:
return
if e[0] >= MAX_LOGIN_FAILS:
if now < e[1]:
raise HTTPException(429, f"失败次数过多,请 {max(int((e[1] - now) / 60), 1)} 分钟后再试")
_login_fails.pop(key, None) # 锁定期已过,重新计数
def note_login_fail(request: Request, username: str):
_trim(_login_fails)
_trim(_user_fails)
u = _user_fails.get(username) or [0, 0.0]
u[0] += 1
if u[0] >= MAX_USER_FAILS:
u[1] = time.time() + LOGIN_LOCK_SECONDS
_user_fails[username] = u
key = _fail_key(request, username)
e = _login_fails.get(key) or [0, 0.0]
if e[0] >= MAX_LOGIN_FAILS and time.time() >= e[1]:
e = [0, 0.0]
e[0] += 1
if e[0] >= MAX_LOGIN_FAILS:
e[1] = time.time() + LOGIN_LOCK_SECONDS
_login_fails[key] = e
def note_login_ok(request: Request, username: str):
_login_fails.pop(_fail_key(request, username), None)
_user_fails.pop(username, None)
# ---------------- 首次设置的安装码 ----------------
# 安装向导和创建管理员的接口在设置完成前不需要登录:谁先打开网页谁就能把实例接管。内网访问照旧不受影响;
# 从公网地址访问(例如已经通过反向代理暴露出去)时,要填启动日志里打印的一次性安装码
_setup_code = secrets.token_hex(4)
def setup_code() -> str:
return _setup_code
def check_setup_code(request: Request):
import hmac
import ipaddress
from app.oplog import client_ip
ip = client_ip(request)
try:
a = ipaddress.ip_address(ip)
if a.is_private or a.is_loopback or a.is_link_local:
return
except ValueError:
return # 测试客户端等没有真实地址的情况
code = (request.headers.get("x-setup-code") or "").strip().lower()
if not hmac.compare_digest(code, _setup_code):
raise HTTPException(403, "setup_code_required")