Implement complete Bearer Token and API key authentication system
- Create comprehensive authentication backend with JWT and API key support - Add database models for users, API keys, and tokens with proper security - Implement authentication middleware and API endpoints - Build complete frontend authentication UI with: - LoginForm component with JWT authentication - APIKeyManager for creating and managing API keys - AuthDashboard for comprehensive auth management - AuthContext for state management and authenticated requests - Initialize database with default admin user (admin/admin123) - Add proper token refresh, validation, and blacklisting - Implement scope-based API key authorization system 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
449
backend/app/api/auth.py
Normal file
449
backend/app/api/auth.py
Normal file
@@ -0,0 +1,449 @@
|
||||
"""
|
||||
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.auth import User, 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: int
|
||||
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: int
|
||||
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)
|
||||
|
||||
# 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 TokenResponse(**token_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 = int(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 []
|
||||
|
||||
return TokenResponse(**create_token_response(user.id, user_data))
|
||||
|
||||
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"}
|
||||
Reference in New Issue
Block a user