Files
wehub-resource-sync 555e282cc4
ci / changelog_check (push) Waiting to run
ci / check_changes (push) Waiting to run
ci / build_mem0 (3.10) (push) Blocked by required conditions
ci / build_mem0 (3.11) (push) Blocked by required conditions
ci / build_mem0 (3.12) (push) Blocked by required conditions
CLI Node CI / lint (push) Waiting to run
CLI Node CI / test (20) (push) Waiting to run
CLI Node CI / test (22) (push) Waiting to run
CLI Node CI / build (push) Waiting to run
CLI Python CI / lint (push) Waiting to run
CLI Python CI / test (3.10) (push) Waiting to run
CLI Python CI / test (3.11) (push) Waiting to run
CLI Python CI / test (3.12) (push) Waiting to run
CLI Python CI / build (push) Waiting to run
openclaw checks / lint (push) Waiting to run
openclaw checks / test (20) (push) Waiting to run
openclaw checks / test (22) (push) Waiting to run
openclaw checks / build (push) Waiting to run
opencode-plugin checks / build (push) Waiting to run
pi-agent-plugin checks / lint (push) Waiting to run
pi-agent-plugin checks / test (20) (push) Waiting to run
pi-agent-plugin checks / test (22) (push) Waiting to run
pi-agent-plugin checks / build (push) Waiting to run
TypeScript SDK CI / check_changes (push) Waiting to run
TypeScript SDK CI / changelog_check (push) Blocked by required conditions
TypeScript SDK CI / build_ts_sdk (20) (push) Blocked by required conditions
TypeScript SDK CI / build_ts_sdk (22) (push) Blocked by required conditions
TypeScript SDK CI / integration_ts_sdk (20) (push) Blocked by required conditions
TypeScript SDK CI / integration_ts_sdk (22) (push) Blocked by required conditions
chore: import upstream snapshot with attribution
2026-07-13 13:03:45 +08:00

216 lines
7.5 KiB
Python

