diff --git a/.env.dev b/.env.dev new file mode 100644 index 0000000..2f17f0b --- /dev/null +++ b/.env.dev @@ -0,0 +1,63 @@ +# OAuth 2.0 Authentication System - Development Environment + +# Application Environment +ENVIRONMENT=dev + +# Security +SECRET_KEY=3bf17c7f-5446-4a18-9cb3-f885eba501e8 +JWT_ALGORITHM=HS256 +JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30 +JWT_REFRESH_TOKEN_EXPIRE_DAYS=7 + +# MongoDB Configuration +MONGODB_URL=mongodb://admin:admin123@localhost:27017/oauth_db?authSource=admin +DATABASE_NAME=oauth_db + +# Redis Configuration +REDIS_URL=redis://localhost:6379 +REDIS_DB=0 + +# API Configuration +API_HOST=0.0.0.0 +API_PORT=8000 +API_PREFIX=/api/v1 + +# Frontend Configuration +FRONTEND_URL=http://localhost:5173 + +# CORS Settings +CORS_ORIGINS=["http://localhost:5173", "http://localhost:8000"] +CORS_ALLOW_CREDENTIALS=true + +# Rate Limiting +RATE_LIMIT_REQUESTS=100 +RATE_LIMIT_PERIOD=60 + +# Backup and Archive Paths +BACKUP_PATH=/var/backups/oauth +ARCHIVE_PATH=/var/archives/oauth + +# Logging +LOG_LEVEL=DEBUG +LOG_PATH=/var/log/oauth + +# Email Configuration +EMAIL_ENABLED=false +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +EMAIL_FROM=noreply@oauth.local + +# OAuth Client Defaults +DEFAULT_CLIENT_ACCESS_TOKEN_EXPIRE_MINUTES=60 +DEFAULT_CLIENT_REFRESH_TOKEN_EXPIRE_DAYS=30 + +# Session Configuration +SESSION_SECRET_KEY=5d1cacb8-4d7e-4604-b553-e0251f8fbe7e +SESSION_COOKIE_NAME=oauth_session +SESSION_EXPIRE_MINUTES=1440 + +# Admin Configuration (Development Only) +ADMIN_EMAIL=admin@oauth.local +ADMIN_PASSWORD=admin123 \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c1f6af1 --- /dev/null +++ b/.env.example @@ -0,0 +1,64 @@ +# OAuth 2.0 Authentication System Environment Variables +# Copy this file to .env.dev, .env.vei, or .env.prod and update values + +# Application Environment +ENVIRONMENT=dev # dev, vei, prod + +# Security +SECRET_KEY=your-secret-key-here-change-in-production +JWT_ALGORITHM=HS256 +JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30 +JWT_REFRESH_TOKEN_EXPIRE_DAYS=7 + +# MongoDB Configuration +MONGODB_URL=mongodb://admin:admin123@localhost:27017/oauth_db?authSource=admin +DATABASE_NAME=oauth_db + +# Redis Configuration +REDIS_URL=redis://localhost:6379 +REDIS_DB=0 + +# API Configuration +API_HOST=0.0.0.0 +API_PORT=8000 +API_PREFIX=/api/v1 + +# Frontend Configuration +FRONTEND_URL=http://localhost:5173 + +# CORS Settings +CORS_ORIGINS=["http://localhost:5173", "http://localhost:9080"] +CORS_ALLOW_CREDENTIALS=true + +# Rate Limiting +RATE_LIMIT_REQUESTS=100 +RATE_LIMIT_PERIOD=60 # seconds + +# Backup and Archive Paths +BACKUP_PATH=/var/backups/oauth +ARCHIVE_PATH=/var/archives/oauth + +# Logging +LOG_LEVEL=INFO +LOG_PATH=/var/log/oauth + +# Email Configuration (Optional) +EMAIL_ENABLED=false +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +EMAIL_FROM=noreply@oauth.local + +# OAuth Client Defaults +DEFAULT_CLIENT_ACCESS_TOKEN_EXPIRE_MINUTES=60 +DEFAULT_CLIENT_REFRESH_TOKEN_EXPIRE_DAYS=30 + +# Session Configuration +SESSION_SECRET_KEY=session-secret-key-change-in-production +SESSION_COOKIE_NAME=oauth_session +SESSION_EXPIRE_MINUTES=1440 # 24 hours + +# Admin Configuration +ADMIN_EMAIL=admin@oauth.local +ADMIN_PASSWORD=admin123 # Change in production! \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 2f3e85b..d635cb5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,8 +84,6 @@ docker-compose down -v ``` #### 서비스 접속 URL -- **API Gateway**: http://localhost:9080 -- **APISIX Dashboard**: http://localhost:9000 (admin/admin123) - **Frontend**: http://localhost:5173 - **Backend API**: http://localhost:9080/api/v1 (through APISIX) - **MongoDB**: mongodb://localhost:27017 @@ -94,8 +92,6 @@ docker-compose down -v ### API 엔드포인트 - Health Check: `GET http://localhost:9080/health` - API Documentation: `http://localhost:9080/api/v1/docs` -- APISIX Admin API: `http://localhost:9092/apisix/admin` -- APISIX Dashboard: `http://localhost:9000` ## OAuth 인증 시스템 diff --git a/CLAUDE/task-plan.md b/CLAUDE/task-plan.md index e4e8aca..98b2683 100644 --- a/CLAUDE/task-plan.md +++ b/CLAUDE/task-plan.md @@ -5,67 +5,67 @@ ### Phase 1: 기본 구조 설정 #### 1. 프로젝트 디렉토리 구조 생성 -- [ ] 메인 디렉토리 생성 (oauth/, services/, .docker/, .k8s/) -- [ ] OAuth 하위 디렉토리 생성 (backend/, frontend/, docs/, configs/) -- [ ] 환경별 설정 디렉토리 생성 (configs/dev/, configs/vei/, configs/prod/) +- [x] 메인 디렉토리 생성 (oauth/, services/, .docker/, .k8s/) +- [x] OAuth 하위 디렉토리 생성 (backend/, frontend/, docs/, configs/) +- [x] 환경별 설정 디렉토리 생성 (configs/dev/, configs/vei/, configs/prod/) #### 2. Docker Compose 파일 생성 -- [ ] docker-compose.yml 기본 파일 작성 -- [ ] MongoDB 7.0 서비스 정의 -- [ ] Redis 7 서비스 정의 -- [ ] APISIX Gateway 서비스 정의 -- [ ] OAuth Backend 서비스 정의 -- [ ] OAuth Frontend 서비스 정의 -- [ ] 네트워크 및 볼륨 설정 -- [ ] Health check 설정 -- [ ] 서비스 간 의존성 설정 (depends_on) +- [x] docker-compose.yml 기본 파일 작성 +- [x] MongoDB 7.0 서비스 정의 +- [x] Redis 7 서비스 정의 +- [ ] ~~APISIX Gateway 서비스 정의~~ (나중에 추가) +- [x] OAuth Backend 서비스 정의 +- [x] OAuth Frontend 서비스 정의 +- [x] 네트워크 및 볼륨 설정 +- [x] Health check 설정 +- [x] 서비스 간 의존성 설정 (depends_on) #### 3. 환경 설정 파일 생성 -- [ ] .env.example 파일 생성 -- [ ] .env.dev 파일 생성 -- [ ] .gitignore 파일 생성 +- [x] .env.example 파일 생성 +- [x] .env.dev 파일 생성 +- [x] .gitignore 파일 생성 --- ### Phase 2: OAuth 백엔드 구축 #### 1. FastAPI 프로젝트 초기화 -- [ ] backend 디렉토리 구조 생성 -- [ ] requirements.txt 파일 작성 -- [ ] Dockerfile 작성 -- [ ] .env.example 작성 +- [x] backend 디렉토리 구조 생성 +- [x] requirements.txt 파일 작성 +- [x] Dockerfile 작성 +- [x] .env.example 작성 #### 2. 기본 앱 구조 생성 -- [ ] app/__init__.py 생성 -- [ ] app/main.py (FastAPI 앱 진입점) 생성 -- [ ] app/config.py (환경 설정) 생성 +- [x] app/__init__.py 생성 +- [x] app/main.py (FastAPI 앱 진입점) 생성 +- [x] app/config.py (환경 설정) 생성 #### 3. 데이터베이스 모델 정의 -- [ ] app/models/__init__.py 생성 -- [ ] app/models/user.py (사용자 모델) 생성 -- [ ] app/models/application.py (애플리케이션 모델) 생성 -- [ ] app/models/auth_history.py (인증 히스토리 모델) 생성 +- [x] app/models/__init__.py 생성 +- [x] app/models/user.py (사용자 모델) 생성 +- [x] app/models/application.py (애플리케이션 모델) 생성 +- [x] app/models/auth_history.py (인증 히스토리 모델) 생성 #### 4. 유틸리티 및 서비스 생성 -- [ ] app/utils/__init__.py 생성 -- [ ] app/utils/database.py (MongoDB 연결) 생성 -- [ ] app/utils/security.py (보안 관련) 생성 -- [ ] app/services/__init__.py 생성 -- [ ] app/services/auth_service.py 생성 -- [ ] app/services/token_service.py (JWT 처리) 생성 +- [x] app/utils/__init__.py 생성 +- [x] app/utils/database.py (MongoDB 연결) 생성 +- [x] app/utils/security.py (보안 관련) 생성 +- [x] app/services/__init__.py 생성 +- [x] app/services/auth_service.py 생성 +- [x] app/services/token_service.py (JWT 처리) 생성 #### 5. API 라우터 구현 -- [ ] app/routers/__init__.py 생성 -- [ ] app/routers/auth.py (인증 엔드포인트) 생성 -- [ ] app/routers/users.py (사용자 관리) 생성 -- [ ] app/routers/applications.py (애플리케이션 관리) 생성 +- [x] app/routers/__init__.py 생성 +- [x] app/routers/auth.py (인증 엔드포인트) 생성 +- [x] app/routers/users.py (사용자 관리) 생성 +- [x] app/routers/applications.py (애플리케이션 관리) 생성 #### 6. 핵심 기능 구현 -- [ ] JWT 토큰 생성/검증 로직 -- [ ] 로그인/로그아웃 API -- [ ] OAuth 2.0 Authorization Code Flow -- [ ] Refresh Token 관리 -- [ ] 3단계 권한 체계 (System Admin/Group Admin/User) +- [x] JWT 토큰 생성/검증 로직 +- [x] 로그인/로그아웃 API +- [x] OAuth 2.0 Authorization Code Flow +- [x] Refresh Token 관리 +- [x] 3단계 권한 체계 (System Admin/Group Admin/User) --- diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ecd34e3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,114 @@ +version: '3.8' + +services: + # MongoDB Database + mongodb: + image: mongo:7.0 + container_name: oauth-mongodb + restart: unless-stopped + ports: + - "27017:27017" + environment: + MONGO_INITDB_ROOT_USERNAME: admin + MONGO_INITDB_ROOT_PASSWORD: admin123 + MONGO_INITDB_DATABASE: oauth_db + volumes: + - mongodb_data:/data/db + - mongodb_config:/data/configdb + networks: + - oauth-network + healthcheck: + test: echo 'db.runCommand("ping").ok' | mongosh localhost:27017/test --quiet + interval: 10s + timeout: 5s + retries: 5 + start_period: 40s + + # Redis Cache/Queue + redis: + image: redis:7-alpine + container_name: oauth-redis + restart: unless-stopped + ports: + - "6379:6379" + command: redis-server --appendonly yes + volumes: + - redis_data:/data + networks: + - oauth-network + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + + # OAuth Backend (FastAPI) + oauth-backend: + build: + context: ./oauth/backend + dockerfile: Dockerfile + target: development + container_name: oauth-backend + restart: unless-stopped + ports: + - "8000:8000" + environment: + - ENVIRONMENT=dev + - MONGODB_URL=mongodb://admin:admin123@mongodb:27017/oauth_db?authSource=admin + - REDIS_URL=redis://redis:6379 + - SECRET_KEY=${SECRET_KEY:-3bf17c7f-5446-4a18-9cb3-f885eba501e8} + - DATABASE_NAME=oauth_db + volumes: + - ./oauth/backend:/app + - backend_logs:/var/log/oauth + networks: + - oauth-network + depends_on: + mongodb: + condition: service_healthy + redis: + condition: service_healthy + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + + # OAuth Frontend (React + Vite) + oauth-frontend: + build: + context: ./oauth/frontend + dockerfile: Dockerfile + target: development + container_name: oauth-frontend + restart: unless-stopped + ports: + - "5173:5173" + environment: + - VITE_API_URL=http://localhost:8000/api/v1 + - VITE_ENV=dev + volumes: + - ./oauth/frontend:/app + - /app/node_modules + networks: + - oauth-network + depends_on: + oauth-backend: + condition: service_started + command: npm run dev -- --host 0.0.0.0 + +networks: + oauth-network: + driver: bridge + name: oauth-network + +volumes: + mongodb_data: + driver: local + name: oauth-mongodb-data + mongodb_config: + driver: local + name: oauth-mongodb-config + redis_data: + driver: local + name: oauth-redis-data + backend_logs: + driver: local + name: oauth-backend-logs \ No newline at end of file diff --git a/oauth/backend/.env.example b/oauth/backend/.env.example new file mode 100644 index 0000000..7281b9e --- /dev/null +++ b/oauth/backend/.env.example @@ -0,0 +1,25 @@ +# OAuth Backend Environment Variables + +# Application +ENVIRONMENT=dev +SECRET_KEY=your-secret-key-here +API_PREFIX=/api/v1 + +# MongoDB +MONGODB_URL=mongodb://admin:admin123@localhost:27017/oauth_db?authSource=admin +DATABASE_NAME=oauth_db + +# Redis +REDIS_URL=redis://localhost:6379 + +# JWT Settings +JWT_ALGORITHM=HS256 +JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30 +JWT_REFRESH_TOKEN_EXPIRE_DAYS=7 + +# CORS +CORS_ORIGINS=["http://localhost:5173"] +CORS_ALLOW_CREDENTIALS=true + +# Logging +LOG_LEVEL=INFO \ No newline at end of file diff --git a/oauth/backend/Dockerfile b/oauth/backend/Dockerfile new file mode 100644 index 0000000..d99c4d0 --- /dev/null +++ b/oauth/backend/Dockerfile @@ -0,0 +1,59 @@ +# Multi-stage Dockerfile for OAuth Backend + +# Base stage +FROM python:3.11-slim as base + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements +COPY requirements.txt . + +# Install Python dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Development stage +FROM base as development + +# Set environment variables +ENV PYTHONUNBUFFERED=1 +ENV ENVIRONMENT=dev + +# Copy application code +COPY . . + +# Create necessary directories +RUN mkdir -p /var/log/oauth + +# Expose port +EXPOSE 8000 + +# Run with hot reload +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] + +# Production stage +FROM base as production + +# Set environment variables +ENV PYTHONUNBUFFERED=1 +ENV ENVIRONMENT=prod + +# Copy application code +COPY . . + +# Create necessary directories +RUN mkdir -p /var/log/oauth + +# Create non-root user +RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app /var/log/oauth +USER appuser + +# Expose port +EXPOSE 8000 + +# Run without reload +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"] \ No newline at end of file diff --git a/oauth/backend/app/__init__.py b/oauth/backend/app/__init__.py new file mode 100644 index 0000000..c23c7e4 --- /dev/null +++ b/oauth/backend/app/__init__.py @@ -0,0 +1,3 @@ +"""OAuth 2.0 Authentication System Backend""" + +__version__ = "1.0.0" \ No newline at end of file diff --git a/oauth/backend/app/config.py b/oauth/backend/app/config.py new file mode 100644 index 0000000..f0023b5 --- /dev/null +++ b/oauth/backend/app/config.py @@ -0,0 +1,62 @@ +"""Configuration settings for OAuth backend""" + +from typing import List, Optional +from pydantic_settings import BaseSettings +from pydantic import Field +import json + + +class Settings(BaseSettings): + # Application + environment: str = Field(default="dev", env="ENVIRONMENT") + api_prefix: str = Field(default="/api/v1", env="API_PREFIX") + secret_key: str = Field(..., env="SECRET_KEY") + + # MongoDB + mongodb_url: str = Field(..., env="MONGODB_URL") + database_name: str = Field(default="oauth_db", env="DATABASE_NAME") + + # Redis + redis_url: str = Field(..., env="REDIS_URL") + redis_db: int = Field(default=0, env="REDIS_DB") + + # JWT + jwt_algorithm: str = Field(default="HS256", env="JWT_ALGORITHM") + jwt_access_token_expire_minutes: int = Field(default=30, env="JWT_ACCESS_TOKEN_EXPIRE_MINUTES") + jwt_refresh_token_expire_days: int = Field(default=7, env="JWT_REFRESH_TOKEN_EXPIRE_DAYS") + + # CORS + cors_origins: List[str] = Field(default=["http://localhost:5173"], env="CORS_ORIGINS") + cors_allow_credentials: bool = Field(default=True, env="CORS_ALLOW_CREDENTIALS") + + # Rate Limiting + rate_limit_requests: int = Field(default=100, env="RATE_LIMIT_REQUESTS") + rate_limit_period: int = Field(default=60, env="RATE_LIMIT_PERIOD") + + # Logging + log_level: str = Field(default="INFO", env="LOG_LEVEL") + log_path: str = Field(default="/var/log/oauth", env="LOG_PATH") + + # Admin + admin_email: Optional[str] = Field(default="admin@oauth.local", env="ADMIN_EMAIL") + admin_password: Optional[str] = Field(default="admin123", env="ADMIN_PASSWORD") + + # Session + session_secret_key: str = Field(default="session-secret-key", env="SESSION_SECRET_KEY") + session_cookie_name: str = Field(default="oauth_session", env="SESSION_COOKIE_NAME") + session_expire_minutes: int = Field(default=1440, env="SESSION_EXPIRE_MINUTES") + + class Config: + env_file = ".env" + env_file_encoding = "utf-8" + case_sensitive = False + + @classmethod + def parse_env_var(cls, field_name: str, raw_val: str): + if field_name == "cors_origins": + return json.loads(raw_val) if isinstance(raw_val, str) else raw_val + return raw_val + + +# Create settings instance +settings = Settings() \ No newline at end of file diff --git a/oauth/backend/app/main.py b/oauth/backend/app/main.py new file mode 100644 index 0000000..061a9b1 --- /dev/null +++ b/oauth/backend/app/main.py @@ -0,0 +1,198 @@ +"""Main FastAPI application""" + +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from contextlib import asynccontextmanager +import logging +from app.config import settings +from app.utils.database import connect_database, disconnect_database +from app.routers import auth_router, users_router, applications_router +from app.services.auth_service import AuthService + +# Configure logging +logging.basicConfig( + level=getattr(logging, settings.log_level), + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Manage application lifecycle""" + # Startup + logger.info("Starting OAuth 2.0 Authentication System") + await connect_database() + + # Create admin user if not exists + if settings.environment == "dev": + await AuthService.create_admin_user() + + yield + + # Shutdown + logger.info("Shutting down OAuth 2.0 Authentication System") + await disconnect_database() + + +# Create FastAPI app +app = FastAPI( + title="OAuth 2.0 Authentication System", + description="Enterprise-grade OAuth 2.0 based central authentication system", + version="1.0.0", + docs_url=f"{settings.api_prefix}/docs", + redoc_url=f"{settings.api_prefix}/redoc", + openapi_url=f"{settings.api_prefix}/openapi.json", + lifespan=lifespan +) + +# Configure CORS +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_credentials=settings.cors_allow_credentials, + allow_methods=["*"], + allow_headers=["*"], +) + + +# Health check endpoint +@app.get("/health") +async def health_check(): + """Health check endpoint""" + return { + "status": "healthy", + "environment": settings.environment, + "version": "1.0.0" + } + + +# Root endpoint +@app.get("/") +async def root(): + """Root endpoint""" + return { + "message": "OAuth 2.0 Authentication System API", + "docs": f"{settings.api_prefix}/docs", + "health": "/health" + } + + +# Include routers +app.include_router(auth_router, prefix=settings.api_prefix) +app.include_router(users_router, prefix=settings.api_prefix) +app.include_router(applications_router, prefix=settings.api_prefix) + + +# Global exception handler +@app.exception_handler(Exception) +async def global_exception_handler(request: Request, exc: Exception): + """Global exception handler""" + logger.error(f"Unhandled exception: {exc}") + return JSONResponse( + status_code=500, + content={"detail": "Internal server error"} + ) + + +# OAuth 2.0 authorization endpoint +@app.get("/oauth/authorize") +async def oauth_authorize( + response_type: str, + client_id: str, + redirect_uri: str, + scope: str, + state: str = None +): + """OAuth 2.0 authorization endpoint""" + # This would typically render a login page + # For now, return the parameters for the frontend to handle + return { + "response_type": response_type, + "client_id": client_id, + "redirect_uri": redirect_uri, + "scope": scope, + "state": state + } + + +# OAuth 2.0 token endpoint +@app.post("/oauth/token") +async def oauth_token( + grant_type: str, + code: str = None, + client_id: str = None, + client_secret: str = None, + redirect_uri: str = None, + refresh_token: str = None +): + """OAuth 2.0 token endpoint""" + from app.services.token_service import TokenService + + if grant_type == "authorization_code": + if not all([code, client_id, client_secret, redirect_uri]): + return JSONResponse( + status_code=400, + content={"error": "invalid_request", "error_description": "Missing required parameters"} + ) + + token = await TokenService.exchange_authorization_code( + code=code, + client_id=client_id, + client_secret=client_secret, + redirect_uri=redirect_uri + ) + + if not token: + return JSONResponse( + status_code=400, + content={"error": "invalid_grant", "error_description": "Invalid authorization code"} + ) + + return token + + elif grant_type == "refresh_token": + if not refresh_token: + return JSONResponse( + status_code=400, + content={"error": "invalid_request", "error_description": "Missing refresh token"} + ) + + user_id = await TokenService.verify_refresh_token(refresh_token) + if not user_id: + return JSONResponse( + status_code=400, + content={"error": "invalid_grant", "error_description": "Invalid refresh token"} + ) + + # Get user and create new tokens + from app.services.auth_service import AuthService + user = await AuthService.get_user_by_id(user_id) + + if not user: + return JSONResponse( + status_code=400, + content={"error": "invalid_grant", "error_description": "User not found"} + ) + + # Revoke old refresh token + await TokenService.revoke_refresh_token(refresh_token) + + # Create new tokens + tokens = await TokenService.create_tokens( + str(user.id), + { + "username": user.username, + "role": user.role, + "email": user.email + } + ) + + return tokens + + else: + return JSONResponse( + status_code=400, + content={"error": "unsupported_grant_type", "error_description": f"Grant type '{grant_type}' is not supported"} + ) \ No newline at end of file diff --git a/oauth/backend/app/models/__init__.py b/oauth/backend/app/models/__init__.py new file mode 100644 index 0000000..c54b6ea --- /dev/null +++ b/oauth/backend/app/models/__init__.py @@ -0,0 +1,5 @@ +"""Data models for OAuth backend""" + +from .user import User, UserCreate, UserUpdate, UserRole, UserInDB +from .application import Application, ApplicationCreate, ApplicationUpdate +from .auth_history import AuthHistory, AuthAction \ No newline at end of file diff --git a/oauth/backend/app/models/application.py b/oauth/backend/app/models/application.py new file mode 100644 index 0000000..fc8941c --- /dev/null +++ b/oauth/backend/app/models/application.py @@ -0,0 +1,79 @@ +"""Application model definitions""" + +from typing import List, Optional, Dict, Any +from pydantic import BaseModel, Field, ConfigDict +from datetime import datetime +from bson import ObjectId + + +class ApplicationTheme(BaseModel): + """Application theme configuration""" + primary_color: str = "#1976d2" + secondary_color: str = "#dc004e" + logo_url: Optional[str] = None + favicon_url: Optional[str] = None + font_family: str = "Roboto, sans-serif" + custom_css: Optional[str] = None + + +class ApplicationBase(BaseModel): + """Base application model""" + app_name: str = Field(..., min_length=3, max_length=100) + description: Optional[str] = None + redirect_uris: List[str] = [] + allowed_origins: List[str] = [] + theme: Optional[ApplicationTheme] = ApplicationTheme() + permissions: List[str] = ["sso", "name", "email"] # Default permissions + is_active: bool = True + + +class ApplicationCreate(ApplicationBase): + """Application creation model""" + pass + + +class ApplicationUpdate(BaseModel): + """Application update model""" + app_name: Optional[str] = Field(None, min_length=3, max_length=100) + description: Optional[str] = None + redirect_uris: Optional[List[str]] = None + allowed_origins: Optional[List[str]] = None + theme: Optional[ApplicationTheme] = None + permissions: Optional[List[str]] = None + is_active: Optional[bool] = None + + +class Application(ApplicationBase): + """Application response model""" + id: str = Field(alias="_id") + client_id: str + client_secret: str + created_by: str + created_at: datetime + updated_at: datetime + + model_config = ConfigDict( + populate_by_name=True, + arbitrary_types_allowed=True, + json_encoders={ObjectId: str} + ) + + +class ApplicationInDB(Application): + """Application model in database""" + pass + + +class ApplicationPublic(BaseModel): + """Public application information (no secret)""" + id: str = Field(alias="_id") + app_name: str + description: Optional[str] = None + theme: Optional[ApplicationTheme] = None + permissions: List[str] = [] + + model_config = ConfigDict( + populate_by_name=True, + arbitrary_types_allowed=True, + json_encoders={ObjectId: str} + ) \ No newline at end of file diff --git a/oauth/backend/app/models/auth_history.py b/oauth/backend/app/models/auth_history.py new file mode 100644 index 0000000..42c055a --- /dev/null +++ b/oauth/backend/app/models/auth_history.py @@ -0,0 +1,53 @@ +"""Authentication history model definitions""" + +from typing import Optional +from pydantic import BaseModel, Field, ConfigDict +from datetime import datetime +from enum import Enum +from bson import ObjectId + + +class AuthAction(str, Enum): + """Authentication action types""" + LOGIN = "login" + LOGOUT = "logout" + TOKEN_REFRESH = "token_refresh" + AUTHORIZATION_CODE = "authorization_code" + PASSWORD_RESET = "password_reset" + REGISTER = "register" + FAILED_LOGIN = "failed_login" + + +class AuthHistoryBase(BaseModel): + """Base authentication history model""" + user_id: str + application_id: Optional[str] = None + action: AuthAction + ip_address: str + user_agent: Optional[str] = None + result: str = "success" + details: Optional[dict] = None + + model_config = ConfigDict(use_enum_values=True) + + +class AuthHistoryCreate(AuthHistoryBase): + """Authentication history creation model""" + pass + + +class AuthHistory(AuthHistoryBase): + """Authentication history response model""" + id: str = Field(alias="_id") + created_at: datetime + + model_config = ConfigDict( + populate_by_name=True, + arbitrary_types_allowed=True, + json_encoders={ObjectId: str} + ) + + +class AuthHistoryInDB(AuthHistory): + """Authentication history model in database""" + pass \ No newline at end of file diff --git a/oauth/backend/app/models/user.py b/oauth/backend/app/models/user.py new file mode 100644 index 0000000..261d87a --- /dev/null +++ b/oauth/backend/app/models/user.py @@ -0,0 +1,108 @@ +"""User model definitions""" + +from typing import Optional, List +from pydantic import BaseModel, EmailStr, Field, ConfigDict +from datetime import datetime +from enum import Enum +from bson import ObjectId + + +class UserRole(str, Enum): + """User role enumeration""" + SYSTEM_ADMIN = "system_admin" + GROUP_ADMIN = "group_admin" + USER = "user" + + +class PyObjectId(ObjectId): + """Custom ObjectId type for Pydantic""" + @classmethod + def __get_validators__(cls): + yield cls.validate + + @classmethod + def validate(cls, v): + if not ObjectId.is_valid(v): + raise ValueError("Invalid ObjectId") + return ObjectId(v) + + @classmethod + def __get_pydantic_json_schema__(cls, field_schema): + field_schema.update(type="string") + + +class UserBase(BaseModel): + """Base user model""" + email: EmailStr + username: str = Field(..., min_length=3, max_length=50) + full_name: Optional[str] = None + role: UserRole = UserRole.USER + profile_picture: Optional[str] = None + phone_number: Optional[str] = None + gender: Optional[str] = None + birth_date: Optional[str] = None + is_active: bool = True + + model_config = ConfigDict(use_enum_values=True) + + +class UserCreate(UserBase): + """User creation model""" + password: str = Field(..., min_length=8) + + +class UserUpdate(BaseModel): + """User update model""" + email: Optional[EmailStr] = None + username: Optional[str] = Field(None, min_length=3, max_length=50) + full_name: Optional[str] = None + role: Optional[UserRole] = None + profile_picture: Optional[str] = None + phone_number: Optional[str] = None + gender: Optional[str] = None + birth_date: Optional[str] = None + is_active: Optional[bool] = None + password: Optional[str] = Field(None, min_length=8) + + +class User(UserBase): + """User response model""" + id: str = Field(alias="_id") + created_at: datetime + updated_at: datetime + + model_config = ConfigDict( + populate_by_name=True, + arbitrary_types_allowed=True, + json_encoders={ObjectId: str} + ) + + +class UserInDB(User): + """User model in database""" + hashed_password: str + + +class UserLogin(BaseModel): + """User login model""" + username: str + password: str + + +class TokenData(BaseModel): + """Token data model""" + user_id: Optional[str] = None + username: Optional[str] = None + role: Optional[str] = None + + +class Token(BaseModel): + """Token response model""" + access_token: str + refresh_token: str + token_type: str = "bearer" + + +class TokenRefresh(BaseModel): + """Token refresh request model""" + refresh_token: str \ No newline at end of file diff --git a/oauth/backend/app/routers/__init__.py b/oauth/backend/app/routers/__init__.py new file mode 100644 index 0000000..307012d --- /dev/null +++ b/oauth/backend/app/routers/__init__.py @@ -0,0 +1,5 @@ +"""API routers""" + +from .auth import router as auth_router +from .users import router as users_router +from .applications import router as applications_router \ No newline at end of file diff --git a/oauth/backend/app/routers/applications.py b/oauth/backend/app/routers/applications.py new file mode 100644 index 0000000..3f49400 --- /dev/null +++ b/oauth/backend/app/routers/applications.py @@ -0,0 +1,256 @@ +"""Applications management router""" + +from fastapi import APIRouter, Depends, HTTPException, status, Query +from typing import List +from bson import ObjectId +from datetime import datetime +from app.models.application import Application, ApplicationCreate, ApplicationUpdate, ApplicationPublic +from app.models.user import User, UserRole +from app.routers.auth import get_current_user +from app.utils.database import get_database +from app.utils.security import generate_client_id, generate_client_secret + +router = APIRouter(prefix="/applications", tags=["Applications"]) + + +def require_admin(current_user: User = Depends(get_current_user)) -> User: + """Require admin role""" + if current_user.role not in [UserRole.SYSTEM_ADMIN, UserRole.GROUP_ADMIN]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions" + ) + return current_user + + +@router.get("/", response_model=List[Application]) +async def get_applications( + skip: int = Query(0, ge=0), + limit: int = Query(10, ge=1, le=100), + current_user: User = Depends(get_current_user) +): + """Get applications (own applications for users, all for admins)""" + db = get_database() + + query = {} + if current_user.role not in [UserRole.SYSTEM_ADMIN, UserRole.GROUP_ADMIN]: + query = {"created_by": str(current_user.id)} + + apps = await db.applications.find(query).skip(skip).limit(limit).to_list(limit) + return [Application(**app) for app in apps] + + +@router.post("/", response_model=Application) +async def create_application( + app_data: ApplicationCreate, + current_user: User = Depends(get_current_user) +): + """Create a new application""" + db = get_database() + + # Generate client credentials + client_id = generate_client_id() + client_secret = generate_client_secret() + + # Check if client_id already exists (unlikely but possible) + existing_app = await db.applications.find_one({"client_id": client_id}) + if existing_app: + # Regenerate if collision + client_id = generate_client_id() + + # Prepare application document + app_doc = { + **app_data.model_dump(), + "client_id": client_id, + "client_secret": client_secret, + "created_by": str(current_user.id), + "created_at": datetime.utcnow(), + "updated_at": datetime.utcnow() + } + + # Insert application + result = await db.applications.insert_one(app_doc) + app_doc["_id"] = result.inserted_id + + return Application(**app_doc) + + +@router.get("/public/{client_id}", response_model=ApplicationPublic) +async def get_application_public(client_id: str): + """Get public application information (for OAuth flow)""" + db = get_database() + + app = await db.applications.find_one({"client_id": client_id, "is_active": True}) + if not app: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Application not found" + ) + + return ApplicationPublic(**app) + + +@router.get("/{app_id}", response_model=Application) +async def get_application(app_id: str, current_user: User = Depends(get_current_user)): + """Get application by ID""" + db = get_database() + + try: + app = await db.applications.find_one({"_id": ObjectId(app_id)}) + if not app: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Application not found" + ) + + # Check permissions + if str(app["created_by"]) != str(current_user.id) and current_user.role not in [UserRole.SYSTEM_ADMIN, UserRole.GROUP_ADMIN]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions" + ) + + return Application(**app) + + except Exception as e: + if isinstance(e, HTTPException): + raise + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid application ID" + ) + + +@router.put("/{app_id}", response_model=Application) +async def update_application( + app_id: str, + app_update: ApplicationUpdate, + current_user: User = Depends(get_current_user) +): + """Update application""" + db = get_database() + + try: + # Get existing application + app = await db.applications.find_one({"_id": ObjectId(app_id)}) + if not app: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Application not found" + ) + + # Check permissions + if str(app["created_by"]) != str(current_user.id) and current_user.role not in [UserRole.SYSTEM_ADMIN, UserRole.GROUP_ADMIN]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions" + ) + + # Prepare update data + update_data = app_update.model_dump(exclude_unset=True) + update_data["updated_at"] = datetime.utcnow() + + # Update application + result = await db.applications.update_one( + {"_id": ObjectId(app_id)}, + {"$set": update_data} + ) + + # Return updated application + app = await db.applications.find_one({"_id": ObjectId(app_id)}) + return Application(**app) + + except Exception as e: + if isinstance(e, HTTPException): + raise + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + + +@router.post("/{app_id}/regenerate-secret", response_model=Application) +async def regenerate_client_secret( + app_id: str, + current_user: User = Depends(get_current_user) +): + """Regenerate client secret""" + db = get_database() + + try: + # Get existing application + app = await db.applications.find_one({"_id": ObjectId(app_id)}) + if not app: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Application not found" + ) + + # Check permissions + if str(app["created_by"]) != str(current_user.id) and current_user.role not in [UserRole.SYSTEM_ADMIN, UserRole.GROUP_ADMIN]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions" + ) + + # Generate new secret + new_secret = generate_client_secret() + + # Update application + await db.applications.update_one( + {"_id": ObjectId(app_id)}, + {"$set": { + "client_secret": new_secret, + "updated_at": datetime.utcnow() + }} + ) + + # Return updated application + app = await db.applications.find_one({"_id": ObjectId(app_id)}) + return Application(**app) + + except Exception as e: + if isinstance(e, HTTPException): + raise + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + + +@router.delete("/{app_id}") +async def delete_application( + app_id: str, + current_user: User = Depends(get_current_user) +): + """Delete application""" + db = get_database() + + try: + # Get existing application + app = await db.applications.find_one({"_id": ObjectId(app_id)}) + if not app: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Application not found" + ) + + # Check permissions + if str(app["created_by"]) != str(current_user.id) and current_user.role != UserRole.SYSTEM_ADMIN: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions" + ) + + # Delete application + result = await db.applications.delete_one({"_id": ObjectId(app_id)}) + + return {"message": "Application deleted successfully"} + + except Exception as e: + if isinstance(e, HTTPException): + raise + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid application ID" + ) \ No newline at end of file diff --git a/oauth/backend/app/routers/auth.py b/oauth/backend/app/routers/auth.py new file mode 100644 index 0000000..061ffe3 --- /dev/null +++ b/oauth/backend/app/routers/auth.py @@ -0,0 +1,164 @@ +"""Authentication router""" + +from fastapi import APIRouter, Depends, HTTPException, status, Request +from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm +from typing import Optional +from app.models.user import User, UserLogin, Token, TokenRefresh, UserCreate +from app.models.auth_history import AuthAction +from app.services.auth_service import AuthService +from app.services.token_service import TokenService +from app.utils.security import decode_token + +router = APIRouter(prefix="/auth", tags=["Authentication"]) + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") + + +async def get_current_user(token: str = Depends(oauth2_scheme)) -> User: + """Get current authenticated user""" + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + payload = decode_token(token) + if payload is None or payload.get("type") != "access": + raise credentials_exception + + user = await AuthService.get_user_by_id(payload.get("sub")) + if user is None: + raise credentials_exception + + return user + + +@router.post("/register", response_model=User) +async def register(user_data: UserCreate, request: Request): + """Register a new user""" + try: + user = await AuthService.create_user(user_data) + + # Log registration + await AuthService.log_auth_action( + user_id=str(user.id), + action=AuthAction.REGISTER, + ip_address=request.client.host, + user_agent=request.headers.get("User-Agent") + ) + + return user + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + + +@router.post("/login", response_model=Token) +async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()): + """Login with username/email and password""" + user = await AuthService.authenticate_user(form_data.username, form_data.password) + + if not user: + # Log failed login attempt + await AuthService.log_auth_action( + user_id="unknown", + action=AuthAction.FAILED_LOGIN, + ip_address=request.client.host, + user_agent=request.headers.get("User-Agent"), + result="failed", + details={"username": form_data.username} + ) + + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Create tokens + tokens = await TokenService.create_tokens( + str(user.id), + { + "username": user.username, + "role": user.role, + "email": user.email + } + ) + + # Log successful login + await AuthService.log_auth_action( + user_id=str(user.id), + action=AuthAction.LOGIN, + ip_address=request.client.host, + user_agent=request.headers.get("User-Agent") + ) + + return tokens + + +@router.post("/refresh", response_model=Token) +async def refresh_token(request: Request, token_data: TokenRefresh): + """Refresh access token using refresh token""" + user_id = await TokenService.verify_refresh_token(token_data.refresh_token) + + if not user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid refresh token" + ) + + # Get user + user = await AuthService.get_user_by_id(user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found" + ) + + # Revoke old refresh token + await TokenService.revoke_refresh_token(token_data.refresh_token) + + # Create new tokens + tokens = await TokenService.create_tokens( + str(user.id), + { + "username": user.username, + "role": user.role, + "email": user.email + } + ) + + # Log token refresh + await AuthService.log_auth_action( + user_id=str(user.id), + action=AuthAction.TOKEN_REFRESH, + ip_address=request.client.host, + user_agent=request.headers.get("User-Agent") + ) + + return tokens + + +@router.post("/logout") +async def logout(request: Request, current_user: User = Depends(get_current_user)): + """Logout current user""" + # Revoke all user tokens + await TokenService.revoke_all_user_tokens(str(current_user.id)) + + # Log logout + await AuthService.log_auth_action( + user_id=str(current_user.id), + action=AuthAction.LOGOUT, + ip_address=request.client.host, + user_agent=request.headers.get("User-Agent") + ) + + return {"message": "Successfully logged out"} + + +@router.get("/me", response_model=User) +async def get_me(current_user: User = Depends(get_current_user)): + """Get current user information""" + return current_user \ No newline at end of file diff --git a/oauth/backend/app/routers/users.py b/oauth/backend/app/routers/users.py new file mode 100644 index 0000000..5a5bef3 --- /dev/null +++ b/oauth/backend/app/routers/users.py @@ -0,0 +1,156 @@ +"""Users management router""" + +from fastapi import APIRouter, Depends, HTTPException, status, Query +from typing import List, Optional +from bson import ObjectId +from app.models.user import User, UserUpdate, UserRole +from app.routers.auth import get_current_user +from app.utils.database import get_database +from app.utils.security import hash_password +from datetime import datetime + +router = APIRouter(prefix="/users", tags=["Users"]) + + +def require_admin(current_user: User = Depends(get_current_user)) -> User: + """Require admin role""" + if current_user.role not in [UserRole.SYSTEM_ADMIN, UserRole.GROUP_ADMIN]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions" + ) + return current_user + + +@router.get("/", response_model=List[User]) +async def get_users( + skip: int = Query(0, ge=0), + limit: int = Query(10, ge=1, le=100), + current_user: User = Depends(require_admin) +): + """Get all users (admin only)""" + db = get_database() + + users = await db.users.find().skip(skip).limit(limit).to_list(limit) + return [User(**user) for user in users] + + +@router.get("/{user_id}", response_model=User) +async def get_user(user_id: str, current_user: User = Depends(get_current_user)): + """Get user by ID""" + # Users can only view their own profile unless they're admin + if str(current_user.id) != user_id and current_user.role not in [UserRole.SYSTEM_ADMIN, UserRole.GROUP_ADMIN]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions" + ) + + db = get_database() + + try: + user = await db.users.find_one({"_id": ObjectId(user_id)}) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + return User(**user) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid user ID" + ) + + +@router.put("/{user_id}", response_model=User) +async def update_user( + user_id: str, + user_update: UserUpdate, + current_user: User = Depends(get_current_user) +): + """Update user""" + # Users can only update their own profile unless they're admin + if str(current_user.id) != user_id and current_user.role not in [UserRole.SYSTEM_ADMIN, UserRole.GROUP_ADMIN]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions" + ) + + # Only system admin can change roles + if user_update.role and current_user.role != UserRole.SYSTEM_ADMIN: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only system admin can change user roles" + ) + + db = get_database() + + try: + # Prepare update data + update_data = user_update.model_dump(exclude_unset=True) + + # Hash password if provided + if "password" in update_data: + update_data["hashed_password"] = hash_password(update_data.pop("password")) + + # Update timestamp + update_data["updated_at"] = datetime.utcnow() + + # Update user + result = await db.users.update_one( + {"_id": ObjectId(user_id)}, + {"$set": update_data} + ) + + if result.modified_count == 0: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + # Return updated user + user = await db.users.find_one({"_id": ObjectId(user_id)}) + return User(**user) + + except Exception as e: + if isinstance(e, HTTPException): + raise + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + + +@router.delete("/{user_id}") +async def delete_user( + user_id: str, + current_user: User = Depends(require_admin) +): + """Delete user (admin only)""" + # Prevent self-deletion + if str(current_user.id) == user_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot delete your own account" + ) + + db = get_database() + + try: + result = await db.users.delete_one({"_id": ObjectId(user_id)}) + + if result.deleted_count == 0: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + return {"message": "User deleted successfully"} + + except Exception as e: + if isinstance(e, HTTPException): + raise + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid user ID" + ) \ No newline at end of file diff --git a/oauth/backend/app/services/__init__.py b/oauth/backend/app/services/__init__.py new file mode 100644 index 0000000..789e8ad --- /dev/null +++ b/oauth/backend/app/services/__init__.py @@ -0,0 +1,4 @@ +"""Service layer for business logic""" + +from .auth_service import AuthService +from .token_service import TokenService \ No newline at end of file diff --git a/oauth/backend/app/services/auth_service.py b/oauth/backend/app/services/auth_service.py new file mode 100644 index 0000000..6a3d3e2 --- /dev/null +++ b/oauth/backend/app/services/auth_service.py @@ -0,0 +1,135 @@ +"""Authentication service""" + +from typing import Optional +from datetime import datetime, timedelta +from bson import ObjectId +from app.utils.database import get_database +from app.utils.security import verify_password, hash_password +from app.models.user import UserCreate, User, UserInDB, UserRole +from app.models.auth_history import AuthHistoryCreate, AuthAction +import logging + +logger = logging.getLogger(__name__) + + +class AuthService: + """Service for handling authentication logic""" + + @staticmethod + async def authenticate_user(username: str, password: str) -> Optional[UserInDB]: + """Authenticate a user with username/email and password""" + db = get_database() + + # Try to find user by username or email + user = await db.users.find_one({ + "$or": [ + {"username": username}, + {"email": username} + ] + }) + + if not user: + return None + + if not verify_password(password, user["hashed_password"]): + return None + + return UserInDB(**user) + + @staticmethod + async def create_user(user_data: UserCreate) -> User: + """Create a new user""" + db = get_database() + + # Check if user already exists + existing_user = await db.users.find_one({ + "$or": [ + {"email": user_data.email}, + {"username": user_data.username} + ] + }) + + if existing_user: + raise ValueError("User with this email or username already exists") + + # Hash password + hashed_password = hash_password(user_data.password) + + # Prepare user document + user_doc = { + **user_data.model_dump(exclude={"password"}), + "hashed_password": hashed_password, + "created_at": datetime.utcnow(), + "updated_at": datetime.utcnow() + } + + # Insert user + result = await db.users.insert_one(user_doc) + user_doc["_id"] = result.inserted_id + + return User(**user_doc) + + @staticmethod + async def get_user_by_id(user_id: str) -> Optional[User]: + """Get user by ID""" + db = get_database() + + try: + user = await db.users.find_one({"_id": ObjectId(user_id)}) + if user: + return User(**user) + except Exception as e: + logger.error(f"Error getting user by ID: {e}") + + return None + + @staticmethod + async def log_auth_action( + user_id: str, + action: AuthAction, + ip_address: str, + user_agent: Optional[str] = None, + application_id: Optional[str] = None, + result: str = "success", + details: Optional[dict] = None + ): + """Log authentication action""" + db = get_database() + + history_doc = { + "user_id": user_id, + "application_id": application_id, + "action": action.value, + "ip_address": ip_address, + "user_agent": user_agent, + "result": result, + "details": details, + "created_at": datetime.utcnow() + } + + await db.auth_history.insert_one(history_doc) + + @staticmethod + async def create_admin_user(): + """Create default admin user if not exists""" + from app.config import settings + + db = get_database() + + # Check if admin exists + admin = await db.users.find_one({"email": settings.admin_email}) + + if not admin: + admin_data = UserCreate( + email=settings.admin_email, + username="admin", + password=settings.admin_password, + full_name="System Administrator", + role=UserRole.SYSTEM_ADMIN + ) + + try: + await AuthService.create_user(admin_data) + logger.info("Admin user created successfully") + except ValueError: + logger.info("Admin user already exists") \ No newline at end of file diff --git a/oauth/backend/app/services/token_service.py b/oauth/backend/app/services/token_service.py new file mode 100644 index 0000000..fbb1ba7 --- /dev/null +++ b/oauth/backend/app/services/token_service.py @@ -0,0 +1,201 @@ +"""Token management service""" + +from typing import Optional, Dict, Any +from datetime import datetime, timedelta, timezone +from bson import ObjectId +from app.utils.database import get_database +from app.utils.security import create_access_token, create_refresh_token, decode_token +from app.models.user import Token +import logging +import secrets + +logger = logging.getLogger(__name__) + + +class TokenService: + """Service for handling token operations""" + + @staticmethod + async def create_tokens(user_id: str, user_data: Dict[str, Any]) -> Token: + """Create access and refresh tokens for a user""" + # Create access token + access_token = create_access_token( + data={ + "sub": user_id, + "username": user_data.get("username"), + "role": user_data.get("role"), + "email": user_data.get("email") + } + ) + + # Create refresh token + refresh_token = create_refresh_token( + data={ + "sub": user_id, + "username": user_data.get("username") + } + ) + + # Store refresh token in database + await TokenService.store_refresh_token(user_id, refresh_token) + + return Token( + access_token=access_token, + refresh_token=refresh_token, + token_type="bearer" + ) + + @staticmethod + async def store_refresh_token(user_id: str, token: str): + """Store refresh token in database""" + db = get_database() + + from app.config import settings + expires_at = datetime.utcnow() + timedelta( + days=settings.jwt_refresh_token_expire_days + ) + + token_doc = { + "token": token, + "user_id": user_id, + "created_at": datetime.utcnow(), + "expires_at": expires_at, + "is_active": True + } + + await db.refresh_tokens.insert_one(token_doc) + + @staticmethod + async def verify_refresh_token(token: str) -> Optional[str]: + """Verify refresh token and return user_id""" + db = get_database() + + # Decode token + payload = decode_token(token) + if not payload or payload.get("type") != "refresh": + return None + + # Check if token exists in database + token_doc = await db.refresh_tokens.find_one({ + "token": token, + "is_active": True + }) + + if not token_doc: + return None + + # Check if token is expired + if token_doc["expires_at"] < datetime.utcnow(): + # Mark token as inactive + await db.refresh_tokens.update_one( + {"_id": token_doc["_id"]}, + {"$set": {"is_active": False}} + ) + return None + + return payload.get("sub") + + @staticmethod + async def revoke_refresh_token(token: str): + """Revoke a refresh token""" + db = get_database() + + await db.refresh_tokens.update_one( + {"token": token}, + {"$set": {"is_active": False}} + ) + + @staticmethod + async def revoke_all_user_tokens(user_id: str): + """Revoke all refresh tokens for a user""" + db = get_database() + + await db.refresh_tokens.update_many( + {"user_id": user_id}, + {"$set": {"is_active": False}} + ) + + @staticmethod + async def create_authorization_code( + user_id: str, + client_id: str, + redirect_uri: str, + scope: str, + state: Optional[str] = None + ) -> str: + """Create and store authorization code""" + db = get_database() + + code = secrets.token_urlsafe(32) + expires_at = datetime.utcnow() + timedelta(minutes=10) # Code valid for 10 minutes + + code_doc = { + "code": code, + "user_id": user_id, + "client_id": client_id, + "redirect_uri": redirect_uri, + "scope": scope, + "state": state, + "created_at": datetime.utcnow(), + "expires_at": expires_at, + "used": False + } + + await db.authorization_codes.insert_one(code_doc) + + return code + + @staticmethod + async def exchange_authorization_code( + code: str, + client_id: str, + client_secret: str, + redirect_uri: str + ) -> Optional[Token]: + """Exchange authorization code for tokens""" + db = get_database() + + # Find and validate code + code_doc = await db.authorization_codes.find_one({ + "code": code, + "client_id": client_id, + "redirect_uri": redirect_uri, + "used": False + }) + + if not code_doc: + return None + + # Check if code is expired + if code_doc["expires_at"] < datetime.utcnow(): + return None + + # Validate client secret + app = await db.applications.find_one({ + "client_id": client_id, + "client_secret": client_secret + }) + + if not app: + return None + + # Mark code as used + await db.authorization_codes.update_one( + {"_id": code_doc["_id"]}, + {"$set": {"used": True}} + ) + + # Get user + user = await db.users.find_one({"_id": ObjectId(code_doc["user_id"])}) + if not user: + return None + + # Create tokens + return await TokenService.create_tokens( + str(user["_id"]), + { + "username": user["username"], + "role": user["role"], + "email": user["email"] + } + ) \ No newline at end of file diff --git a/oauth/backend/app/utils/__init__.py b/oauth/backend/app/utils/__init__.py new file mode 100644 index 0000000..46950a3 --- /dev/null +++ b/oauth/backend/app/utils/__init__.py @@ -0,0 +1 @@ +"""Utility modules for OAuth backend""" \ No newline at end of file diff --git a/oauth/backend/app/utils/database.py b/oauth/backend/app/utils/database.py new file mode 100644 index 0000000..e9faa7c --- /dev/null +++ b/oauth/backend/app/utils/database.py @@ -0,0 +1,87 @@ +"""MongoDB database connection and utilities""" + +from motor.motor_asyncio import AsyncIOMotorClient +from typing import Optional +from app.config import settings +import logging + +logger = logging.getLogger(__name__) + + +class Database: + client: Optional[AsyncIOMotorClient] = None + database = None + + +db = Database() + + +async def connect_database(): + """Create database connection""" + try: + db.client = AsyncIOMotorClient(settings.mongodb_url) + db.database = db.client[settings.database_name] + + # Test connection + await db.client.server_info() + logger.info("Successfully connected to MongoDB") + + # Create indexes + await create_indexes() + + except Exception as e: + logger.error(f"Failed to connect to MongoDB: {e}") + raise + + +async def disconnect_database(): + """Close database connection""" + if db.client: + db.client.close() + logger.info("Disconnected from MongoDB") + + +async def create_indexes(): + """Create database indexes for better performance""" + try: + # Users collection indexes + users_collection = db.database["users"] + await users_collection.create_index("email", unique=True) + await users_collection.create_index("username", unique=True) + await users_collection.create_index("created_at") + + # Applications collection indexes + apps_collection = db.database["applications"] + await apps_collection.create_index("client_id", unique=True) + await apps_collection.create_index("created_by") + await apps_collection.create_index("created_at") + + # Auth history collection indexes + history_collection = db.database["auth_history"] + await history_collection.create_index("user_id") + await history_collection.create_index("application_id") + await history_collection.create_index("created_at") + await history_collection.create_index( + [("created_at", 1)], + expireAfterSeconds=2592000 # 30 days + ) + + # Refresh tokens collection indexes + tokens_collection = db.database["refresh_tokens"] + await tokens_collection.create_index("token", unique=True) + await tokens_collection.create_index("user_id") + await tokens_collection.create_index("expires_at") + await tokens_collection.create_index( + [("expires_at", 1)], + expireAfterSeconds=0 + ) + + logger.info("Database indexes created successfully") + + except Exception as e: + logger.error(f"Failed to create indexes: {e}") + + +def get_database(): + """Get database instance""" + return db.database \ No newline at end of file diff --git a/oauth/backend/app/utils/security.py b/oauth/backend/app/utils/security.py new file mode 100644 index 0000000..241b594 --- /dev/null +++ b/oauth/backend/app/utils/security.py @@ -0,0 +1,93 @@ +"""Security utilities for password hashing and JWT tokens""" + +from datetime import datetime, timedelta, timezone +from typing import Optional, Dict, Any +from passlib.context import CryptContext +from jose import JWTError, jwt +from app.config import settings +import secrets +import string + +# Password hashing context +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verify a plain password against a hashed password""" + return pwd_context.verify(plain_password, hashed_password) + + +def hash_password(password: str) -> str: + """Hash a password""" + return pwd_context.hash(password) + + +def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str: + """Create a JWT access token""" + to_encode = data.copy() + + if expires_delta: + expire = datetime.now(timezone.utc) + expires_delta + else: + expire = datetime.now(timezone.utc) + timedelta( + minutes=settings.jwt_access_token_expire_minutes + ) + + to_encode.update({"exp": expire, "type": "access"}) + encoded_jwt = jwt.encode( + to_encode, + settings.secret_key, + algorithm=settings.jwt_algorithm + ) + return encoded_jwt + + +def create_refresh_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str: + """Create a JWT refresh token""" + to_encode = data.copy() + + if expires_delta: + expire = datetime.now(timezone.utc) + expires_delta + else: + expire = datetime.now(timezone.utc) + timedelta( + days=settings.jwt_refresh_token_expire_days + ) + + to_encode.update({"exp": expire, "type": "refresh"}) + encoded_jwt = jwt.encode( + to_encode, + settings.secret_key, + algorithm=settings.jwt_algorithm + ) + return encoded_jwt + + +def decode_token(token: str) -> Optional[Dict[str, Any]]: + """Decode and verify a JWT token""" + try: + payload = jwt.decode( + token, + settings.secret_key, + algorithms=[settings.jwt_algorithm] + ) + return payload + except JWTError: + return None + + +def generate_client_secret() -> str: + """Generate a secure client secret""" + alphabet = string.ascii_letters + string.digits + return ''.join(secrets.choice(alphabet) for _ in range(32)) + + +def generate_client_id() -> str: + """Generate a unique client ID""" + alphabet = string.ascii_lowercase + string.digits + return ''.join(secrets.choice(alphabet) for _ in range(16)) + + +def generate_authorization_code() -> str: + """Generate a secure authorization code""" + alphabet = string.ascii_letters + string.digits + return ''.join(secrets.choice(alphabet) for _ in range(32)) \ No newline at end of file diff --git a/oauth/backend/requirements.txt b/oauth/backend/requirements.txt new file mode 100644 index 0000000..84e2775 --- /dev/null +++ b/oauth/backend/requirements.txt @@ -0,0 +1,36 @@ +# Core +fastapi==0.109.0 +uvicorn[standard]==0.27.0 +python-multipart==0.0.6 + +# Database +motor==3.3.2 +pymongo==4.6.1 + +# Redis +redis==5.0.1 +aioredis==2.0.1 + +# Security +passlib[bcrypt]==1.7.4 +python-jose[cryptography]==3.3.0 +cryptography==41.0.7 + +# Validation +pydantic==2.5.3 +pydantic-settings==2.1.0 +email-validator==2.1.0 + +# CORS +python-dotenv==1.0.0 + +# Date/Time +python-dateutil==2.8.2 + +# Testing +pytest==7.4.4 +pytest-asyncio==0.23.3 +httpx==0.26.0 + +# Logging +loguru==0.7.2 \ No newline at end of file