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:
0
services/console/backend/app/__init__.py
Normal file
0
services/console/backend/app/__init__.py
Normal file
0
services/console/backend/app/core/__init__.py
Normal file
0
services/console/backend/app/core/__init__.py
Normal file
47
services/console/backend/app/core/config.py
Normal file
47
services/console/backend/app/core/config.py
Normal 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()
|
||||
78
services/console/backend/app/core/security.py
Normal file
78
services/console/backend/app/core/security.py
Normal 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
|
||||
0
services/console/backend/app/db/__init__.py
Normal file
0
services/console/backend/app/db/__init__.py
Normal file
37
services/console/backend/app/db/mongodb.py
Normal file
37
services/console/backend/app/db/mongodb.py
Normal 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()
|
||||
99
services/console/backend/app/main.py
Normal file
99
services/console/backend/app/main.py
Normal 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
|
||||
)
|
||||
0
services/console/backend/app/models/__init__.py
Normal file
0
services/console/backend/app/models/__init__.py
Normal file
89
services/console/backend/app/models/user.py
Normal file
89
services/console/backend/app/models/user.py
Normal 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
|
||||
0
services/console/backend/app/routes/__init__.py
Normal file
0
services/console/backend/app/routes/__init__.py
Normal file
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"}
|
||||
0
services/console/backend/app/schemas/__init__.py
Normal file
0
services/console/backend/app/schemas/__init__.py
Normal file
89
services/console/backend/app/schemas/auth.py
Normal file
89
services/console/backend/app/schemas/auth.py
Normal 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"
|
||||
}
|
||||
}
|
||||
)
|
||||
0
services/console/backend/app/services/__init__.py
Normal file
0
services/console/backend/app/services/__init__.py
Normal file
143
services/console/backend/app/services/user_service.py
Normal file
143
services/console/backend/app/services/user_service.py
Normal 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
|
||||
Reference in New Issue
Block a user