Merge pull request 'auth' (#1) from auth into master

Reviewed-on: #1
This commit is contained in:
dvirlabs 2026-02-22 03:25:17 +00:00
commit 4e0ae2e775
13 changed files with 595 additions and 29 deletions

110
GOOGLE_OAUTH_SETUP.md Normal file
View File

@ -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

51
SETUP_COMPLETE.md Normal file
View File

@ -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!

View File

@ -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

44
backend/.gitignore vendored Normal file
View File

@ -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

View File

@ -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])

View File

@ -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

View File

@ -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 = [
'📝', '💼', '🏠', '🛒', '🎯', '💪', '📚', '✈️',

View File

@ -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 {

View File

@ -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 (
<div className="auth-container">
<div className="auth-box">
@ -143,6 +183,25 @@ function Auth({ onLogin }) {
<button type="submit" className="auth-submit" disabled={loading}>
{loading ? 'Please wait...' : (isLogin ? 'Login' : 'Create Account')}
</button>
<div className="divider">
<span>OR</span>
</div>
<button
type="button"
className="google-login-btn"
onClick={handleGoogleLogin}
disabled={loading}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="24px" height="24px">
<path fill="#FFC107" d="M43.611,20.083H42V20H24v8h11.303c-1.649,4.657-6.08,8-11.303,8c-6.627,0-12-5.373-12-12c0-6.627,5.373-12,12-12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C12.955,4,4,12.955,4,24c0,11.045,8.955,20,20,20c11.045,0,20-8.955,20-20C44,22.659,43.862,21.35,43.611,20.083z"/>
<path fill="#FF3D00" d="M6.306,14.691l6.571,4.819C14.655,15.108,18.961,12,24,12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C16.318,4,9.656,8.337,6.306,14.691z"/>
<path fill="#4CAF50" d="M24,44c5.166,0,9.86-1.977,13.409-5.192l-6.19-5.238C29.211,35.091,26.715,36,24,36c-5.202,0-9.619-3.317-11.283-7.946l-6.522,5.025C9.505,39.556,16.227,44,24,44z"/>
<path fill="#1976D2" d="M43.611,20.083H42V20H24v8h11.303c-0.792,2.237-2.231,4.166-4.087,5.571c0.001-0.001,0.002-0.001,0.003-0.002l6.19,5.238C36.971,39.205,44,34,44,24C44,22.659,43.862,21.35,43.611,20.083z"/>
</svg>
Continue with Google
</button>
</form>
</div>
</div>

View File

@ -4,4 +4,34 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
proxy: {
// Proxy API requests to backend - enables same-origin for session cookies
'/auth': {
target: 'http://localhost:8000',
changeOrigin: true,
secure: false,
},
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
secure: false,
},
'/login': {
target: 'http://localhost:8000',
changeOrigin: true,
secure: false,
},
'/register': {
target: 'http://localhost:8000',
changeOrigin: true,
secure: false,
},
'/logout': {
target: 'http://localhost:8000',
changeOrigin: true,
secure: false,
},
},
},
})