Files
agent-desktop/src/pineagents/app/auth.py
T

852 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""Authentication module: password hashing, JWT tokens, and FastAPI middleware.
Login is disabled by default and only enabled when the environment
variable ``QWENPAW_AUTH_ENABLED`` is set to a truthy value (``true``,
``1``, ``yes``). Credentials are created through a web-based
registration flow rather than environment variables, so that agents
running inside the process cannot read plaintext passwords.
Single-user design: only one account can be registered. If the user
forgets their password, delete ``auth.json`` from ``SECRET_DIR`` and
restart the service to re-register.
Uses only Python stdlib (hashlib, hmac, secrets) to avoid adding new
dependencies. The password is stored as a salted SHA-256 hash in
``auth.json`` under ``SECRET_DIR``.
"""
from __future__ import annotations
import hashlib
import hmac
import ipaddress
import json
import logging
import os
import re
import secrets
import time
from typing import Optional
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from ..constant import SECRET_DIR, EnvVarLoader
from ..security.secret_store import (
AUTH_SECRET_FIELDS,
decrypt_dict_fields,
encrypt_dict_fields,
is_encrypted,
)
logger = logging.getLogger(__name__)
AUTH_FILE = SECRET_DIR / "auth.json"
# Token validity: 7 days (default)
TOKEN_EXPIRY_SECONDS = 7 * 24 * 3600
# Maximum token validity: 100 years (for "permanent" tokens)
TOKEN_EXPIRY_MAX = 100 * 365 * 24 * 3600
# Paths that do NOT require authentication
_PUBLIC_PATHS: frozenset[str] = frozenset(
{
"/api/auth/login",
"/api/auth/status",
"/api/auth/register",
"/api/desktop/shutdown",
"/api/version",
"/api/settings/language",
"/api/settings/upload-limit",
"/api/frontend_plugin",
},
)
# Prefixes that do NOT require authentication (static assets)
# /api/frontend_plugin/ is safe: only read-only GET handlers are registered
# under that prefix (list + static file serving). All write operations
# remain under /api/plugins/ which requires authentication.
_PUBLIC_PREFIXES: tuple[str, ...] = (
"/assets/",
"/logo.png",
"/qwenpaw-symbol.svg",
"/api/frontend_plugin/",
)
# ---------------------------------------------------------------------------
# Helpers (reuse SECRET_DIR patterns from envs/store.py)
# ---------------------------------------------------------------------------
def _chmod_best_effort(path, mode: int) -> None:
try:
os.chmod(path, mode)
except OSError:
pass
def _prepare_secret_parent(path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
_chmod_best_effort(path.parent, 0o700)
# ---------------------------------------------------------------------------
# Password hashing (salted SHA-256, no external deps)
# ---------------------------------------------------------------------------
def _hash_password(
password: str,
salt: Optional[str] = None,
) -> tuple[str, str]:
"""Hash *password* with *salt*. Returns ``(hash_hex, salt_hex)``."""
if salt is None:
salt = secrets.token_hex(16)
h = hashlib.sha256((salt + password).encode("utf-8")).hexdigest()
return h, salt
def verify_password(password: str, stored_hash: str, salt: str) -> bool:
"""Verify *password* against a stored hash."""
h, _ = _hash_password(password, salt)
return hmac.compare_digest(h, stored_hash)
# ---------------------------------------------------------------------------
# Token generation / verification (HMAC-SHA256, no PyJWT needed)
# ---------------------------------------------------------------------------
def _get_jwt_secret() -> str:
"""Return the signing secret, creating one if absent."""
data = _load_auth_data()
secret = data.get("jwt_secret", "")
if not secret:
secret = secrets.token_hex(32)
data["jwt_secret"] = secret
_save_auth_data(data)
return secret
def create_token(username: str, expiry_seconds: Optional[int] = None) -> str:
"""Create an HMAC-signed token: ``base64(payload).signature``.
Args:
username: The username to encode in the token.
expiry_seconds: Custom expiry time in seconds.
Use -1 or 0 for permanent tokens.
Defaults to TOKEN_EXPIRY_SECONDS (7 days).
"""
import base64
if expiry_seconds is None:
expiry_seconds = TOKEN_EXPIRY_SECONDS
elif expiry_seconds <= 0:
# Permanent token: 100 years
expiry_seconds = TOKEN_EXPIRY_MAX
else:
# Cap at maximum allowed expiry
expiry_seconds = min(expiry_seconds, TOKEN_EXPIRY_MAX)
secret = _get_jwt_secret()
# Generate unique token ID (jti) for revocation support
token_id = secrets.token_hex(16)
payload = json.dumps(
{
"sub": username,
"exp": int(time.time()) + expiry_seconds,
"iat": int(time.time()),
"jti": token_id, # JWT ID for individual revocation
},
)
payload_b64 = base64.urlsafe_b64encode(payload.encode()).decode()
sig = hmac.new(
secret.encode(),
payload_b64.encode(),
hashlib.sha256,
).hexdigest()
return f"{payload_b64}.{sig}"
def verify_token(token: str) -> Optional[str]:
"""Verify *token*, return username if valid, ``None`` otherwise.
Also checks if the token has been revoked (appears in the revocation list).
性能优化:验证结果缓存 30 秒,避免每次请求都重复 HMAC 验证和磁盘 I/O。
"""
import base64
# 性能优化:验证结果缓存(内存,30 秒 TTL)
global _token_verify_cache
if "_token_verify_cache" not in globals():
_token_verify_cache = {}
now = time.time()
cached = _token_verify_cache.get(token)
if cached is not None and now < cached["expires_at"]:
return cached["result"]
try:
parts = token.split(".", 1)
if len(parts) != 2:
result = None
else:
payload_b64, sig = parts
secret = _get_jwt_secret()
expected_sig = hmac.new(
secret.encode(),
payload_b64.encode(),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(sig, expected_sig):
result = None
else:
payload = json.loads(base64.urlsafe_b64decode(payload_b64))
if payload.get("exp", 0) < now:
result = None
else:
# Check if token is revoked
jti = payload.get("jti")
if jti and _is_token_revoked(jti):
result = None
else:
result = payload.get("sub")
except (json.JSONDecodeError, KeyError, ValueError, TypeError) as exc:
logger.debug("Token verification failed: %s", exc)
result = None
# 写缓存(30 秒 TTL
_token_verify_cache[token] = {"result": result, "expires_at": now + 30}
# 限制缓存大小,避免内存泄漏
if len(_token_verify_cache) > 1000:
# 清理过期的缓存项
_token_verify_cache = {k: v for k, v in _token_verify_cache.items() if time.time() < v["expires_at"]}
return result
# ---------------------------------------------------------------------------
# OPC JWTPineAgentsServer 签发)验证
# ---------------------------------------------------------------------------
def _get_opc_jwt_secret() -> str:
"""PineAgentsServer 签发 OPC JWT 用的密钥(本应用镜像同一值)。
PineAgentsServer 的 ``config.JWT_SECRET`` 取 ``PINEAGENTS_DEMO_JWT_SECRET``
缺省为固定 dev 字符串。主后端校验 /console 的 OPC JWT 需共享该密钥。
"""
return os.environ.get(
"PINEAGENTS_DEMO_JWT_SECRET",
"dev-insecure-change-me-0123456789abcdef",
)
def decode_opc_jwt(token: str) -> Optional[dict]:
"""验签并解码 PineAgentsServer 签发的 OPC JWT(标准 HS256pyjwt 兼容)。
前端把 OPC JWT 作为 Bearer 发往 /console/*。验签成功返回 claims
(含 ``sub``=账号 user_id、``identity_id``),失败/过期返回 ``None``。
OPC JWT 不携带 ``port``——端口身份需结合 identity_id 在服务端解析。
"""
import base64
try:
parts = token.split(".")
if len(parts) != 3:
return None
signing_input = f"{parts[0]}.{parts[1]}".encode()
expected_sig = base64.urlsafe_b64encode(
hmac.new(
_get_opc_jwt_secret().encode(),
signing_input,
hashlib.sha256,
).digest()
).rstrip(b"=").decode()
if not hmac.compare_digest(parts[2], expected_sig):
return None
padded = parts[1] + "=" * (-len(parts[1]) % 4)
payload = json.loads(base64.urlsafe_b64decode(padded))
if payload.get("exp", 0) < time.time():
return None
return payload
except (json.JSONDecodeError, ValueError, TypeError, KeyError):
logger.debug("OPC JWT verification failed")
return None
# ---------------------------------------------------------------------------
# Auth data persistence (auth.json in SECRET_DIR)
# ---------------------------------------------------------------------------
def _load_auth_data() -> dict:
"""Load ``auth.json`` from ``SECRET_DIR``.
Returns the parsed dict, or a sentinel with ``_auth_load_error``
set to ``True`` when the file exists but cannot be read/parsed so
that callers can fail closed instead of silently bypassing auth.
Encrypted fields (``jwt_secret``) are transparently decrypted.
Legacy plaintext values trigger an automatic re-encryption.
"""
if AUTH_FILE.is_file():
try:
with open(AUTH_FILE, "r", encoding="utf-8") as fh:
data = json.load(fh)
needs_rewrite = any(
isinstance(data.get(field), str)
and data.get(field)
and not is_encrypted(data[field])
for field in AUTH_SECRET_FIELDS
)
data = decrypt_dict_fields(data, AUTH_SECRET_FIELDS)
if needs_rewrite:
try:
_save_auth_data(data)
except Exception as enc_err:
logger.debug(
"Deferred plaintext→encrypted migration for"
" auth.json: %s",
enc_err,
)
return data
except (json.JSONDecodeError, OSError) as exc:
logger.error("Failed to load auth file %s: %s", AUTH_FILE, exc)
return {"_auth_load_error": True}
return {}
def _save_auth_data(data: dict) -> None:
"""Save ``auth.json`` to ``SECRET_DIR`` with restrictive permissions.
Sensitive fields (``jwt_secret``) are encrypted before writing.
"""
_prepare_secret_parent(AUTH_FILE)
encrypted_data = encrypt_dict_fields(data, AUTH_SECRET_FIELDS)
with open(AUTH_FILE, "w", encoding="utf-8") as f:
json.dump(encrypted_data, f, indent=2, ensure_ascii=False)
_chmod_best_effort(AUTH_FILE, 0o600)
# ---------------------------------------------------------------------------
# Token revocation (blacklist management)
# ---------------------------------------------------------------------------
def _is_token_revoked(jti: str) -> bool:
"""Check if a token ID (jti) is in the revocation list.
Uses O(1) dict lookup via revoked_tokens_meta for performance.
"""
data = _load_auth_data()
meta = data.get("revoked_tokens_meta", {})
return jti in meta
def _add_to_revocation_list(jti: str, exp: int) -> None:
"""Add a token ID to the revocation list with its expiry time.
Uses revoked_tokens_meta dict for O(1) lookups. The revoked_tokens list
is kept for backwards compatibility but not used for membership checks.
"""
data = _load_auth_data()
if data.get("_auth_load_error"):
return
# Initialize revoked_tokens_meta if not present
if "revoked_tokens_meta" not in data:
data["revoked_tokens_meta"] = {}
# O(1) check using dict
if jti not in data["revoked_tokens_meta"]:
data["revoked_tokens_meta"][jti] = exp
# Also add to list for backwards compatibility
if "revoked_tokens" not in data:
data["revoked_tokens"] = []
data["revoked_tokens"].append(jti)
_save_auth_data(data)
def _clean_expired_revocations() -> None:
"""
Remove expired tokens from the revocation list to prevent unbounded growth.
"""
data = _load_auth_data()
if data.get("_auth_load_error"):
return
revoked = data.get("revoked_tokens", [])
meta = data.get("revoked_tokens_meta", {})
current_time = int(time.time())
# Remove expired tokens
cleaned_revoked = []
cleaned_meta = {}
for jti in revoked:
exp = meta.get(jti, 0)
if exp > current_time:
cleaned_revoked.append(jti)
cleaned_meta[jti] = exp
if len(cleaned_revoked) < len(revoked):
data["revoked_tokens"] = cleaned_revoked
data["revoked_tokens_meta"] = cleaned_meta
_save_auth_data(data)
logger.info(
"Cleaned %d expired tokens from revocation list",
len(revoked) - len(cleaned_revoked),
)
def is_auth_enabled() -> bool:
"""Check whether authentication is enabled via environment variable.
Returns ``True`` when ``QWENPAW_AUTH_ENABLED`` is set to a truthy
value (``true``, ``1``, ``yes``). The presence of a registered
user is checked separately by the middleware so that the first
user can still reach the registration page.
"""
env_flag = EnvVarLoader.get_str("QWENPAW_AUTH_ENABLED", "").strip().lower()
return env_flag in ("true", "1", "yes")
def has_registered_users() -> bool:
"""Return ``True`` if a user has been registered."""
data = _load_auth_data()
return bool(data.get("user"))
# ---------------------------------------------------------------------------
# Registration (single-user)
# ---------------------------------------------------------------------------
def register_user(
username: str,
password: str,
expiry_seconds: Optional[int] = None,
) -> Optional[str]:
"""Register the single user account.
Args:
username: The username to register.
password: The password to register.
expiry_seconds: Custom token expiry time in seconds.
Returns a token on success, ``None`` if a user already exists.
"""
data = _load_auth_data()
# Only one user allowed
if data.get("user"):
return None
pw_hash, salt = _hash_password(password)
data["user"] = {
"username": username,
"password_hash": pw_hash,
"password_salt": salt,
}
# Ensure jwt_secret exists
if not data.get("jwt_secret"):
data["jwt_secret"] = secrets.token_hex(32)
_save_auth_data(data)
logger.info("User '%s' registered", username)
return create_token(username, expiry_seconds)
def auto_register_from_env() -> None:
"""Auto-register admin user from environment variables.
Called once during application startup. If ``QWENPAW_AUTH_ENABLED``
is truthy and both ``QWENPAW_AUTH_USERNAME`` and ``QWENPAW_AUTH_PASSWORD``
are set, the admin account is created automatically — useful for
Docker, Kubernetes, server-panel, and other automated deployments
where interactive web registration is not practical.
Skips silently when:
- authentication is not enabled
- a user has already been registered
- either env var is missing or empty
"""
if not is_auth_enabled():
return
if has_registered_users():
return
username = EnvVarLoader.get_str("QWENPAW_AUTH_USERNAME", "").strip()
password = EnvVarLoader.get_str("QWENPAW_AUTH_PASSWORD", "").strip()
if not username or not password:
return
token = register_user(username, password)
if token:
logger.info(
"Auto-registered user '%s' from environment variables",
username,
)
def update_credentials(
current_password: str,
new_username: Optional[str] = None,
new_password: Optional[str] = None,
expiry_seconds: Optional[int] = None,
) -> Optional[str]:
"""Update the registered user's username and/or password.
Requires the current password for verification. Returns a new
token on success (because the username may have changed), or
``None`` if verification fails.
Args:
current_password: The current password for verification.
new_username: The new username (optional).
new_password: The new password (optional).
expiry_seconds: Custom token expiry time in seconds.
"""
data = _load_auth_data()
user = data.get("user")
if not user:
return None
stored_hash = user.get("password_hash", "")
stored_salt = user.get("password_salt", "")
if not verify_password(current_password, stored_hash, stored_salt):
return None
if new_username and new_username.strip():
user["username"] = new_username.strip()
if new_password:
pw_hash, salt = _hash_password(new_password)
user["password_hash"] = pw_hash
user["password_salt"] = salt
# Rotate JWT secret to invalidate all existing sessions
data["jwt_secret"] = secrets.token_hex(32)
data["user"] = user
_save_auth_data(data)
logger.info("Credentials updated for user '%s'", user["username"])
return create_token(user["username"], expiry_seconds)
# ---------------------------------------------------------------------------
# Authentication
# ---------------------------------------------------------------------------
def authenticate(
username: str,
password: str,
expiry_seconds: Optional[int] = None,
) -> Optional[str]:
"""Authenticate *username* / *password*. Returns a token if valid.
Args:
username: The username to authenticate.
password: The password to verify.
expiry_seconds: Custom token expiry time in seconds.
"""
data = _load_auth_data()
user = data.get("user")
if not user:
return None
if user.get("username") != username:
return None
stored_hash = user.get("password_hash", "")
stored_salt = user.get("password_salt", "")
if (
stored_hash
and stored_salt
and verify_password(password, stored_hash, stored_salt)
):
return create_token(username, expiry_seconds)
return None
def revoke_token(token: str) -> bool:
"""Revoke a single token by adding its jti to the blacklist.
Args:
token: The token string to revoke.
Returns True on success, False on failure.
"""
import base64
try:
# Extract jti and exp from token
parts = token.split(".", 1)
if len(parts) != 2:
return False
payload_b64 = parts[0]
payload = json.loads(base64.urlsafe_b64decode(payload_b64))
jti = payload.get("jti")
exp = payload.get("exp", 0)
if not jti:
logger.warning("Token has no jti, cannot revoke individually")
return False
_add_to_revocation_list(jti, exp)
logger.info("Token %s revoked", jti[:8])
# Clean up expired tokens periodically
_clean_expired_revocations()
return True
except Exception as exc:
logger.error("Failed to revoke token: %s", exc)
return False
def revoke_all_tokens() -> bool:
"""Revoke all existing tokens by rotating the JWT secret.
This will invalidate all tokens that were issued before this call.
Also clears the revocation list since all tokens are invalid anyway.
Returns True on success, False on failure.
"""
try:
data = _load_auth_data()
if data.get("_auth_load_error"):
return False
# Rotate JWT secret to invalidate all existing tokens
data["jwt_secret"] = secrets.token_hex(32)
# Clear revocation list since all tokens are now invalid
data["revoked_tokens"] = []
data["revoked_tokens_meta"] = {}
_save_auth_data(data)
logger.info("All tokens revoked (JWT secret rotated)")
return True
except Exception as exc:
logger.error("Failed to revoke tokens: %s", exc)
return False
# ---------------------------------------------------------------------------
# FastAPI middleware — client IP resolution with trusted proxy verification
# ---------------------------------------------------------------------------
_LOOPBACK = frozenset({"127.0.0.1", "::1"})
_BRACKETED = re.compile(r"^\[([^\]]+)\](?::\d+)?$")
_V4_PORT = re.compile(r"^(\d{1,3}(?:\.\d{1,3}){3}):\d+$")
_MAX_WARN_IPS = 1024
_warned_untrusted_ips: set[str] = set()
def _normalize_ip(raw: str) -> str | None:
"""Strip brackets, port, zone-id and validate. None on failure."""
if not raw:
return None
s = raw.strip()
m = _BRACKETED.match(s) or _V4_PORT.match(s)
if m:
s = m.group(1)
if "%" in s:
s = s.split("%", 1)[0]
try:
return str(ipaddress.ip_address(s))
except ValueError:
return None
def _parse_networks(entries: list[str]) -> list:
"""Parse CIDR/IP strings into network objects."""
nets = []
for entry in entries:
try:
nets.append(ipaddress.ip_network(entry, strict=False))
except ValueError:
continue
return nets
def _ip_in_networks(ip_str: str, networks: list) -> bool:
"""Check if a normalized IP string falls within any network."""
try:
addr = ipaddress.ip_address(ip_str)
except ValueError:
return False
for net in networks:
if addr.version == net.version and addr in net:
return True
return False
# Cached config for hot-path auth checks (avoids disk read per request)
_auth_config_cache: tuple = (0, None, [])
def _get_config_cached():
"""Return (config, trusted_networks) with mtime-based cache."""
global _auth_config_cache # noqa: PLW0603
from ..config import load_config
from ..config.utils import get_config_path
config_path = get_config_path()
try:
mtime_ns = config_path.stat().st_mtime_ns
except OSError:
mtime_ns = 0
if mtime_ns != _auth_config_cache[0] or _auth_config_cache[1] is None:
cfg = load_config()
nets = _parse_networks(cfg.security.trusted_proxies)
_auth_config_cache = (mtime_ns, cfg, nets)
return _auth_config_cache[1], _auth_config_cache[2]
def _resolve_client_ip(request: Request) -> str:
"""Return the real client IP.
Only trusts proxy headers when the direct TCP peer is in
trusted_proxies. XFF is parsed right-to-left, skipping
trusted IPs.
"""
direct_raw = request.client.host if request.client else ""
direct_ip = _normalize_ip(direct_raw) or direct_raw
_cfg, networks = _get_config_cached()
if not networks or not _ip_in_networks(direct_ip, networks):
# Log once per untrusted source to avoid flooding
has_proxy_hdr = request.headers.get(
"x-forwarded-for",
) or request.headers.get("x-real-ip")
if (
has_proxy_hdr
and direct_ip not in _warned_untrusted_ips
and len(_warned_untrusted_ips) < _MAX_WARN_IPS
):
_warned_untrusted_ips.add(direct_ip)
logger.warning(
"Ignoring proxy headers from untrusted source"
" %s (add to security.trusted_proxies if"
" legitimate)",
direct_ip,
)
return direct_ip
xff = request.headers.get("x-forwarded-for", "")
if xff:
for token in reversed(xff.split(",")):
norm = _normalize_ip(token)
if norm is None:
break
if not _ip_in_networks(norm, networks):
return norm
real_ip = _normalize_ip(
request.headers.get("x-real-ip", ""),
)
return real_ip or direct_ip
resolve_client_ip = _resolve_client_ip
class AuthMiddleware(BaseHTTPMiddleware):
"""Middleware that checks Bearer token on protected routes."""
async def dispatch(self, request: Request, call_next):
if self._should_skip_auth(request):
return await call_next(request)
token = self._extract_token(request)
if not token:
return Response(
content='{"detail":"Not authenticated"}',
status_code=401,
media_type="application/json",
)
user = verify_token(token)
if user is None:
return Response(
content='{"detail":"Invalid or expired token"}',
status_code=401,
media_type="application/json",
)
request.state.user = user
return await call_next(request)
@staticmethod
def _should_skip_auth( # pylint: disable=too-many-return-statements
request: Request,
) -> bool:
if not is_auth_enabled() or not has_registered_users():
return True
path = request.url.path
if (
request.method == "OPTIONS"
or path in _PUBLIC_PATHS
or any(path.startswith(p) for p in _PUBLIC_PREFIXES)
or not path.startswith("/api/")
):
return True
cfg, _ = _get_config_cached()
allowed = cfg.security.allow_no_auth_hosts
client_ip = resolve_client_ip(request)
norm = _normalize_ip(client_ip) or client_ip
if norm not in allowed:
return False
# Defense-in-depth: loopback whitelist requires
# direct TCP peer also be loopback.
if norm in _LOOPBACK:
peer = _normalize_ip(
request.client.host if request.client else "",
)
if peer not in _LOOPBACK:
logger.warning(
"Auth skip blocked: client_ip=%s but"
" direct peer %s is not loopback",
norm,
peer,
)
return False
return True
@staticmethod
def _extract_token(request: Request) -> Optional[str]:
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
return auth[7:]
conn = request.headers.get("connection", "")
if "upgrade" in conn.lower():
return request.query_params.get("token")
return request.query_params.get("token") or None
def check_proxy_config_sanity() -> None:
"""Log a warning at startup if proxy config looks suspect."""
try:
cfg, _ = _get_config_cached()
except (OSError, ValueError):
return
sec = cfg.security
has_non_loopback = any(h not in _LOOPBACK for h in sec.allow_no_auth_hosts)
if has_non_loopback and not sec.trusted_proxies:
logger.warning(
"allow_no_auth_hosts contains non-loopback entries"
" but trusted_proxies is empty. If behind a reverse"
" proxy, add proxy IPs to"
" security.trusted_proxies.",
)