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:
jungwoo choi
2025-10-28 16:23:07 +09:00
parent 161f206ae2
commit f4b75b96a5
51 changed files with 2480 additions and 100 deletions

View File

View File

@ -0,0 +1,47 @@
from pydantic_settings import BaseSettings
from typing import Optional
class Settings(BaseSettings):
"""Application settings"""
# App
APP_NAME: str = "Site11 Console"
APP_VERSION: str = "1.0.0"
DEBUG: bool = False
# Security
SECRET_KEY: str = "your-secret-key-change-in-production"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
# Database
MONGODB_URL: str = "mongodb://localhost:27017"
DB_NAME: str = "site11_console"
# Redis
REDIS_URL: str = "redis://localhost:6379"
# CORS
CORS_ORIGINS: list = ["http://localhost:3000", "http://localhost:8000"]
# OAuth (Google, GitHub, etc.)
GOOGLE_CLIENT_ID: Optional[str] = None
GOOGLE_CLIENT_SECRET: Optional[str] = None
GITHUB_CLIENT_ID: Optional[str] = None
GITHUB_CLIENT_SECRET: Optional[str] = None
# Services URLs
USERS_SERVICE_URL: str = "http://users-backend:8000"
IMAGES_SERVICE_URL: str = "http://images-backend:8000"
# Kafka (optional)
KAFKA_BOOTSTRAP_SERVERS: str = "kafka:9092"
class Config:
env_file = ".env"
case_sensitive = True
settings = Settings()

View File

@ -0,0 +1,78 @@
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
import bcrypt
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from .config import settings
# OAuth2 scheme
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a password against a hash"""
try:
password_bytes = plain_password.encode('utf-8')
hashed_bytes = hashed_password.encode('utf-8')
return bcrypt.checkpw(password_bytes, hashed_bytes)
except Exception as e:
print(f"Password verification error: {e}")
return False
def get_password_hash(password: str) -> str:
"""Hash a password"""
password_bytes = password.encode('utf-8')
salt = bcrypt.gensalt()
hashed = bcrypt.hashpw(password_bytes, salt)
return hashed.decode('utf-8')
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
"""Create JWT access token"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire, "type": "access"})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def create_refresh_token(data: dict) -> str:
"""Create JWT refresh token"""
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
to_encode.update({"exp": expire, "type": "refresh"})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def decode_token(token: str) -> dict:
"""Decode and validate JWT token"""
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
return payload
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
async def get_current_user_id(token: str = Depends(oauth2_scheme)) -> str:
"""Extract user ID from token"""
payload = decode_token(token)
user_id: str = payload.get("sub")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
return user_id

View File

@ -0,0 +1,37 @@
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
from typing import Optional
from ..core.config import settings
class MongoDB:
"""MongoDB connection manager"""
client: Optional[AsyncIOMotorClient] = None
db: Optional[AsyncIOMotorDatabase] = None
@classmethod
async def connect(cls):
"""Connect to MongoDB"""
cls.client = AsyncIOMotorClient(settings.MONGODB_URL)
cls.db = cls.client[settings.DB_NAME]
print(f"✅ Connected to MongoDB: {settings.DB_NAME}")
@classmethod
async def disconnect(cls):
"""Disconnect from MongoDB"""
if cls.client:
cls.client.close()
print("❌ Disconnected from MongoDB")
@classmethod
def get_db(cls) -> AsyncIOMotorDatabase:
"""Get database instance"""
if cls.db is None:
raise Exception("Database not initialized. Call connect() first.")
return cls.db
# Convenience function
async def get_database() -> AsyncIOMotorDatabase:
"""Dependency to get database"""
return MongoDB.get_db()

View File