import os
import secrets
import uuid
from datetime import datetime, timedelta, timezone
from db import get_db
from fastapi import Depends, HTTPException, Request
from fastapi.security import APIKeyHeader, HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from models import APIKey, RefreshTokenJti, User
from passlib.context import CryptContext
from sqlalchemy import select, update
from sqlalchemy.orm import Session
JWT_SECRET = os.environ.get("JWT_SECRET", "")
JWT_ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
REFRESH_TOKEN_EXPIRE_DAYS = 30
ADMIN_API_KEY = os.environ.get("ADMIN_API_KEY", "")
AUTH_DISABLED = os.environ.get("AUTH_DISABLED", "").lower() in {"1", "true", "yes", "on"}
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def dummy_verify_password() -> None:
"""Burn the same bcrypt cycles as a real verify so login timing doesn't leak whether an email exists."""
pwd_context.dummy_verify()
def generate_api_key() -> tuple[str, str, str]:
"""Returns (full_key, prefix, hash)."""
raw = secrets.token_urlsafe(32)
full_key = f"m0sk_{raw}"
prefix = full_key[:12]
key_hash = pwd_context.hash(full_key)
return full_key, prefix, key_hash
def verify_api_key_hash(plain_key: str, hashed: str) -> bool:
return pwd_context.verify(plain_key, hashed)
def _get_secret() -> str:
if not JWT_SECRET:
raise HTTPException(status_code=500, detail="JWT_SECRET is not configured.")
return JWT_SECRET
def create_access_token(user_id: str, role: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
payload = {"sub": user_id, "role": role, "exp": expire, "type": "access"}
return jwt.encode(payload, _get_secret(), algorithm=JWT_ALGORITHM)
def create_refresh_token(user_id: str, db: Session) -> str:
expire = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
jti = uuid.uuid4()
db.add(RefreshTokenJti(jti=jti, user_id=uuid.UUID(user_id), expires_at=expire))
db.commit()
payload = {"sub": user_id, "exp": expire, "jti": str(jti), "type": "refresh"}
return jwt.encode(payload, _get_secret(), algorithm=JWT_ALGORITHM)
def consume_refresh_jti(jti: str, db: Session) -> None:
"""Atomically mark a refresh token's jti as used. Raises 401 if missing, already used, or expired.
The conditional UPDATE closes the read-check-write race: concurrent replays of the same
token race on a single row, so at most one update affects a row and the rest see rowcount 0.
"""
try:
jti_uuid = uuid.UUID(jti)
except (TypeError, ValueError):
raise HTTPException(status_code=401, detail="Refresh token is no longer valid.")
now = datetime.now(timezone.utc)
result = db.execute(
update(RefreshTokenJti)
.where(
RefreshTokenJti.jti == jti_uuid,
RefreshTokenJti.used_at.is_(None),
RefreshTokenJti.expires_at > now,
)
.values(used_at=now)
)
if result.rowcount == 0:
raise HTTPException(status_code=401, detail="Refresh token is no longer valid.")
db.commit()
def decode_token(token: str) -> dict:
try:
return jwt.decode(token, _get_secret(), algorithms=[JWT_ALGORITHM])
except JWTError:
raise HTTPException(status_code=401, detail="Invalid or expired token.")
bearer_scheme = HTTPBearer(auto_error=False)
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
def _mark_auth_type(request: Request, auth_type: str) -> None:
request.state.auth_type = auth_type
def _get_default_user(db: Session) -> User | None:
return db.scalar(select(User).order_by(User.created_at.asc()))
def _resolve_user_from_jwt(token: str, db: Session) -> User:
payload = decode_token(token)
if payload.get("type") != "access":
raise HTTPException(status_code=401, detail="Invalid token type.")
user = db.get(User, payload.get("sub"))
if user is None:
raise HTTPException(status_code=401, detail="User not found.")
return user
def _resolve_user_from_api_key(key: str, db: Session) -> User:
prefix = key[:12] if len(key) >= 12 else key
candidates = (
db.execute(select(APIKey).where(APIKey.key_prefix == prefix, APIKey.revoked_at.is_(None))).scalars().all()
)
for candidate in candidates:
if verify_api_key_hash(key, candidate.key_hash):
candidate.last_used_at = datetime.now(timezone.utc)
db.commit()
user = db.get(User, candidate.created_by)
if user is None:
raise HTTPException(status_code=401, detail="API key owner not found.")
return user
raise HTTPException(status_code=401, detail="Invalid API key.")
async def verify_auth(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
x_api_key: str | None = Depends(api_key_header),
db: Session = Depends(get_db),
) -> User | None:
"""Authenticate via JWT, X-API-Key, or legacy ADMIN_API_KEY. Returns User or None."""
if credentials is not None:
_mark_auth_type(request, "bearer")
return _resolve_user_from_jwt(credentials.credentials, db)
if x_api_key is not None:
if ADMIN_API_KEY and secrets.compare_digest(x_api_key, ADMIN_API_KEY):
_mark_auth_type(request, "admin_api_key")
return None
_mark_auth_type(request, "api_key")
return _resolve_user_from_api_key(x_api_key, db)
if AUTH_DISABLED:
_mark_auth_type(request, "disabled")
return None
raise HTTPException(
status_code=401,
detail="Authentication required. Provide a Bearer token or X-API-Key header.",
headers={"WWW-Authenticate": "Bearer"},
)
async def require_auth(
request: Request,
user: User | None = Depends(verify_auth),
db: Session = Depends(get_db),
) -> User:
"""Like verify_auth but guarantees a non-None User. Use for endpoints that require auth."""
if user is None:
if getattr(request.state, "auth_type", "none") in {"admin_api_key", "disabled"}:
default_user = _get_default_user(db)
if default_user is not None:
return default_user
raise HTTPException(status_code=401, detail="Authentication required.")
return user
_BOOTSTRAP_ADMIN = User(
id=uuid.UUID(int=0), name="admin_api_key", email="", password_hash="", role="admin", created_at=datetime.min.replace(tzinfo=timezone.utc),
)
async def require_admin(
request: Request,
user: User | None = Depends(verify_auth),
db: Session = Depends(get_db),
) -> User:
"""Like require_auth but also enforces admin role.
ADMIN_API_KEY and AUTH_DISABLED callers are treated as admin even when
the users table is empty (fresh-deploy bootstrap).
"""
auth_type = getattr(request.state, "auth_type", "none")
if user is None:
if auth_type in {"admin_api_key", "disabled"}:
default_user = _get_default_user(db)
if default_user is not None:
if default_user.role != "admin":
raise HTTPException(status_code=403, detail="Admin role required.")
return default_user
return _BOOTSTRAP_ADMIN
raise HTTPException(status_code=401, detail="Authentication required.")
if user.role != "admin":
raise HTTPException(status_code=403, detail="Admin role required.")
return user