Files
hive/backend/app/api/auth.py
anthonyrawlins 3f3eec7f5d Integrate Bzzz P2P task coordination and enhance project management
🔗 Bzzz Integration:
- Added comprehensive Bzzz integration documentation and todos
- Implemented N8N chat workflow architecture for task coordination
- Enhanced project management with Bzzz-specific features
- Added GitHub service for seamless issue synchronization
- Created BzzzIntegration component for frontend management

🎯 Project Management Enhancements:
- Improved project listing and filtering capabilities
- Enhanced authentication and authorization flows
- Added unified coordinator for better task orchestration
- Streamlined project activation and configuration
- Updated API endpoints for Bzzz compatibility

📊 Technical Improvements:
- Updated Docker Swarm configuration for local registry
- Enhanced frontend build with updated assets
- Improved WebSocket connections for real-time updates
- Added comprehensive error handling and logging
- Updated environment configurations for production

 System Integration:
- Successfully tested with Bzzz v1.2 task execution workflow
- Validated GitHub issue discovery and claiming functionality
- Confirmed sandbox-based task execution compatibility
- Verified Docker registry integration

This release enables seamless integration between Hive project management and Bzzz P2P task coordination, creating a complete distributed development ecosystem.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 20:56:01 +10:00

493 lines
14 KiB
Python

"""
Authentication API endpoints for Hive 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"}