@ -0,0 +1,99 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
import logging
from .core.config import settings
from .db.mongodb import MongoDB
from .routes import auth
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan manager"""
# Startup
logger.info("🚀 Starting Console Backend...")
try:
# Connect to MongoDB
await MongoDB.connect()
logger.info("✅ MongoDB connected successfully")
except Exception as e:
logger.error(f"❌ Failed to connect to MongoDB: {e}")
raise
yield
# Shutdown
logger.info("👋 Shutting down Console Backend...")
await MongoDB.disconnect()
# Create FastAPI app
app = FastAPI(
title=settings.APP_NAME,
version=settings.APP_VERSION,
description="Site11 Console - Central management system for news generation pipeline",
lifespan=lifespan
)
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS if not settings.DEBUG else ["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include routers
app.include_router(auth.router)
# Health check endpoints
@app.get("/")
async def root():
"""Root endpoint"""
return {
"message": f"Welcome to {settings.APP_NAME}",
"version": settings.APP_VERSION,
"status": "running"
}
@app.get("/health")
async def health_check():
"""Health check endpoint"""
return {
"status": "healthy",
"service": "console-backend",
"version": settings.APP_VERSION
}
@app.get("/api/health")
async def api_health_check():
"""API health check endpoint for frontend"""
return {
"status": "healthy",
"service": "console-backend-api",
"version": settings.APP_VERSION
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"app.main:app",
host="0.0.0.0",
port=8000,
reload=settings.DEBUG
)

View File

@ -0,0 +1,89 @@
from datetime import datetime
from typing import Optional, List, Annotated
from pydantic import BaseModel, EmailStr, Field, field_validator, ConfigDict
from pydantic_core import core_schema
from bson import ObjectId
class PyObjectId(str):
"""Custom ObjectId type for Pydantic v2"""
@classmethod
def __get_pydantic_core_schema__(cls, source_type, handler):
return core_schema.union_schema([
core_schema.is_instance_schema(ObjectId),
core_schema.chain_schema([
core_schema.str_schema(),
core_schema.no_info_plain_validator_function(cls.validate),
])
],
serialization=core_schema.plain_serializer_function_ser_schema(
lambda x: str(x)
))
@classmethod
def validate(cls, v):
if isinstance(v, ObjectId):
return v
if isinstance(v, str) and ObjectId.is_valid(v):
return ObjectId(v)
raise ValueError("Invalid ObjectId")
class UserRole(str):
"""User roles"""
ADMIN = "admin"
EDITOR = "editor"
VIEWER = "viewer"
class OAuthProvider(BaseModel):
"""OAuth provider information"""
provider: str = Field(..., description="OAuth provider name (google, github, azure)")
provider_user_id: str = Field(..., description="User ID from the provider")
access_token: Optional[str] = Field(None, description="Access token (encrypted)")
refresh_token: Optional[str] = Field(None, description="Refresh token (encrypted)")
class UserProfile(BaseModel):
"""User profile information"""
avatar_url: Optional[str] = None
department: Optional[str] = None
timezone: str = "Asia/Seoul"
class User(BaseModel):
"""User model"""
id: Optional[PyObjectId] = Field(alias="_id", default=None)
email: EmailStr = Field(..., description="User email")
username: str = Field(..., min_length=3, max_length=50, description="Username")
hashed_password: str = Field(..., description="Hashed password")
full_name: Optional[str] = Field(None, description="Full name")
role: str = Field(default=UserRole.VIEWER, description="User role")
permissions: List[str] = Field(default_factory=list, description="User permissions")
oauth_providers: List[OAuthProvider] = Field(default_factory=list)
profile: UserProfile = Field(default_factory=UserProfile)
status: str = Field(default="active", description="User status")
is_active: bool = Field(default=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
last_login_at: Optional[datetime] = None
model_config = ConfigDict(
populate_by_name=True,
arbitrary_types_allowed=True,
json_encoders={ObjectId: str},
json_schema_extra={
"example": {
"email": "user@example.com",
"username": "johndoe",
"full_name": "John Doe",
"role": "viewer"
}
}
)
class UserInDB(User):
"""User model with password hash"""
pass

View 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"}

View File

@ -0,0 +1,89 @@
from pydantic import BaseModel, EmailStr, Field, ConfigDict
from typing import Optional
class UserRegister(BaseModel):
"""User registration schema"""
email: EmailStr = Field(..., description="User email")
username: str = Field(..., min_length=3, max_length=50, description="Username")
password: str = Field(..., min_length=6, description="Password")
full_name: Optional[str] = Field(None, description="Full name")
model_config = ConfigDict(
json_schema_extra={
"example": {
"email": "user@example.com",
"username": "johndoe",
"password": "securepassword123",
"full_name": "John Doe"
}
}
)
class UserLogin(BaseModel):
"""User login schema"""
username: str = Field(..., description="Username or email")
password: str = Field(..., description="Password")
model_config = ConfigDict(
json_schema_extra={
"example": {
"username": "johndoe",
"password": "securepassword123"
}
}
)
class Token(BaseModel):
"""Token response schema"""
access_token: str = Field(..., description="JWT access token")
refresh_token: Optional[str] = Field(None, description="JWT refresh token")
token_type: str = Field(default="bearer", description="Token type")
model_config = ConfigDict(
json_schema_extra={
"example": {
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "bearer"
}
}
)
class TokenRefresh(BaseModel):
"""Token refresh schema"""
refresh_token: str = Field(..., description="Refresh token")
class UserResponse(BaseModel):
"""User response schema (without password)"""
id: str = Field(..., alias="_id", description="User ID")
email: EmailStr
username: str
full_name: Optional[str] = None
role: str
permissions: list = []
status: str
is_active: bool
created_at: str
last_login_at: Optional[str] = None
model_config = ConfigDict(
populate_by_name=True,
json_schema_extra={
"example": {
"_id": "507f1f77bcf86cd799439011",
"email": "user@example.com",
"username": "johndoe",
"full_name": "John Doe",
"role": "viewer",
"permissions": [],
"status": "active",
"is_active": True,
"created_at": "2024-01-01T00:00:00Z"
}
}
)

View File

@ -0,0 +1,143 @@
from datetime import datetime
from typing import Optional
from motor.motor_asyncio import AsyncIOMotorDatabase
from bson import ObjectId
from fastapi import HTTPException, status
from ..models.user import User, UserInDB, UserRole
from ..schemas.auth import UserRegister
from ..core.security import get_password_hash, verify_password
class UserService:
"""User service for business logic"""
def __init__(self, db: AsyncIOMotorDatabase):
self.db = db
self.collection = db.users
async def create_user(self, user_data: UserRegister) -> UserInDB:
"""Create a new user"""
# Check if user already exists
existing_user = await self.collection.find_one({
"$or": [
{"email": user_data.email},
{"username": user_data.username}
]
})
if existing_user:
if existing_user["email"] == user_data.email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
if existing_user["username"] == user_data.username:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already taken"
)
# Create user document
user_dict = {
"email": user_data.email,
"username": user_data.username,
"hashed_password": get_password_hash(user_data.password),
"full_name": user_data.full_name,
"role": UserRole.VIEWER, # Default role
"permissions": [],
"oauth_providers": [],
"profile": {
"avatar_url": None,
"department": None,
"timezone": "Asia/Seoul"
},
"status": "active",
"is_active": True,
"created_at": datetime.utcnow(),
"updated_at": datetime.utcnow(),
"last_login_at": None
}
result = await self.collection.insert_one(user_dict)
user_dict["_id"] = result.inserted_id
return UserInDB(**user_dict)
async def get_user_by_username(self, username: str) -> Optional[UserInDB]:
"""Get user by username"""
user_dict = await self.collection.find_one({"username": username})
if user_dict:
return UserInDB(**user_dict)
return None
async def get_user_by_email(self, email: str) -> Optional[UserInDB]:
"""Get user by email"""
user_dict = await self.collection.find_one({"email": email})
if user_dict:
return UserInDB(**user_dict)
return None
async def get_user_by_id(self, user_id: str) -> Optional[UserInDB]:
"""Get user by ID"""
if not ObjectId.is_valid(user_id):
return None
user_dict = await self.collection.find_one({"_id": ObjectId(user_id)})
if user_dict:
return UserInDB(**user_dict)
return None
async def authenticate_user(self, username: str, password: str) -> Optional[UserInDB]:
"""Authenticate user with username/email and password"""
# Try to find by username or email
user = await self.get_user_by_username(username)
if not user:
user = await self.get_user_by_email(username)
if not user:
return None
if not verify_password(password, user.hashed_password):
return None
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is inactive"
)
return user
async def update_last_login(self, user_id: str):
"""Update user's last login timestamp"""
await self.collection.update_one(
{"_id": ObjectId(user_id)},
{"$set": {"last_login_at": datetime.utcnow()}}
)
async def update_user(self, user_id: str, update_data: dict) -> Optional[UserInDB]:
"""Update user data"""
if not ObjectId.is_valid(user_id):
return None
update_data["updated_at"] = datetime.utcnow()
await self.collection.update_one(
{"_id": ObjectId(user_id)},
{"$set": update_data}
)
return await self.get_user_by_id(user_id)
async def delete_user(self, user_id: str) -> bool:
"""Delete user (soft delete - set status to deleted)"""
if not ObjectId.is_valid(user_id):
return False
result = await self.collection.update_one(
{"_id": ObjectId(user_id)},
{"$set": {"status": "deleted", "is_active": False, "updated_at": datetime.utcnow()}}
)
return result.modified_count > 0