Files
update-server/app/core/security.py
T

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