343 lines
11 KiB
Python
343 lines
11 KiB
Python
import hashlib
|
|
import json
|
|
import os
|
|
import secrets
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
from typing import Callable
|
|
|
|
import jwt
|
|
from argon2 import PasswordHasher
|
|
from argon2.exceptions import VerifyMismatchError, VerificationError
|
|
from fastapi import Depends, Header, HTTPException, Request
|
|
|
|
from app.core.config import ENV_FILE_PATH, settings
|
|
from app.repositories import admin_user_repository
|
|
|
|
|
|
if not settings.admin_token:
|
|
raise RuntimeError("请在环境变量或 .env 中配置 ADMIN_TOKEN")
|
|
|
|
JWT_SECRET = os.getenv("ADMIN_JWT_SECRET", "").strip() or settings.admin_token
|
|
JWT_ALGORITHM = "HS256"
|
|
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ADMIN_ACCESS_TOKEN_EXPIRE_MIN", "120"))
|
|
REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv("ADMIN_REFRESH_TOKEN_EXPIRE_DAYS", "7"))
|
|
PASSWORD_HASHER = PasswordHasher()
|
|
|
|
|
|
ROLE_PERMISSIONS = {
|
|
"super_admin": ["*:*:*"],
|
|
"release_admin": [
|
|
"admin:access",
|
|
"app:manage",
|
|
"channel:manage",
|
|
"version:manage",
|
|
"publish:manage",
|
|
"policy:manage",
|
|
"device:view",
|
|
"log:view",
|
|
"config:view",
|
|
],
|
|
"license_admin": [
|
|
"admin:access",
|
|
"license:manage",
|
|
"device:view",
|
|
"config:view",
|
|
"log:view",
|
|
],
|
|
"auditor": [
|
|
"admin:access",
|
|
"app:view",
|
|
"version:view",
|
|
"device:view",
|
|
"license:view",
|
|
"log:view",
|
|
"crash:view",
|
|
"config:view",
|
|
],
|
|
"crash_admin": [
|
|
"admin:access",
|
|
"crash:view",
|
|
"log:view",
|
|
],
|
|
}
|
|
|
|
ROLE_NAMES = {
|
|
"super_admin": "超级管理员",
|
|
"release_admin": "发布管理员",
|
|
"license_admin": "授权管理员",
|
|
"auditor": "审计人员",
|
|
"crash_admin": "崩溃报告管理员",
|
|
}
|
|
|
|
|
|
def token_digest(token: str) -> str:
|
|
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def utc_now() -> datetime:
|
|
return datetime.now(timezone.utc).replace(microsecond=0)
|
|
|
|
|
|
def utc_text(value: datetime) -> str:
|
|
return value.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
|
|
|
|
def parse_utc_text(value: str) -> datetime:
|
|
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
if parsed.tzinfo is None:
|
|
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
return parsed.astimezone(timezone.utc)
|
|
|
|
|
|
@dataclass
|
|
class AdminPrincipal:
|
|
username: str
|
|
display_name: str
|
|
roles: list[str]
|
|
permissions: list[str]
|
|
auth_type: str
|
|
|
|
def has_permission(self, permission: str) -> bool:
|
|
return permission_allowed(self.permissions, permission)
|
|
|
|
@property
|
|
def actor_hash(self) -> str:
|
|
return token_digest(self.username)[:16]
|
|
|
|
|
|
def role_permissions(roles: list[str]) -> list[str]:
|
|
permissions: list[str] = []
|
|
for role in roles:
|
|
permissions.extend(ROLE_PERMISSIONS.get(role, []))
|
|
seen = set()
|
|
result = []
|
|
for item in permissions:
|
|
if item not in seen:
|
|
seen.add(item)
|
|
result.append(item)
|
|
return result
|
|
|
|
|
|
def permission_allowed(permissions: list[str], permission: str) -> bool:
|
|
if "*:*:*" in permissions or permission in permissions:
|
|
return True
|
|
parts = permission.split(":")
|
|
if len(parts) >= 2 and f"{parts[0]}:*" in permissions:
|
|
return True
|
|
if len(parts) == 2 and parts[1] == "view" and f"{parts[0]}:manage" in permissions:
|
|
return True
|
|
return False
|
|
|
|
|
|
def hash_password(password: str) -> str:
|
|
return PASSWORD_HASHER.hash(password)
|
|
|
|
|
|
def verify_password(password: str, password_hash: str) -> bool:
|
|
try:
|
|
return PASSWORD_HASHER.verify(password_hash, password)
|
|
except (VerifyMismatchError, VerificationError, ValueError):
|
|
return False
|
|
|
|
|
|
def normalize_roles(raw_roles: str | list[str] | None) -> list[str]:
|
|
if isinstance(raw_roles, list):
|
|
roles = [str(item).strip() for item in raw_roles]
|
|
else:
|
|
try:
|
|
parsed = json.loads(raw_roles or "[]")
|
|
except json.JSONDecodeError:
|
|
parsed = []
|
|
roles = [str(item).strip() for item in parsed if str(item).strip()]
|
|
return roles or ["super_admin"]
|
|
|
|
|
|
def principal_from_user_row(row, auth_type: str = "jwt") -> AdminPrincipal:
|
|
roles = normalize_roles(row["roles"])
|
|
return AdminPrincipal(
|
|
username=row["username"],
|
|
display_name=row["display_name"] or row["username"],
|
|
roles=roles,
|
|
permissions=role_permissions(roles),
|
|
auth_type=auth_type,
|
|
)
|
|
|
|
|
|
def legacy_admin_principal() -> AdminPrincipal:
|
|
return AdminPrincipal(
|
|
username="legacy-admin-token",
|
|
display_name="兼容管理员令牌",
|
|
roles=["super_admin"],
|
|
permissions=role_permissions(["super_admin"]),
|
|
auth_type="legacy_token",
|
|
)
|
|
|
|
|
|
def create_jwt_token(username: str, token_type: str, expires_delta: timedelta, roles: list[str] | None = None) -> tuple[str, str, datetime]:
|
|
now = utc_now()
|
|
expires_at = now + expires_delta
|
|
token_id = secrets.token_urlsafe(24)
|
|
payload = {
|
|
"sub": username,
|
|
"typ": token_type,
|
|
"jti": token_id,
|
|
"iat": int(now.timestamp()),
|
|
"exp": int(expires_at.timestamp()),
|
|
}
|
|
if roles is not None:
|
|
payload["roles"] = roles
|
|
token = jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
|
|
return token, token_id, expires_at
|
|
|
|
|
|
def decode_jwt_token(token: str, expected_type: str) -> dict:
|
|
try:
|
|
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
|
|
except jwt.ExpiredSignatureError:
|
|
raise HTTPException(status_code=401, detail="登录已过期,请重新登录")
|
|
except jwt.InvalidTokenError:
|
|
raise HTTPException(status_code=401, detail="无效登录凭证")
|
|
if payload.get("typ") != expected_type:
|
|
raise HTTPException(status_code=401, detail="登录凭证类型错误")
|
|
return payload
|
|
|
|
|
|
def token_response_for_user(row, request: Request | None = None) -> dict:
|
|
principal = principal_from_user_row(row)
|
|
access_token, _, access_expires = create_jwt_token(
|
|
principal.username,
|
|
"access",
|
|
timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
|
|
principal.roles,
|
|
)
|
|
refresh_token, refresh_id, refresh_expires = create_jwt_token(
|
|
principal.username,
|
|
"refresh",
|
|
timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS),
|
|
)
|
|
admin_user_repository.save_refresh_token(
|
|
refresh_id,
|
|
principal.username,
|
|
token_digest(refresh_token),
|
|
utc_text(refresh_expires),
|
|
request.headers.get("user-agent", "")[:300] if request else "",
|
|
request.client.host if request and request.client else "",
|
|
)
|
|
return {
|
|
"success": True,
|
|
"data": {
|
|
"avatar": "",
|
|
"username": principal.username,
|
|
"nickname": principal.display_name,
|
|
"roles": principal.roles,
|
|
"permissions": principal.permissions,
|
|
"accessToken": access_token,
|
|
"refreshToken": refresh_token,
|
|
"expires": utc_text(access_expires),
|
|
},
|
|
}
|
|
|
|
|
|
def quote_env_value(value: str) -> str:
|
|
unsafe_chars = set(" \t\n\r#\"'")
|
|
if value and not any(ch in unsafe_chars for ch in value):
|
|
return value
|
|
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
|
|
|
|
def persist_env_value(path: Path, key: str, value: str) -> bool:
|
|
line = f"{key}={quote_env_value(value)}\n"
|
|
if path.exists():
|
|
lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
|
|
replaced = False
|
|
for index, item in enumerate(lines):
|
|
stripped = item.lstrip()
|
|
if stripped.startswith("#") or "=" not in stripped:
|
|
continue
|
|
name = stripped.split("=", 1)[0].strip()
|
|
if name == key:
|
|
lines[index] = line
|
|
replaced = True
|
|
break
|
|
if not replaced:
|
|
if lines and not lines[-1].endswith(("\n", "\r")):
|
|
lines[-1] += "\n"
|
|
lines.append(line)
|
|
path.write_text("".join(lines), encoding="utf-8")
|
|
return True
|
|
path.write_text(line, encoding="utf-8")
|
|
return True
|
|
|
|
|
|
def legacy_admin_auth(X_Admin_Token: str = Header("")):
|
|
# Backward-compatible token-only auth for old scripts.
|
|
token = X_Admin_Token if isinstance(X_Admin_Token, str) else ""
|
|
if token and secrets.compare_digest(token, settings.admin_token):
|
|
return legacy_admin_principal()
|
|
raise HTTPException(status_code=403, detail="后台密钥错误,禁止访问")
|
|
|
|
|
|
def current_admin(
|
|
request: Request,
|
|
Authorization: str = Header(""),
|
|
X_Admin_Token: str = Header(""),
|
|
) -> AdminPrincipal:
|
|
authorization = Authorization if isinstance(Authorization, str) else ""
|
|
admin_token = X_Admin_Token if isinstance(X_Admin_Token, str) else ""
|
|
if authorization.lower().startswith("bearer "):
|
|
payload = decode_jwt_token(authorization[7:].strip(), "access")
|
|
username = str(payload.get("sub") or "")
|
|
row = admin_user_repository.get_user(username)
|
|
if not row or row["status"] != "active":
|
|
raise HTTPException(status_code=401, detail="管理员账号不存在或已禁用")
|
|
principal = principal_from_user_row(row)
|
|
request.state.admin_principal = principal
|
|
return principal
|
|
principal = legacy_admin_auth(admin_token)
|
|
request.state.admin_principal = principal
|
|
return principal
|
|
|
|
|
|
admin_auth = current_admin
|
|
|
|
|
|
def require_permission(permission: str) -> Callable:
|
|
def permission_dependency(principal: AdminPrincipal = Depends(current_admin)):
|
|
if not principal.has_permission(permission):
|
|
raise HTTPException(status_code=403, detail=f"缺少权限:{permission}")
|
|
return principal
|
|
|
|
return permission_dependency
|
|
|
|
|
|
def ensure_default_admin_user():
|
|
username = os.getenv("ADMIN_USERNAME", "admin").strip() or "admin"
|
|
password = os.getenv("ADMIN_PASSWORD", "").strip() or settings.admin_token
|
|
display_name = os.getenv("ADMIN_DISPLAY_NAME", "超级管理员").strip() or username
|
|
if not admin_user_repository.has_any_user():
|
|
admin_user_repository.create_user(
|
|
username,
|
|
hash_password(password),
|
|
display_name,
|
|
json.dumps(["super_admin"], ensure_ascii=False),
|
|
"active",
|
|
)
|
|
print(f"已初始化默认管理员账号:{username}")
|
|
return
|
|
row = admin_user_repository.get_user(username)
|
|
if not row:
|
|
return
|
|
if row["status"] != "active":
|
|
admin_user_repository.set_user_status(username, "active")
|
|
|
|
|
|
ADMIN_TOKEN = settings.admin_token
|
|
|
|
|
|
def set_admin_token(new_token: str):
|
|
global ADMIN_TOKEN
|
|
settings.admin_token = new_token
|
|
ADMIN_TOKEN = new_token
|