Your page content goes here
+//diff --git a/docker-compose-server.yml b/docker-compose-server.yml index 313563a1..65714cd7 100644 --- a/docker-compose-server.yml +++ b/docker-compose-server.yml @@ -65,6 +65,16 @@ services: POSTGRES_PORT: ${POSTGRES_PORT:-5432} POSTGRES_DB: ${POSTGRES_DB:-mlmd} REACT_APP_CMF_API_URL: ${REACT_APP_CMF_API_URL} + # OAuth Configuration + GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} + GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET} + GOOGLE_REDIRECT_URI: ${GOOGLE_REDIRECT_URI} + SECRET_KEY: ${SECRET_KEY} + ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60} + FRONTEND_URL: ${FRONTEND_URL} + AUTH_ENABLED: ${AUTH_ENABLED:-true} + COOKIE_SECURE: ${COOKIE_SECURE:-false} + ALLOWED_EMAIL_DOMAINS: ${ALLOWED_EMAIL_DOMAINS:-} healthcheck: test: ["CMD-SHELL", "curl -f http://localhost:8080 || exit 1"] interval: 15s diff --git a/env-example b/env-example index 592985ab..3dc91e6d 100644 --- a/env-example +++ b/env-example @@ -18,6 +18,39 @@ POSTGRES_DB=mlmd # Directory for persistent data storage CMF_DATA_DIR=/home/kulkashr/sk/ +# ======================================== +# OAuth2 Authentication Configuration +# ======================================== + +# Google OAuth2 Credentials (REQUIRED for Google Login) +# Get these from: https://console.cloud.google.com/apis/credentials +GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=your-google-client-secret +GOOGLE_REDIRECT_URI=http://localhost:8080/api/auth/google/callback + +# JWT Secret Key (REQUIRED - Generate a secure random string) +# Generate with: python -c "import secrets; print(secrets.token_urlsafe(32))" +SECRET_KEY=your-secret-key-change-this-in-production + +# Session Configuration (OPTIONAL - defaults provided) +ACCESS_TOKEN_EXPIRE_MINUTES=60 +REFRESH_TOKEN_EXPIRE_DAYS=30 + +# Frontend URL (REQUIRED for OAuth callback) +#FRONTEND_URL=http://localhost:3000 + +# Authentication Toggle (OPTIONAL - set to false to disable auth) +AUTH_ENABLED=true + +# Email Domain Whitelist (OPTIONAL - comma separated, leave empty to allow all) +# Example: ALLOWED_EMAIL_DOMAINS=company.com,partner.com +ALLOWED_EMAIL_DOMAINS= + +# Cookie Security (OPTIONAL - set to true in production with HTTPS) +COOKIE_SECURE=false + +# ======================================== + #USER=frist #UID=123456789 diff --git a/server/app/auth_config.py b/server/app/auth_config.py new file mode 100644 index 00000000..25aff68d --- /dev/null +++ b/server/app/auth_config.py @@ -0,0 +1,47 @@ +""" +OAuth2 Authentication Configuration for CMF Server +""" +import os +from datetime import timedelta +from dotenv import load_dotenv + +load_dotenv() + +# Google OAuth2 Settings (REQUIRED) +GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID", "") +GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET", "") +GOOGLE_REDIRECT_URI = os.getenv("GOOGLE_REDIRECT_URI", "http://localhost:8080/api/auth/google/callback") + +# JWT Settings for Session Management (REQUIRED) +SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-this-in-production") +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "60")) # 1 hour +REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv("REFRESH_TOKEN_EXPIRE_DAYS", "30")) # 30 days + +# Session Cookie Settings +COOKIE_NAME = "cmf_session" +COOKIE_MAX_AGE = ACCESS_TOKEN_EXPIRE_MINUTES * 60 # in seconds +COOKIE_SECURE = os.getenv("COOKIE_SECURE", "false").lower() == "true" # True for HTTPS only +COOKIE_HTTPONLY = True # Prevents JavaScript access +COOKIE_SAMESITE = "lax" # CSRF protection + +# Frontend URL (REQUIRED) +FRONTEND_URL = os.getenv("REACT_APP_CMF_API_URL", "http://localhost:3000") + +# Optional: Enable/Disable Authentication +AUTH_ENABLED = os.getenv("AUTH_ENABLED", "true").lower() == "true" + +# Optional: Whitelist of allowed email domains (e.g., only allow company emails) +ALLOWED_EMAIL_DOMAINS = os.getenv("ALLOWED_EMAIL_DOMAINS", "").split(",") if os.getenv("ALLOWED_EMAIL_DOMAINS") else [] + +# OAuth2 Configuration +GOOGLE_AUTHORIZE_URL = "https://accounts.google.com/o/oauth2/v2/auth" +GOOGLE_ACCESS_TOKEN_URL = "https://oauth2.googleapis.com/token" +GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo" + +# Scopes +GOOGLE_SCOPES = [ + "openid", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile" +] diff --git a/server/app/auth_middleware.py b/server/app/auth_middleware.py new file mode 100644 index 00000000..5b4e219a --- /dev/null +++ b/server/app/auth_middleware.py @@ -0,0 +1,77 @@ +# """ +# Authentication middleware for protecting API routes +# """ +# from fastapi import Request, HTTPException, status +# from starlette.middleware.base import BaseHTTPMiddleware +# from server.app.auth_utils import verify_token +# from server.app.auth_config import COOKIE_NAME, AUTH_ENABLED + +# # Routes that don't require authentication +# PUBLIC_ROUTES = [ +# "/api/", +# "/api/auth/login/google", +# "/api/auth/google/callback", +# "/api/auth/status", +# "/api/auth/me", +# "/cmf-server/data/static" +# ] + + +# class AuthenticationMiddleware(BaseHTTPMiddleware): +# """ +# Middleware to enforce authentication on protected routes +# """ + +# async def dispatch(self, request: Request, call_next): +# # Skip authentication if disabled +# if not AUTH_ENABLED: +# return await call_next(request) + +# # Check if route is public +# path = request.url.path +# if any(path.startswith(route) for route in PUBLIC_ROUTES): +# return await call_next(request) + +# # Check for authentication cookie +# token = request.cookies.get(COOKIE_NAME) + +# if not token: +# raise HTTPException( +# status_code=status.HTTP_401_UNAUTHORIZED, +# detail="Authentication required" +# ) + +# # Verify token +# try: +# payload = verify_token(token) +# # Add user info to request state +# request.state.user = payload +# except HTTPException: +# raise HTTPException( +# status_code=status.HTTP_401_UNAUTHORIZED, +# detail="Invalid or expired session" +# ) + +# response = await call_next(request) +# return response + + +# def get_authenticated_user(request: Request) -> dict: +# """ +# Dependency to get authenticated user from request + +# Args: +# request: FastAPI Request object + +# Returns: +# User information dictionary + +# Raises: +# HTTPException: If user is not authenticated +# """ +# if not hasattr(request.state, "user"): +# raise HTTPException( +# status_code=status.HTTP_401_UNAUTHORIZED, +# detail="Authentication required" +# ) +# return request.state.user diff --git a/server/app/auth_routes.py b/server/app/auth_routes.py new file mode 100644 index 00000000..8486abe1 --- /dev/null +++ b/server/app/auth_routes.py @@ -0,0 +1,168 @@ +""" +OAuth2 Authentication Routes for Google Login +""" +from fastapi import APIRouter, Request, HTTPException +from fastapi.responses import RedirectResponse, JSONResponse +from authlib.integrations.starlette_client import OAuth +from starlette.config import Config +from server.app.auth_config import ( + GOOGLE_CLIENT_ID, + GOOGLE_CLIENT_SECRET, + GOOGLE_REDIRECT_URI, + GOOGLE_SCOPES, + FRONTEND_URL, + AUTH_ENABLED +) +from server.app.auth_utils import ( + create_access_token, + set_auth_cookie, + clear_auth_cookie, + get_current_user_from_cookie, + validate_email_domain, + create_user_session_data +) +from typing import Dict, Any + +# Create router +router = APIRouter(prefix="/auth", tags=["authentication"]) + +# Configure OAuth +config = Config(environ={ + "GOOGLE_CLIENT_ID": GOOGLE_CLIENT_ID, + "GOOGLE_CLIENT_SECRET": GOOGLE_CLIENT_SECRET +}) + +oauth = OAuth(config) + +# Register Google OAuth provider +oauth.register( + name='google', + server_metadata_url='https://accounts.google.com/.well-known/openid-configuration', + client_kwargs={ + 'scope': ' '.join(GOOGLE_SCOPES) + } +) + + +@router.get("/login/google") +async def login_google(request: Request): + """ + Initiate Google OAuth login flow + + Returns: + Redirect to Google's authorization page + """ + if not AUTH_ENABLED: + raise HTTPException(status_code=403, detail="Authentication is disabled") + + if not GOOGLE_CLIENT_ID or not GOOGLE_CLIENT_SECRET: + raise HTTPException( + status_code=500, + detail="Google OAuth not configured properly" + ) + + redirect_uri = GOOGLE_REDIRECT_URI + return await oauth.google.authorize_redirect(request, redirect_uri) + + +@router.get("/google/callback") +async def google_callback(request: Request): + """ + Handle Google OAuth callback + + Returns: + Redirect to frontend with authentication cookie set + """ + try: + # Get access token from Google + token = await oauth.google.authorize_access_token(request) + + # Get user info from Google + user_info = token.get('userinfo') + if not user_info: + # If userinfo not in token, fetch it + resp = await oauth.google.get('https://www.googleapis.com/oauth2/v3/userinfo', token=token) + user_info = resp.json() + + # Validate email domain if configured + email = user_info.get('email') + if not validate_email_domain(email): + return RedirectResponse( + url=f"{FRONTEND_URL}/?error=unauthorized_domain", + status_code=302 + ) + + # Create user session data + session_data = create_user_session_data(user_info) + + # Create JWT token + access_token = create_access_token(data=session_data) + + # Redirect to frontend with cookie + response = RedirectResponse(url=f"{FRONTEND_URL}/?auth=success", status_code=302) + set_auth_cookie(response, access_token) + + return response + + except Exception as e: + print(f"OAuth callback error: {str(e)}") + return RedirectResponse( + url=f"{FRONTEND_URL}/?error=auth_failed", + status_code=302 + ) + + +@router.post("/logout") +async def logout(request: Request): + """ + Logout user and clear session cookie + + Returns: + Success response with cleared cookie + """ + response = JSONResponse(content={"message": "Logged out successfully"}) + clear_auth_cookie(response) + return response + + +@router.get("/me") +async def get_current_user(request: Request): + """ + Get current authenticated user information + + Returns: + User information from session + + Raises: + HTTPException: If user is not authenticated + """ + try: + user = get_current_user_from_cookie(request) + return { + "authenticated": True, + "user": { + "email": user.get("email"), + "name": user.get("name"), + "picture": user.get("picture"), + "email_verified": user.get("email_verified", False) + } + } + except HTTPException: + return { + "authenticated": False, + "user": None + } + + +@router.get("/status") +async def auth_status(): + """ + Check if authentication is enabled + + Returns: + Authentication configuration status + """ + return { + "auth_enabled": AUTH_ENABLED, + "google_oauth_configured": bool(GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET) + } diff --git a/server/app/auth_utils.py b/server/app/auth_utils.py new file mode 100644 index 00000000..4e21fccf --- /dev/null +++ b/server/app/auth_utils.py @@ -0,0 +1,164 @@ +""" +Authentication utilities for JWT token management and user sessions +""" +from datetime import datetime, timedelta +from typing import Optional, Dict, Any +from jose import JWTError, jwt +from fastapi import HTTPException, status, Request, Response +from server.app.auth_config import ( + SECRET_KEY, + ALGORITHM, + ACCESS_TOKEN_EXPIRE_MINUTES, + COOKIE_NAME, + COOKIE_MAX_AGE, + COOKIE_SECURE, + COOKIE_HTTPONLY, + COOKIE_SAMESITE, + ALLOWED_EMAIL_DOMAINS +) + + +def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str: + """ + Create JWT access token with user information + + Args: + data: Dictionary containing user information (email, name, picture, etc.) + expires_delta: Optional expiration time delta + + Returns: + Encoded JWT token string + """ + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + + to_encode.update({ + "exp": expire, + "iat": datetime.utcnow() + }) + + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + + +def verify_token(token: str) -> Dict[str, Any]: + """ + Verify and decode JWT token + + Args: + token: JWT token string + + Returns: + Decoded token payload + + Raises: + HTTPException: If token is invalid or expired + """ + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + return payload + except JWTError: + raise credentials_exception + + +def get_current_user_from_cookie(request: Request) -> Dict[str, Any]: + """ + Extract and verify user from session cookie + + Args: + request: FastAPI Request object + + Returns: + User information dictionary + + Raises: + HTTPException: If no valid session found + """ + token = request.cookies.get(COOKIE_NAME) + + if not token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated" + ) + + return verify_token(token) + + +def set_auth_cookie(response: Response, token: str) -> None: + """ + Set authentication cookie in response + + Args: + response: FastAPI Response object + token: JWT token to store in cookie + """ + response.set_cookie( + key=COOKIE_NAME, + value=token, + max_age=COOKIE_MAX_AGE, + httponly=COOKIE_HTTPONLY, + secure=COOKIE_SECURE, + samesite=COOKIE_SAMESITE + ) + + +def clear_auth_cookie(response: Response) -> None: + """ + Clear authentication cookie (for logout) + + Args: + response: FastAPI Response object + """ + response.delete_cookie( + key=COOKIE_NAME, + httponly=COOKIE_HTTPONLY, + secure=COOKIE_SECURE, + samesite=COOKIE_SAMESITE + ) + + +def validate_email_domain(email: str) -> bool: + """ + Validate if email domain is in allowed list (if configured) + + Args: + email: User email address + + Returns: + True if email is allowed, False otherwise + """ + if not ALLOWED_EMAIL_DOMAINS or not ALLOWED_EMAIL_DOMAINS[0]: + # If no domain restrictions, allow all + return True + + domain = email.split("@")[-1] + return domain in ALLOWED_EMAIL_DOMAINS + + +def create_user_session_data(google_user_info: Dict[str, Any]) -> Dict[str, Any]: + """ + Extract and format user session data from Google user info + + Args: + google_user_info: User information from Google OAuth + + Returns: + Formatted user session dictionary + """ + return { + "email": google_user_info.get("email"), + "name": google_user_info.get("name"), + "picture": google_user_info.get("picture"), + "email_verified": google_user_info.get("email_verified", False), + "sub": google_user_info.get("sub") # Google user ID + } diff --git a/server/app/main.py b/server/app/main.py index e646325b..cb30d4c8 100644 --- a/server/app/main.py +++ b/server/app/main.py @@ -6,6 +6,7 @@ from fastapi.responses import HTMLResponse, PlainTextResponse, StreamingResponse from fastapi.staticfiles import StaticFiles from fastapi.middleware.cors import CORSMiddleware +from starlette.middleware.sessions import SessionMiddleware from contextlib import asynccontextmanager import pandas as pd from typing import List, Dict, Any, Optional @@ -53,6 +54,10 @@ import dotenv from jsonpath_ng.ext import parse from cmflib.cmf_federation import update_mlmd +# Import authentication routes +from server.app.auth_routes import router as auth_router +# Optional: Import authentication middleware (uncomment to enable) +#from server.app.auth_middleware import AuthenticationMiddleware dotenv.load_dotenv() @@ -84,6 +89,16 @@ async def lifespan(app: FastAPI): app = FastAPI(title="cmf-server", lifespan=lifespan, root_path="/api") +# Add Session Middleware (REQUIRED for OAuth) +# This must be added BEFORE CORS middleware +app.add_middleware( + SessionMiddleware, + secret_key=os.getenv("SECRET_KEY", "your-secret-key-change-this-in-production"), + max_age=3600, # 1 hour + same_site="lax", + https_only=False # Set to True in production with HTTPS +) + # Add CORS middleware app.add_middleware( CORSMiddleware, @@ -93,6 +108,12 @@ async def lifespan(app: FastAPI): allow_headers=["*"], # Allows all headers ) +# Optional: Add authentication middleware (uncomment to enable route protection) +# app.add_middleware(AuthenticationMiddleware) + +# Include authentication routes +app.include_router(auth_router) + BASE_PATH = Path(__file__).resolve().parent app.mount("/cmf-server/data/static", StaticFiles(directory="/cmf-server/data/static"), name="static") diff --git a/server/requirements.txt b/server/requirements.txt index c671165a..eb644a28 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -10,3 +10,6 @@ python-dotenv psycopg[binary] httpx jsonpath_ng +authlib>=1.3.0 +python-jose[cryptography]>=3.3.0 +itsdangerous>=2.1.0 diff --git a/ui/package.json b/ui/package.json index 2a965fe2..1b13230d 100644 --- a/ui/package.json +++ b/ui/package.json @@ -21,7 +21,8 @@ "react-table": "^7.8.0", "web-vitals": "^2.1.4", "react-data-table-component": "^7.7.0", - "papaparse": "^5.5.2" + "papaparse": "^5.5.2", + "js-cookie": "^3.0.5" }, "scripts": { "start": "react-scripts start", diff --git a/ui/src/App.js b/ui/src/App.js index 8b6ef6d4..a3c0a9da 100644 --- a/ui/src/App.js +++ b/ui/src/App.js @@ -16,7 +16,10 @@ import React from "react"; import { BrowserRouter, Routes, Route } from "react-router-dom"; +import { AuthProvider } from "./contexts/AuthContext"; +import ProtectedRoute from "./components/ProtectedRoute"; import Home from "./pages/home"; +import Login from "./pages/login"; import Lineage from "./pages/lineage"; import TensorBoard from "./pages/tensorboard"; import Metahub from "./pages/metahub"; @@ -28,14 +31,64 @@ function App() { return (
Your page content goes here
+//Common Metadata Framework
++ Sign in with your Google account to access the CMF Server dashboard. +
+© 2023 Hewlett Packard Enterprise Development LP
+