- Migrated from HIVE branding to WHOOSH across all components - Enhanced backend API with new services: AI models, BZZZ integration, templates, members - Added comprehensive testing suite with security, performance, and integration tests - Improved frontend with new components for project setup, AI models, and team management - Updated MCP server implementation with WHOOSH-specific tools and resources - Enhanced deployment configurations with production-ready Docker setups - Added comprehensive documentation and setup guides - Implemented age encryption service and UCXL integration 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
493 lines
14 KiB
Python
493 lines
14 KiB
Python
"""
|
|
Authentication API endpoints for WHOOSH platform.
|
|
Handles user registration, login, token refresh, and API key management.
|
|
"""
|
|
|
|
from datetime import datetime, timedelta
|
|
from typing import List, Optional, Dict, Any
|
|
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
|
from fastapi.security import OAuth2PasswordRequestForm
|
|
from sqlalchemy.orm import Session
|
|
from pydantic import BaseModel, EmailStr
|
|
|
|
from app.core.database import get_db
|
|
from app.core.security import TokenManager, APIKeyManager, create_token_response, verify_password
|
|
from app.core.auth_deps import (
|
|
get_current_user_context,
|
|
get_current_active_user,
|
|
get_current_superuser,
|
|
require_admin
|
|
)
|
|
from app.models.user import User
|
|
from app.models.auth import APIKey, RefreshToken, TokenBlacklist, API_SCOPES, DEFAULT_API_SCOPES
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# Pydantic models for request/response
|
|
class UserCreate(BaseModel):
|
|
username: str
|
|
email: EmailStr
|
|
password: str
|
|
full_name: Optional[str] = None
|
|
|
|
|
|
class UserResponse(BaseModel):
|
|
id: str
|
|
username: str
|
|
email: str
|
|
full_name: Optional[str]
|
|
is_active: bool
|
|
is_superuser: bool
|
|
is_verified: bool
|
|
created_at: str
|
|
last_login: Optional[str]
|
|
|
|
|
|
class TokenResponse(BaseModel):
|
|
access_token: str
|
|
refresh_token: str
|
|
token_type: str
|
|
expires_in: int
|
|
user: UserResponse
|
|
|
|
|
|
class RefreshTokenRequest(BaseModel):
|
|
refresh_token: str
|
|
|
|
|
|
class APIKeyCreate(BaseModel):
|
|
name: str
|
|
scopes: Optional[List[str]] = None
|
|
expires_at: Optional[datetime] = None
|
|
|
|
|
|
class APIKeyResponse(BaseModel):
|
|
id: str
|
|
name: str
|
|
key_prefix: str
|
|
scopes: List[str]
|
|
is_active: bool
|
|
last_used: Optional[str]
|
|
usage_count: int
|
|
expires_at: Optional[str]
|
|
created_at: str
|
|
|
|
|
|
class APIKeyCreateResponse(APIKeyResponse):
|
|
api_key: str # Only returned once during creation
|
|
|
|
|
|
class PasswordChange(BaseModel):
|
|
current_password: str
|
|
new_password: str
|
|
|
|
|
|
class ScopeInfo(BaseModel):
|
|
scope: str
|
|
description: str
|
|
|
|
|
|
# Authentication endpoints
|
|
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
|
async def register_user(
|
|
user_data: UserCreate,
|
|
db: Session = Depends(get_db),
|
|
current_user: Dict[str, Any] = Depends(get_current_superuser) # Only admins can create users
|
|
):
|
|
"""Register a new user (admin only)."""
|
|
# Check if username or email already exists
|
|
existing_user = db.query(User).filter(
|
|
(User.username == user_data.username) | (User.email == user_data.email)
|
|
).first()
|
|
|
|
if existing_user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Username or email already registered"
|
|
)
|
|
|
|
# Create new user
|
|
user = User(
|
|
username=user_data.username,
|
|
email=user_data.email,
|
|
full_name=user_data.full_name,
|
|
hashed_password=User.hash_password(user_data.password),
|
|
is_active=True,
|
|
is_verified=True # Auto-verify admin-created users
|
|
)
|
|
|
|
db.add(user)
|
|
db.commit()
|
|
db.refresh(user)
|
|
|
|
return UserResponse(**user.to_dict())
|
|
|
|
|
|
@router.post("/login", response_model=TokenResponse)
|
|
async def login(
|
|
request: Request,
|
|
form_data: OAuth2PasswordRequestForm = Depends(),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Authenticate user and return JWT tokens."""
|
|
# Find user by username
|
|
user = db.query(User).filter(User.username == form_data.username).first()
|
|
|
|
if not user or not user.verify_password(form_data.password):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Incorrect username or password",
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
)
|
|
|
|
if not user.is_active:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Inactive user"
|
|
)
|
|
|
|
# Update last login
|
|
user.update_last_login()
|
|
db.commit()
|
|
|
|
# Create token response
|
|
user_data = user.to_dict()
|
|
user_data["scopes"] = ["admin"] if user.is_superuser else []
|
|
|
|
token_response = create_token_response(user.id, user_data)
|
|
|
|
# Create UserResponse object for proper serialization
|
|
user_response = UserResponse(
|
|
id=user_data["id"],
|
|
username=user_data["username"],
|
|
email=user_data["email"],
|
|
full_name=user_data["full_name"],
|
|
is_active=user_data["is_active"],
|
|
is_superuser=user_data["is_superuser"],
|
|
is_verified=user_data["is_verified"],
|
|
created_at=user_data["created_at"],
|
|
last_login=user_data["last_login"]
|
|
)
|
|
|
|
# Create final response manually to avoid datetime serialization issues
|
|
final_response = TokenResponse(
|
|
access_token=token_response["access_token"],
|
|
refresh_token=token_response["refresh_token"],
|
|
token_type=token_response["token_type"],
|
|
expires_in=token_response["expires_in"],
|
|
user=user_response
|
|
)
|
|
|
|
# Store refresh token in database
|
|
refresh_token_plain = token_response["refresh_token"]
|
|
refresh_token_hash = User.hash_password(refresh_token_plain)
|
|
|
|
# Get device info
|
|
device_info = {
|
|
"user_agent": request.headers.get("user-agent", ""),
|
|
"ip": request.client.host if request.client else None,
|
|
}
|
|
|
|
# Create refresh token record
|
|
refresh_token_record = RefreshToken(
|
|
user_id=user.id,
|
|
token_hash=refresh_token_hash,
|
|
jti=TokenManager.get_token_claims(refresh_token_plain).get("jti"),
|
|
device_info=str(device_info),
|
|
expires_at=datetime.utcnow() + timedelta(days=30)
|
|
)
|
|
|
|
db.add(refresh_token_record)
|
|
db.commit()
|
|
|
|
return final_response
|
|
|
|
|
|
@router.post("/refresh", response_model=TokenResponse)
|
|
async def refresh_token(
|
|
refresh_request: RefreshTokenRequest,
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Refresh access token using refresh token."""
|
|
try:
|
|
# Verify refresh token
|
|
payload = TokenManager.verify_token(refresh_request.refresh_token)
|
|
|
|
if payload.get("type") != "refresh":
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid token type"
|
|
)
|
|
|
|
user_id = payload.get("sub")
|
|
jti = payload.get("jti")
|
|
|
|
# Check if refresh token exists and is valid
|
|
refresh_token_record = db.query(RefreshToken).filter(
|
|
RefreshToken.jti == jti,
|
|
RefreshToken.user_id == user_id,
|
|
RefreshToken.is_active == True
|
|
).first()
|
|
|
|
if not refresh_token_record or not refresh_token_record.is_valid():
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid or expired refresh token"
|
|
)
|
|
|
|
# Get user
|
|
user = db.query(User).filter(User.id == user_id).first()
|
|
if not user or not user.is_active:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="User not found or inactive"
|
|
)
|
|
|
|
# Update refresh token usage
|
|
refresh_token_record.record_usage()
|
|
db.commit()
|
|
|
|
# Create new token response
|
|
user_data = user.to_dict()
|
|
user_data["scopes"] = ["admin"] if user.is_superuser else []
|
|
|
|
token_response = create_token_response(user.id, user_data)
|
|
|
|
# Create UserResponse object for proper serialization
|
|
user_response = UserResponse(
|
|
id=user_data["id"],
|
|
username=user_data["username"],
|
|
email=user_data["email"],
|
|
full_name=user_data["full_name"],
|
|
is_active=user_data["is_active"],
|
|
is_superuser=user_data["is_superuser"],
|
|
is_verified=user_data["is_verified"],
|
|
created_at=user_data["created_at"],
|
|
last_login=user_data["last_login"]
|
|
)
|
|
|
|
return TokenResponse(
|
|
access_token=token_response["access_token"],
|
|
refresh_token=token_response["refresh_token"],
|
|
token_type=token_response["token_type"],
|
|
expires_in=token_response["expires_in"],
|
|
user=user_response
|
|
)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Could not validate refresh token"
|
|
)
|
|
|
|
|
|
@router.post("/logout")
|
|
async def logout(
|
|
current_user: Dict[str, Any] = Depends(get_current_user_context),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Logout user and revoke current tokens."""
|
|
# Blacklist the current access token
|
|
if current_user.get("token_jti"):
|
|
TokenBlacklist.blacklist_token(
|
|
db,
|
|
current_user["token_jti"],
|
|
"access",
|
|
datetime.utcnow() + timedelta(hours=1) # Token would expire anyway
|
|
)
|
|
|
|
# Revoke all user's refresh tokens
|
|
refresh_tokens = db.query(RefreshToken).filter(
|
|
RefreshToken.user_id == current_user["user_id"],
|
|
RefreshToken.is_active == True
|
|
).all()
|
|
|
|
for token in refresh_tokens:
|
|
token.revoke()
|
|
|
|
db.commit()
|
|
|
|
return {"message": "Successfully logged out"}
|
|
|
|
|
|
# User management endpoints
|
|
@router.get("/me", response_model=UserResponse)
|
|
async def get_current_user_info(
|
|
current_user: Dict[str, Any] = Depends(get_current_active_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get current user information."""
|
|
user = db.query(User).filter(User.id == current_user["user_id"]).first()
|
|
return UserResponse(**user.to_dict())
|
|
|
|
|
|
@router.post("/change-password")
|
|
async def change_password(
|
|
password_data: PasswordChange,
|
|
current_user: Dict[str, Any] = Depends(get_current_active_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Change current user's password."""
|
|
user = db.query(User).filter(User.id == current_user["user_id"]).first()
|
|
|
|
if not user.verify_password(password_data.current_password):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Current password is incorrect"
|
|
)
|
|
|
|
user.set_password(password_data.new_password)
|
|
db.commit()
|
|
|
|
return {"message": "Password changed successfully"}
|
|
|
|
|
|
# API Key management endpoints
|
|
@router.get("/api-keys", response_model=List[APIKeyResponse])
|
|
async def list_api_keys(
|
|
current_user: Dict[str, Any] = Depends(get_current_active_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""List user's API keys."""
|
|
api_keys = db.query(APIKey).filter(APIKey.user_id == current_user["user_id"]).all()
|
|
return [APIKeyResponse(**key.to_dict()) for key in api_keys]
|
|
|
|
|
|
@router.post("/api-keys", response_model=APIKeyCreateResponse)
|
|
async def create_api_key(
|
|
key_data: APIKeyCreate,
|
|
current_user: Dict[str, Any] = Depends(get_current_active_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Create a new API key."""
|
|
# Generate API key
|
|
plain_key, hashed_key, prefix = APIKeyManager.generate_api_key()
|
|
|
|
# Set default scopes if none provided
|
|
scopes = key_data.scopes if key_data.scopes else DEFAULT_API_SCOPES
|
|
|
|
# Validate scopes
|
|
invalid_scopes = [scope for scope in scopes if scope not in API_SCOPES]
|
|
if invalid_scopes:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Invalid scopes: {', '.join(invalid_scopes)}"
|
|
)
|
|
|
|
# Create API key record
|
|
api_key = APIKey(
|
|
user_id=current_user["user_id"],
|
|
name=key_data.name,
|
|
key_hash=hashed_key,
|
|
key_prefix=prefix,
|
|
expires_at=key_data.expires_at
|
|
)
|
|
api_key.set_scopes(scopes)
|
|
|
|
db.add(api_key)
|
|
db.commit()
|
|
db.refresh(api_key)
|
|
|
|
# Return API key with the plain key (only time it's shown)
|
|
response_data = api_key.to_dict()
|
|
response_data["api_key"] = plain_key
|
|
|
|
return APIKeyCreateResponse(**response_data)
|
|
|
|
|
|
@router.delete("/api-keys/{key_id}")
|
|
async def delete_api_key(
|
|
key_id: int,
|
|
current_user: Dict[str, Any] = Depends(get_current_active_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Delete an API key."""
|
|
api_key = db.query(APIKey).filter(
|
|
APIKey.id == key_id,
|
|
APIKey.user_id == current_user["user_id"]
|
|
).first()
|
|
|
|
if not api_key:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="API key not found"
|
|
)
|
|
|
|
db.delete(api_key)
|
|
db.commit()
|
|
|
|
return {"message": "API key deleted successfully"}
|
|
|
|
|
|
@router.patch("/api-keys/{key_id}")
|
|
async def update_api_key(
|
|
key_id: int,
|
|
key_data: dict,
|
|
current_user: Dict[str, Any] = Depends(get_current_active_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Update an API key (name, scopes, active status)."""
|
|
api_key = db.query(APIKey).filter(
|
|
APIKey.id == key_id,
|
|
APIKey.user_id == current_user["user_id"]
|
|
).first()
|
|
|
|
if not api_key:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="API key not found"
|
|
)
|
|
|
|
# Update allowed fields
|
|
if "name" in key_data:
|
|
api_key.name = key_data["name"]
|
|
|
|
if "scopes" in key_data:
|
|
scopes = key_data["scopes"]
|
|
invalid_scopes = [scope for scope in scopes if scope not in API_SCOPES]
|
|
if invalid_scopes:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Invalid scopes: {', '.join(invalid_scopes)}"
|
|
)
|
|
api_key.set_scopes(scopes)
|
|
|
|
if "is_active" in key_data:
|
|
api_key.is_active = key_data["is_active"]
|
|
|
|
db.commit()
|
|
|
|
return APIKeyResponse(**api_key.to_dict())
|
|
|
|
|
|
# Admin endpoints
|
|
@router.get("/users", response_model=List[UserResponse])
|
|
async def list_users(
|
|
current_user: Dict[str, Any] = Depends(require_admin),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""List all users (admin only)."""
|
|
users = db.query(User).all()
|
|
return [UserResponse(**user.to_dict()) for user in users]
|
|
|
|
|
|
@router.get("/scopes", response_model=List[ScopeInfo])
|
|
async def list_available_scopes():
|
|
"""List all available API scopes."""
|
|
return [
|
|
ScopeInfo(scope=scope, description=description)
|
|
for scope, description in API_SCOPES.items()
|
|
]
|
|
|
|
|
|
@router.post("/cleanup-tokens")
|
|
async def cleanup_expired_tokens(
|
|
current_user: Dict[str, Any] = Depends(require_admin),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Cleanup expired tokens from blacklist (admin only)."""
|
|
count = TokenBlacklist.cleanup_expired_tokens(db)
|
|
return {"message": f"Cleaned up {count} expired tokens"} |