diff --git a/GOOGLE_OAUTH_SETUP.md b/GOOGLE_OAUTH_SETUP.md new file mode 100644 index 0000000..f6c905f --- /dev/null +++ b/GOOGLE_OAUTH_SETUP.md @@ -0,0 +1,110 @@ +# Google OAuth Setup Guide + +## Prerequisites +1. A Google Cloud Platform account +2. A project created in Google Cloud Console + +## Step 1: Create OAuth 2.0 Credentials + +1. Go to [Google Cloud Console](https://console.cloud.google.com/) +2. Select your project (or create a new one) +3. Navigate to **APIs & Services** > **Credentials** +4. Click **Create Credentials** > **OAuth 2.0 Client ID** +5. If prompted, configure the OAuth consent screen: + - Choose **External** user type + - Fill in the required fields: + - App name: Tasko + - User support email: Your email + - Developer contact information: Your email + - Add scopes (optional): `userinfo.email`, `userinfo.profile` + - Add test users if needed +6. Select **Application type**: Web application +7. Add **Authorized redirect URIs**: + - For local development: `http://localhost:8000/auth/google/callback` + - For production: `https://your-domain.com/auth/google/callback` +8. Click **Create** +9. Copy the **Client ID** and **Client Secret** + +## Step 2: Configure Backend + +1. Copy `.env.example` to `.env` in the backend directory: + ```bash + cd backend + cp .env.example .env + ``` + +2. Edit `.env` and add your Google OAuth credentials: + ```env + GOOGLE_CLIENT_ID=your_client_id_here.apps.googleusercontent.com + GOOGLE_CLIENT_SECRET=your_client_secret_here + GOOGLE_REDIRECT_URI=http://localhost:8000/auth/google/callback + FRONTEND_URL=http://localhost:5173 + ``` + +3. For production, update the URLs: + ```env + GOOGLE_REDIRECT_URI=https://api.tasko.dvirlabs.com/auth/google/callback + FRONTEND_URL=https://tasko.dvirlabs.com + ``` + +## Step 3: Install Dependencies + +```bash +cd backend +pip install -r requirements.txt +``` + +## Step 4: Test the Setup + +1. Start the backend server: + ```bash + cd backend + uvicorn main:app --reload + ``` + +2. Start the frontend: + ```bash + cd frontend + npm run dev + ``` + +3. Open your browser and navigate to the frontend URL +4. Click "Continue with Google" button +5. Complete the Google authentication flow + +## Features Implemented + +### 1. Case-Insensitive Username Login +- Users can now login with usernames in any case (e.g., "Dvir", "dvir", "DVIR" all work) +- Registration checks for existing usernames case-insensitively to prevent duplicates + +### 2. Google OAuth Integration +- Users can sign up and login using their Google account +- New users are automatically created with: + - Username derived from email (before @) + - Email from Google account + - Default task lists (Personal, Work, Shopping) +- Existing users (matched by email) can login with Google +- Seamless redirect back to the application after authentication + +## Troubleshooting + +### "redirect_uri_mismatch" Error +- Ensure the redirect URI in Google Cloud Console exactly matches the one in your `.env` file +- Include the protocol (http/https), domain, port, and full path + +### "Access blocked" Error +- Add your email as a test user in the OAuth consent screen +- If published, ensure your app is verified by Google + +### "Invalid credentials" Error +- Check that your `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` are correct +- Ensure there are no extra spaces or quotes in the `.env` file + +## Security Notes + +1. Never commit your `.env` file to version control +2. Keep your `GOOGLE_CLIENT_SECRET` secure +3. Use HTTPS in production +4. Regularly rotate your OAuth credentials +5. Review and limit the OAuth scopes to only what's needed diff --git a/SETUP_COMPLETE.md b/SETUP_COMPLETE.md new file mode 100644 index 0000000..b450e95 --- /dev/null +++ b/SETUP_COMPLETE.md @@ -0,0 +1,51 @@ +# Quick Google OAuth Setup Instructions + +Your Google OAuth credentials have been configured in the backend! + +## ā Already Configured +- Client ID: `143092846986-b7fv9kucjugh9h5ojq60e1e44em57n1h.apps.googleusercontent.com` +- Client Secret: `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` +- Backend `.env` file created +- All dependencies installed + +## š§ Required: Google Cloud Console Setup + +**IMPORTANT:** You need to add the redirect URI in your Google Cloud Console: + +1. Go to: https://console.cloud.google.com/apis/credentials +2. Find your OAuth 2.0 Client ID: `143092846986-b7fv9kucjugh9h5ojq60e1e44em57n1h` +3. Click **Edit** (pencil icon) +4. Under **Authorized redirect URIs**, add: + ``` + http://localhost:8000/auth/google/callback + ``` +5. Click **Save** + +## š Test the Application + +### Start Backend: +```bash +cd backend +python -m uvicorn main:app --reload --host 0.0.0.0 --port 8000 +``` + +### Start Frontend (in another terminal): +```bash +cd frontend +npm run dev +``` + +### Test: +1. Open http://localhost:5173 +2. Try logging in with username (case-insensitive: "Dvir" = "dvir" = "DVIR") +3. Click **Continue with Google** to test OAuth login + +## š Features Implemented +- ā Case-insensitive username login +- ā Google OAuth login/signup +- ā Automatic user creation for new Google accounts +- ā Secure credential storage in `.env` + +## ā ļø Security Note +The `.env` file contains sensitive credentials and is excluded from git via `.gitignore`. +Never commit this file to your repository! diff --git a/backend/.env.example b/backend/.env.example index 84fc4b7..2d0d36d 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,2 +1,11 @@ PORT=8001 HOST=0.0.0.0 + +# Database Configuration +DATABASE_URL=postgresql://tasko_user:tasko_password@localhost:5432/tasko_db + +# Google OAuth Configuration +GOOGLE_CLIENT_ID=your_google_client_id_here.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=your_google_client_secret_here +GOOGLE_REDIRECT_URI=http://localhost:8000/auth/google/callback +FRONTEND_URL=http://localhost:5173 diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..98916e9 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,44 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +ENV/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environment variables +.env +.env.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Database +*.db +*.sqlite +*.sqlite3 + +# OS +.DS_Store +Thumbs.db diff --git a/backend/__pycache__/database.cpython-312.pyc b/backend/__pycache__/database.cpython-312.pyc deleted file mode 100644 index 71b60f8..0000000 Binary files a/backend/__pycache__/database.cpython-312.pyc and /dev/null differ diff --git a/backend/__pycache__/database.cpython-313.pyc b/backend/__pycache__/database.cpython-313.pyc deleted file mode 100644 index d438eda..0000000 Binary files a/backend/__pycache__/database.cpython-313.pyc and /dev/null differ diff --git a/backend/__pycache__/main.cpython-312.pyc b/backend/__pycache__/main.cpython-312.pyc deleted file mode 100644 index 67fafe7..0000000 Binary files a/backend/__pycache__/main.cpython-312.pyc and /dev/null differ diff --git a/backend/main.py b/backend/main.py index c73c5e9..2347c46 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,11 +1,18 @@ -from fastapi import FastAPI, HTTPException, Header, Depends +from fastapi import FastAPI, HTTPException, Header, Depends, Request from fastapi.middleware.cors import CORSMiddleware -from pydantic import BaseModel, Field +from fastapi.responses import RedirectResponse +from starlette.middleware.sessions import SessionMiddleware +from pydantic import BaseModel, Field, ConfigDict from typing import List, Optional from datetime import datetime from sqlalchemy.orm import Session +from authlib.integrations.starlette_client import OAuth import uuid import hashlib +import os +from dotenv import load_dotenv + +load_dotenv() from database import init_db, get_db import database as db_models @@ -15,44 +22,97 @@ app = FastAPI(title="Tasko API", version="1.0.0") # Initialize database init_db() +# OAuth Configuration +oauth = OAuth() +oauth.register( + name='google', + client_id=os.getenv('GOOGLE_CLIENT_ID', ''), + client_secret=os.getenv('GOOGLE_CLIENT_SECRET', ''), + server_metadata_url='https://accounts.google.com/.well-known/openid-configuration', + client_kwargs={ + 'scope': 'openid email profile' + } +) + allowed_origins = ["http://localhost:5173", "https://tasko.dvirlabs.com"] -# Configure CORS +# Environment-aware configuration +ENVIRONMENT = os.getenv('ENVIRONMENT', 'development') +is_production = ENVIRONMENT == 'production' +is_development = ENVIRONMENT == 'development' + +# Configure Session Middleware (required for OAuth state/nonce storage) +# Generate a strong SECRET_KEY: python -c "import secrets; print(secrets.token_hex(32))" +SESSION_SECRET = os.getenv('SESSION_SECRET', 'dev-secret-change-in-production') + +# Session cookie configuration - environment aware +session_config = { + "secret_key": SESSION_SECRET, + "session_cookie": "tasko_session", + "max_age": 3600, # 1 hour + "path": "/", # Available on all routes (required for OAuth callback) + "same_site": "lax", # Allows OAuth redirects while preventing CSRF + "https_only": is_production, # False for HTTP (dev), True for HTTPS (prod) +} + +# Development-specific: Ensure cookies work on localhost +if is_development: + session_config["domain"] = None # Don't set domain for localhost + +app.add_middleware(SessionMiddleware, **session_config) + +# Log session configuration in development +if is_development: + print(f"š Session Configuration (Development Mode):") + print(f" - Cookie Name: {session_config['session_cookie']}") + print(f" - Path: {session_config['path']}") + print(f" - SameSite: {session_config['same_site']}") + print(f" - HTTPS Only: {session_config['https_only']}") + print(f" - Domain: {session_config.get('domain', 'None (localhost)')}") + +# Configure CORS - MUST use specific origins (not "*") when allow_credentials=True app.add_middleware( CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, + allow_origins=allowed_origins, # Specific origins required for credentials + allow_credentials=True, # Required for session cookies allow_methods=["*"], allow_headers=["*"], + expose_headers=["*"], # Allow frontend to read all response headers ) +# Log startup info +if is_development: + print(f"š Tasko API starting in DEVELOPMENT mode") + print(f" - CORS Origins: {allowed_origins}") + print(f" - Allow Credentials: True (session cookies enabled)") + # Pydantic Models for API class UserResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: str username: str email: str created_at: datetime - class Config: - from_attributes = True - class UserRegister(BaseModel): username: str email: str password: str class UserLogin(BaseModel): + model_config = ConfigDict(populate_by_name=True) + username_or_email: str = Field(..., alias='usernameOrEmail') password: str - - class Config: - populate_by_name = True class AuthResponse(BaseModel): user: UserResponse token: str class TaskListResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: str user_id: str name: str @@ -60,9 +120,6 @@ class TaskListResponse(BaseModel): color: str = "#667eea" created_at: datetime - class Config: - from_attributes = True - class TaskListCreate(BaseModel): name: str icon: Optional[str] = "š" @@ -74,6 +131,8 @@ class TaskListUpdate(BaseModel): color: Optional[str] = None class TaskResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: str list_id: str user_id: str @@ -84,9 +143,6 @@ class TaskResponse(BaseModel): created_at: datetime updated_at: datetime - class Config: - from_attributes = True - class TaskCreate(BaseModel): title: str list_id: str @@ -122,9 +178,9 @@ def read_root(): @app.post("/register", response_model=AuthResponse) def register(user_data: UserRegister, db: Session = Depends(get_db)): """Register a new user""" - # Check if username or email exists + # Check if username or email exists (case-insensitive for username) existing_user = db.query(db_models.User).filter( - (db_models.User.username == user_data.username) | + (db_models.User.username.ilike(user_data.username)) | (db_models.User.email == user_data.email) ).first() @@ -180,10 +236,10 @@ def login(user_data: UserLogin, db: Session = Depends(get_db)): """Login a user with username or email""" password_hash = hash_password(user_data.password) - # Try to find user by email or username + # Try to find user by email or username (case-insensitive for username) user = db.query(db_models.User).filter( ((db_models.User.email == user_data.username_or_email) | - (db_models.User.username == user_data.username_or_email)), + (db_models.User.username.ilike(user_data.username_or_email))), db_models.User.password_hash == password_hash ).first() @@ -212,6 +268,154 @@ def logout(authorization: Optional[str] = Header(None), db: Session = Depends(ge db.commit() return {"message": "Logged out successfully"} +@app.get("/auth/google") +async def google_login(request: Request): + """Initiate Google OAuth login - DIRECT BROWSER REDIRECT (not fetch)""" + redirect_uri = os.getenv('GOOGLE_REDIRECT_URI', 'http://localhost:8000/auth/google/callback') + + # Debug logging for development + if is_development: + print(f"\nš OAuth Login initiated (/auth/google):") + print(f" - Redirect URI: {redirect_uri}") + print(f" - Request URL: {request.url}") + print(f" - Request method: {request.method}") + print(f" - Request headers Cookie: {request.headers.get('cookie', 'NONE')}") + print(f" - Session available: {hasattr(request, 'session')}") + if hasattr(request, 'session'): + print(f" - Session keys BEFORE: {list(request.session.keys())}") + + # This will set session state and redirect to Google + response = await oauth.google.authorize_redirect(request, redirect_uri) + + if is_development: + print(f" - Session keys AFTER: {list(request.session.keys())}") + print(f" - Response status: {response.status_code}") + print(f" - Response Set-Cookie: {response.headers.get('set-cookie', 'NONE')}") + print(f" - Response Location: {response.headers.get('location', 'NONE')[:100]}...") + + return response + +@app.get("/auth/google/callback") +async def google_callback(request: Request, db: Session = Depends(get_db)): + """Handle Google OAuth callback""" + try: + # Debug logging for development + if is_development: + print(f"\nš OAuth Callback received (/auth/google/callback):") + print(f" - Request URL: {request.url}") + print(f" - Request method: {request.method}") + print(f" - Request headers Cookie: {request.headers.get('cookie', 'NONE')}") + print(f" - Request query params: {dict(request.query_params)}") + print(f" - Cookies from request.cookies: {list(request.cookies.keys())}") + print(f" - Session available: {hasattr(request, 'session')}") + if hasattr(request, 'session'): + session_keys = list(request.session.keys()) + print(f" - Session keys: {session_keys}") + # Print state for debugging + for key in session_keys: + if 'state' in key.lower(): + value_str = str(request.session[key]) + if len(value_str) > 100: + print(f" - Session[{key}]: {value_str[:100]}...") + else: + print(f" - Session[{key}]: {value_str}") + + # 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: + raise HTTPException(status_code=400, detail="Failed to get user info from Google") + + email = user_info.get('email') + google_id = user_info.get('sub') + name = user_info.get('name', email.split('@')[0]) + + # Check if user exists + user = db.query(db_models.User).filter(db_models.User.email == email).first() + + if not user: + # Create new user + user_id = str(uuid.uuid4()) + # Use email username part as username, make it unique if needed + username = email.split('@')[0] + counter = 1 + original_username = username + while db.query(db_models.User).filter(db_models.User.username.ilike(username)).first(): + username = f"{original_username}{counter}" + counter += 1 + + user = db_models.User( + id=user_id, + username=username, + email=email, + password_hash=hashlib.sha256(google_id.encode()).hexdigest() # Use Google ID as password hash + ) + db.add(user) + + # Create default lists for new user + default_lists = [ + {"name": "Personal", "icon": "š ", "color": "#667eea"}, + {"name": "Work", "icon": "š¼", "color": "#f093fb"}, + {"name": "Shopping", "icon": "š", "color": "#4facfe"}, + ] + + for list_data in default_lists: + list_id = str(uuid.uuid4()) + new_list = db_models.TaskList( + id=list_id, + user_id=user_id, + name=list_data["name"], + icon=list_data["icon"], + color=list_data["color"] + ) + db.add(new_list) + + db.commit() + db.refresh(user) + + # Create auth token + token_str = str(uuid.uuid4()) + new_token = db_models.Token(token=token_str, user_id=user.id) + db.add(new_token) + db.commit() + + if is_development: + print(f"ā OAuth Login SUCCESS!") + print(f" - User: {user.email} (ID: {user.id})") + print(f" - Token generated: {token_str[:20]}...") + print(f" - Redirecting to frontend with token") + + # Redirect to frontend with token + frontend_url = os.getenv('FRONTEND_URL', 'http://localhost:5173') + return RedirectResponse(url=f"{frontend_url}?token={token_str}&user={user.id}") + + except Exception as e: + if is_development: + print(f"ā OAuth Login FAILED: {str(e)}") + import traceback + traceback.print_exc() + raise HTTPException(status_code=400, detail=f"Authentication failed: {str(e)}") + +@app.get("/auth/google/url") +def get_google_auth_url(): + """Get Google OAuth URL for frontend""" + redirect_uri = os.getenv('GOOGLE_REDIRECT_URI', 'http://localhost:8000/auth/google/callback') + authorization_url = f"https://accounts.google.com/o/oauth2/v2/auth?client_id={os.getenv('GOOGLE_CLIENT_ID', '')}&redirect_uri={redirect_uri}&response_type=code&scope=openid%20email%20profile&access_type=offline&prompt=consent" + return {"url": authorization_url} + +@app.get("/api/user", response_model=UserResponse) +def get_current_user(authorization: Optional[str] = Header(None), db: Session = Depends(get_db)): + """Get current user details""" + user_id = verify_token(authorization, db) + user = db.query(db_models.User).filter(db_models.User.id == user_id).first() + + if not user: + raise HTTPException(status_code=404, detail="User not found") + + return UserResponse.model_validate(user) + # Task List endpoints @app.get("/lists", response_model=List[TaskListResponse]) @app.get("/api/lists", response_model=List[TaskListResponse]) diff --git a/backend/requirements.txt b/backend/requirements.txt index c4bdd0a..b73a9a6 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,5 +1,8 @@ -fastapi==0.109.0 -uvicorn==0.27.0 -pydantic==2.5.3 -sqlalchemy==2.0.25 -psycopg2-binary==2.9.9 +fastapi>=0.109.0 +uvicorn>=0.27.0 +pydantic>=2.5.0 +sqlalchemy>=2.0.25 +psycopg2-binary>=2.9.9 +authlib>=1.3.0 +httpx>=0.27.0 +python-dotenv>=1.0.0 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d96c2fd..a5d89ae 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -2,7 +2,8 @@ import { useState, useEffect } from 'react' import Auth from './Auth' import './App.css' -const API_URL = import.meta.env.VITE_API_URL || 'http://10.188.50.221:8000' +// Use relative URLs to leverage Vite proxy (same-origin = cookies work!) +const API_URL = import.meta.env.VITE_API_URL || '' // Empty string = same-origin const AVAILABLE_ICONS = [ 'š', 'š¼', 'š ', 'š', 'šÆ', 'šŖ', 'š', 'āļø', diff --git a/frontend/src/Auth.css b/frontend/src/Auth.css index b813435..5874ca8 100644 --- a/frontend/src/Auth.css +++ b/frontend/src/Auth.css @@ -131,6 +131,61 @@ cursor: not-allowed; } +.divider { + display: flex; + align-items: center; + text-align: center; + margin: 0.5rem 0; +} + +.divider::before, +.divider::after { + content: ''; + flex: 1; + border-bottom: 1px solid #e0e0e0; +} + +.divider span { + padding: 0 1rem; + color: #999; + font-size: 0.85rem; + font-weight: 600; +} + +.google-login-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + padding: 1rem 2rem; + background: white; + color: #333; + border: 2px solid #e0e0e0; + border-radius: 12px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.3s; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.google-login-btn:hover:not(:disabled) { + background: #f8f9fa; + border-color: #d0d0d0; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.google-login-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.google-login-btn svg { + width: 24px; + height: 24px; +} + /* Mobile responsive styles */ @media (max-width: 768px) { .auth-container { diff --git a/frontend/src/Auth.jsx b/frontend/src/Auth.jsx index 14360d7..79a8de8 100644 --- a/frontend/src/Auth.jsx +++ b/frontend/src/Auth.jsx @@ -1,7 +1,8 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' import './Auth.css' -const API_URL = import.meta.env.VITE_API_URL || 'http://10.188.50.221:8000' +// Use relative URLs to leverage Vite proxy (same-origin = cookies work!) +const API_URL = import.meta.env.VITE_API_URL || '' // Empty string = same-origin function Auth({ onLogin }) { const [isLogin, setIsLogin] = useState(true) @@ -12,6 +13,34 @@ function Auth({ onLogin }) { const [error, setError] = useState('') const [loading, setLoading] = useState(false) + // Check for Google OAuth callback + useEffect(() => { + const urlParams = new URLSearchParams(window.location.search) + const token = urlParams.get('token') + const userId = urlParams.get('user') + + if (token && userId) { + // Fetch user details with token + fetch(`${API_URL}/api/user`, { + headers: { + 'Authorization': `Bearer ${token}` + } + }) + .then(res => res.json()) + .then(user => { + localStorage.setItem('token', token) + localStorage.setItem('user', JSON.stringify(user)) + onLogin(user, token) + // Clean up URL + window.history.replaceState({}, document.title, '/') + }) + .catch(err => { + console.error('Failed to fetch user:', err) + setError('Failed to complete Google login') + }) + } + }, [onLogin]) + const handleSubmit = async (e) => { e.preventDefault() setError('') @@ -62,6 +91,17 @@ function Auth({ onLogin }) { } } + const handleGoogleLogin = async () => { + setLoading(true) + setError('') + + // CORRECT OAuth flow: Direct browser redirect to /auth/google + // This allows the backend to set session cookie and redirect to Google + // DO NOT use fetch() - OAuth requires browser redirects to preserve cookies + console.log('Redirecting to Google OAuth...') + window.location.href = `${API_URL}/auth/google` + } + return (