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
216 lines
7.5 KiB
Python
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
|