feat: Phase 1 - Complete authentication system with JWT
Backend Implementation (FastAPI + MongoDB): - JWT authentication with access/refresh tokens - User registration and login endpoints - Password hashing with bcrypt (fixed 72-byte limit) - Protected endpoints with JWT middleware - Token refresh mechanism - Role-Based Access Control (RBAC) structure - Pydantic v2 models and async MongoDB with Motor - API endpoints: /api/auth/register, /api/auth/login, /api/auth/me, /api/auth/refresh Frontend Implementation (React + TypeScript + Material-UI): - Login and Register pages with validation - AuthContext for global authentication state - API client with Axios interceptors for token refresh - Protected routes with automatic redirect - User profile display in navigation - Logout functionality Technical Achievements: - Resolved bcrypt 72-byte limit (replaced passlib with native bcrypt) - Fixed Pydantic v2 compatibility (PyObjectId, ConfigDict) - Implemented automatic token refresh on 401 errors - Created comprehensive test suite for all auth endpoints Docker & Kubernetes: - Backend image: yakenator/site11-console-backend:latest - Frontend image: yakenator/site11-console-frontend:latest - Deployed to site11-pipeline namespace - Nginx reverse proxy configuration Documentation: - CONSOLE_ARCHITECTURE.md - Complete system architecture - PHASE1_COMPLETION.md - Detailed completion report - PROGRESS.md - Updated with Phase 1 status All authentication endpoints tested and verified working. 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
167
services/console/backend/app/routes/auth.py
Normal file
167
services/console/backend/app/routes/auth.py
Normal file
@ -0,0 +1,167 @@
|
||||
from datetime import timedelta
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from motor.motor_asyncio import AsyncIOMotorDatabase
|
||||
|
||||
from ..schemas.auth import UserRegister, Token, TokenRefresh, UserResponse
|
||||
from ..services.user_service import UserService
|
||||
from ..db.mongodb import get_database
|
||||
from ..core.security import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
get_current_user_id
|
||||
)
|
||||
from ..core.config import settings
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["authentication"])
|
||||
|
||||
|
||||
def get_user_service(db: AsyncIOMotorDatabase = Depends(get_database)) -> UserService:
|
||||
"""Dependency to get user service"""
|
||||
return UserService(db)
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def register(
|
||||
user_data: UserRegister,
|
||||
user_service: UserService = Depends(get_user_service)
|
||||
):
|
||||
"""Register a new user"""
|
||||
user = await user_service.create_user(user_data)
|
||||
|
||||
return UserResponse(
|
||||
_id=str(user.id),
|
||||
email=user.email,
|
||||
username=user.username,
|
||||
full_name=user.full_name,
|
||||
role=user.role,
|
||||
permissions=user.permissions,
|
||||
status=user.status,
|
||||
is_active=user.is_active,
|
||||
created_at=user.created_at.isoformat(),
|
||||
last_login_at=user.last_login_at.isoformat() if user.last_login_at else None
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
user_service: UserService = Depends(get_user_service)
|
||||
):
|
||||
"""Login with username/email and password"""
|
||||
user = await user_service.authenticate_user(form_data.username, form_data.password)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username/email or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Update last login timestamp
|
||||
await user_service.update_last_login(str(user.id))
|
||||
|
||||
# Create tokens
|
||||
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
access_token = create_access_token(
|
||||
data={"sub": str(user.id), "username": user.username},
|
||||
expires_delta=access_token_expires
|
||||
)
|
||||
refresh_token = create_refresh_token(data={"sub": str(user.id)})
|
||||
|
||||
return Token(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
token_type="bearer"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=Token)
|
||||
async def refresh_token(
|
||||
token_data: TokenRefresh,
|
||||
user_service: UserService = Depends(get_user_service)
|
||||
):
|
||||
"""Refresh access token using refresh token"""
|
||||
try:
|
||||
payload = decode_token(token_data.refresh_token)
|
||||
|
||||
# Verify it's a refresh token
|
||||
if payload.get("type") != "refresh":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token type"
|
||||
)
|
||||
|
||||
user_id = payload.get("sub")
|
||||
if not user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token"
|
||||
)
|
||||
|
||||
# Verify user still exists and is active
|
||||
user = await user_service.get_user_by_id(user_id)
|
||||
if not user or not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found or inactive"
|
||||
)
|
||||
|
||||
# Create new access token
|
||||
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
access_token = create_access_token(
|
||||
data={"sub": user_id, "username": user.username},
|
||||
expires_delta=access_token_expires
|
||||
)
|
||||
|
||||
return Token(
|
||||
access_token=access_token,
|
||||
refresh_token=token_data.refresh_token,
|
||||
token_type="bearer"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired refresh token"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_current_user(
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
user_service: UserService = Depends(get_user_service)
|
||||
):
|
||||
"""Get current user information"""
|
||||
user = await user_service.get_user_by_id(user_id)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
return UserResponse(
|
||||
_id=str(user.id),
|
||||
email=user.email,
|
||||
username=user.username,
|
||||
full_name=user.full_name,
|
||||
role=user.role,
|
||||
permissions=user.permissions,
|
||||
status=user.status,
|
||||
is_active=user.is_active,
|
||||
created_at=user.created_at.isoformat(),
|
||||
last_login_at=user.last_login_at.isoformat() if user.last_login_at else None
|
||||
)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(user_id: str = Depends(get_current_user_id)):
|
||||
"""Logout endpoint (token should be removed on client side)"""
|
||||
# In a more sophisticated system, you might want to:
|
||||
# 1. Blacklist the token in Redis
|
||||
# 2. Log the logout event
|
||||
# 3. Clear any session data
|
||||
|
||||
return {"message": "Successfully logged out"}
|
||||
Reference in New Issue
Block a user