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 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 current_admin( request: Request, Authorization: str = Header(""), ) -> AdminPrincipal: authorization = Authorization if isinstance(Authorization, 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 raise HTTPException(status_code=401, detail="请先登录管理后台") 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