Compare commits

...

8 Commits

Author SHA1 Message Date
cf7f3ee799 Build the app 2026-02-22 15:13:44 +02:00
b2d800a0d6 Update requierments.txt 2026-02-22 05:36:16 +02:00
4e0ae2e775 Merge pull request 'auth' (#1) from auth into master
Reviewed-on: #1
2026-02-22 03:25:17 +00:00
e48a5a2d2c Fix google auth 2026-02-22 05:24:40 +02:00
941d005d6e ADD login 2026-02-20 16:56:44 +02:00
d830f33e23 fix: Add Request parameter to Google OAuth endpoints
Pass the FastAPI Request object to authorize_redirect() and authorize_access_token() methods as required by Authlib's Starlette integration
2026-02-20 16:53:34 +02:00
6ebf2d4b45 fix: Update Pydantic models to use ConfigDict instead of deprecated class-based config
Replace deprecated class Config with model_config = ConfigDict() to fix Pydantic v2 deprecation warnings
2026-02-20 16:32:29 +02:00
d6eeb9a079 feat: Add case-insensitive username login and Google OAuth authentication
- Make username login case-insensitive (Dvir = dvir = DVIR)
- Add Google OAuth login/signup functionality
- Install required dependencies (authlib, httpx, python-dotenv)
- Create OAuth endpoints: /auth/google/url, /auth/google/callback
- Add Google login button to frontend with styled UI
- Configure environment variables for OAuth credentials
- Add comprehensive setup documentation
- Secure .env file with .gitignore
2026-02-20 16:30:27 +02:00
16 changed files with 848 additions and 31 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

250
PRODUCTION_OAUTH_SETUP.md Normal file
View File

@ -0,0 +1,250 @@
# Production OAuth Setup Guide
## 🔧 Changes Made
### 1. Kubernetes Configuration Updated
**Files Modified:**
- `tasko-chart/templates/secret.yaml` - Added OAuth secrets
- `tasko-chart/templates/backend-deployment.yaml` - Added environment variables from secrets
- `tasko-chart/values.yaml` - Added OAuth configuration
**What was added:**
```yaml
backend:
env:
ENVIRONMENT: "production"
GOOGLE_REDIRECT_URI: "https://api-tasko.dvirlabs.com/auth/google/callback"
FRONTEND_URL: "https://tasko.dvirlabs.com"
oauth:
google:
clientId: "YOUR_CLIENT_ID"
clientSecret: "YOUR_CLIENT_SECRET"
sessionSecret: "YOUR_SESSION_SECRET"
```
---
## 🔐 Google Cloud Console Setup
### Step 1: Add Production Redirect URI
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Navigate to **APIs & Services** → **Credentials**
3. Click on your OAuth 2.0 Client ID (the one you created for Tasko)
4. Under **Authorized redirect URIs**, add:
```
https://api-tasko.dvirlabs.com/auth/google/callback
```
5. Keep the localhost URI for development:
```
http://localhost:8000/auth/google/callback
```
6. Click **Save**
### Step 2: Verify Authorized JavaScript Origins
Make sure these origins are authorized:
- `https://tasko.dvirlabs.com` (frontend)
- `https://api-tasko.dvirlabs.com` (backend)
- `http://localhost:5173` (local dev)
- `http://localhost:8000` (local dev)
---
## 🚀 Deploy to Kubernetes
### Option A: Using Helm Upgrade
```bash
# From the tasko-chart directory
helm upgrade tasko . --namespace my-apps --create-namespace
# Or if first deployment
helm install tasko . --namespace my-apps --create-namespace
```
### Option B: Using kubectl (if you pushed to Git)
```bash
# Your GitOps tool (ArgoCD, Flux, etc.) should auto-sync
# Or manually trigger sync if needed
```
---
## ✅ Verify Deployment
### 1. Check Backend Logs
```bash
kubectl logs -n my-apps deployment/tasko-backend -f
```
You should see:
```
🔐 Session Configuration (Development Mode): # Wait, this should say Production!
```
### 2. Check Environment Variables
```bash
kubectl exec -n my-apps deployment/tasko-backend -- env | grep GOOGLE
```
Expected output:
```
GOOGLE_CLIENT_ID=672182384838-vob26vd0qhmf0g9mru4u4sibkqre0rfa.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-...
GOOGLE_REDIRECT_URI=https://api-tasko.dvirlabs.com/auth/google/callback
```
### 3. Test OAuth Flow
1. Go to `https://tasko.dvirlabs.com`
2. Click "Continue with Google"
3. You should be redirected to Google login
4. After authentication, you should be redirected back to your app with a token
Watch the backend logs:
```bash
kubectl logs -n my-apps deployment/tasko-backend -f
```
Expected logs:
```
🔑 OAuth Login initiated (/auth/google):
- Redirect URI: https://api-tasko.dvirlabs.com/auth/google/callback
- Response Location: https://accounts.google.com/o/oauth2/v2/auth?client_id=672182384838-...
🔄 OAuth Callback received (/auth/google/callback):
- Request headers Cookie: tasko_session=...
- Cookies from request.cookies: ['tasko_session']
- Session keys: ['_state_google_...']
✅ OAuth Login SUCCESS!
- User: your.email@gmail.com
```
---
## 🔒 Security Notes
### Production vs Development
The code automatically detects the environment:
**Development (`ENVIRONMENT=development`):**
- `https_only=False` (allows HTTP cookies for localhost)
- Debug logging enabled
- Session cookies work on `localhost`
**Production (`ENVIRONMENT=production`):**
- `https_only=True` (requires HTTPS for cookies)
- Debug logging disabled
- Secure session cookies
### Session Secret
The `sessionSecret` is used to sign session cookies. **Change this to a unique value!**
Generate a new secret:
```bash
python -c "import secrets; print(secrets.token_hex(32))"
```
Update in `values.yaml`:
```yaml
backend:
sessionSecret: "YOUR_NEW_SECRET_HERE"
```
---
## 🐛 Troubleshooting
### Issue: "client_id is empty"
**Cause:** Environment variables not loaded in container
**Fix:**
```bash
# Check if secrets exist
kubectl get secret -n my-apps tasko-secrets -o yaml
# Verify secret contains OAuth keys
kubectl describe secret -n my-apps tasko-secrets
# Restart deployment
kubectl rollout restart deployment/tasko-backend -n my-apps
```
### Issue: "mismatching_state: CSRF Warning"
**Cause:** Session cookies not being sent
**Possible causes:**
1. `ENVIRONMENT` not set to `production` (cookies require HTTPS)
2. Frontend and backend on different domains without proper CORS
3. Cookie `SameSite` settings
**Fix:**
- Verify `ENVIRONMENT=production` is set
- Check that `FRONTEND_URL` matches your actual frontend domain
- Ensure HTTPS is working on both frontend and backend
### Issue: "Redirect URI mismatch"
**Cause:** Google Console redirect URI doesn't match
**Fix:**
1. Check the actual redirect URI in the error message from Google
2. Add that exact URI to Google Console
3. Make sure `GOOGLE_REDIRECT_URI` in `values.yaml` matches
---
## 📝 Frontend Configuration
The frontend should automatically use the production API URL because of the proxy setup in `vite.config.js`.
### Build-time Configuration
When building the frontend Docker image, ensure `VITE_API_URL` is set:
**In `values.yaml`:**
```yaml
frontend:
env:
VITE_API_URL: "https://api-tasko.dvirlabs.com"
```
**Or in Dockerfile:**
```dockerfile
ENV VITE_API_URL=https://api-tasko.dvirlabs.com
RUN npm run build
```
---
## ✨ Quick Reference
### Backend URLs
- Production API: `https://api-tasko.dvirlabs.com`
- OAuth callback: `https://api-tasko.dvirlabs.com/auth/google/callback`
### Frontend URLs
- Production: `https://tasko.dvirlabs.com`
### Environment Variables (Backend)
```bash
ENVIRONMENT=production
GOOGLE_CLIENT_ID=672182384838-vob26vd0qhmf0g9mru4u4sibkqre0rfa.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=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
GOOGLE_REDIRECT_URI=https://api-tasko.dvirlabs.com/auth/google/callback
FRONTEND_URL=https://tasko.dvirlabs.com
SESSION_SECRET=<generate-new-secret>
DATABASE_URL=<from-secret>
```

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 PORT=8001
HOST=0.0.0.0 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

@ -21,4 +21,4 @@ COPY . .
EXPOSE 8000 EXPOSE 8000
# Run the application # Run the application
CMD ["python", "main.py"] CMD ["python", "main.py"]

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 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 typing import List, Optional
from datetime import datetime from datetime import datetime
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from authlib.integrations.starlette_client import OAuth
import uuid import uuid
import hashlib import hashlib
import os
from dotenv import load_dotenv
load_dotenv()
from database import init_db, get_db from database import init_db, get_db
import database as db_models import database as db_models
@ -15,44 +22,97 @@ app = FastAPI(title="Tasko API", version="1.0.0")
# Initialize database # Initialize database
init_db() 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"] 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( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=allowed_origins, # Specific origins required for credentials
allow_credentials=True, allow_credentials=True, # Required for session cookies
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], 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 # Pydantic Models for API
class UserResponse(BaseModel): class UserResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str id: str
username: str username: str
email: str email: str
created_at: datetime created_at: datetime
class Config:
from_attributes = True
class UserRegister(BaseModel): class UserRegister(BaseModel):
username: str username: str
email: str email: str
password: str password: str
class UserLogin(BaseModel): class UserLogin(BaseModel):
model_config = ConfigDict(populate_by_name=True)
username_or_email: str = Field(..., alias='usernameOrEmail') username_or_email: str = Field(..., alias='usernameOrEmail')
password: str password: str
class Config:
populate_by_name = True
class AuthResponse(BaseModel): class AuthResponse(BaseModel):
user: UserResponse user: UserResponse
token: str token: str
class TaskListResponse(BaseModel): class TaskListResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str id: str
user_id: str user_id: str
name: str name: str
@ -60,9 +120,6 @@ class TaskListResponse(BaseModel):
color: str = "#667eea" color: str = "#667eea"
created_at: datetime created_at: datetime
class Config:
from_attributes = True
class TaskListCreate(BaseModel): class TaskListCreate(BaseModel):
name: str name: str
icon: Optional[str] = "📝" icon: Optional[str] = "📝"
@ -74,6 +131,8 @@ class TaskListUpdate(BaseModel):
color: Optional[str] = None color: Optional[str] = None
class TaskResponse(BaseModel): class TaskResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str id: str
list_id: str list_id: str
user_id: str user_id: str
@ -84,9 +143,6 @@ class TaskResponse(BaseModel):
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
class Config:
from_attributes = True
class TaskCreate(BaseModel): class TaskCreate(BaseModel):
title: str title: str
list_id: str list_id: str
@ -122,9 +178,9 @@ def read_root():
@app.post("/register", response_model=AuthResponse) @app.post("/register", response_model=AuthResponse)
def register(user_data: UserRegister, db: Session = Depends(get_db)): def register(user_data: UserRegister, db: Session = Depends(get_db)):
"""Register a new user""" """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( 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) (db_models.User.email == user_data.email)
).first() ).first()
@ -180,10 +236,10 @@ def login(user_data: UserLogin, db: Session = Depends(get_db)):
"""Login a user with username or email""" """Login a user with username or email"""
password_hash = hash_password(user_data.password) 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( user = db.query(db_models.User).filter(
((db_models.User.email == user_data.username_or_email) | ((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 db_models.User.password_hash == password_hash
).first() ).first()
@ -212,6 +268,154 @@ def logout(authorization: Optional[str] = Header(None), db: Session = Depends(ge
db.commit() db.commit()
return {"message": "Logged out successfully"} 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 # Task List endpoints
@app.get("/lists", response_model=List[TaskListResponse]) @app.get("/lists", response_model=List[TaskListResponse])
@app.get("/api/lists", response_model=List[TaskListResponse]) @app.get("/api/lists", response_model=List[TaskListResponse])

View File

@ -1,5 +1,9 @@
fastapi==0.109.0 fastapi>=0.109.0
uvicorn==0.27.0 uvicorn>=0.27.0
pydantic==2.5.3 pydantic>=2.5.0
sqlalchemy==2.0.25 sqlalchemy>=2.0.25
psycopg2-binary==2.9.9 psycopg2-binary>=2.9.9
authlib>=1.3.0
httpx>=0.27.0
python-dotenv>=1.0.0
itsdangerous>=2.1.0

View File

@ -31,4 +31,4 @@ COPY nginx.conf /etc/nginx/conf.d/default.conf
# Expose port 80 # Expose port 80
EXPOSE 80 EXPOSE 80
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]

View File

@ -2,7 +2,8 @@ import { useState, useEffect } from 'react'
import Auth from './Auth' import Auth from './Auth'
import './App.css' 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 = [ const AVAILABLE_ICONS = [
'📝', '💼', '🏠', '🛒', '🎯', '💪', '📚', '✈️', '📝', '💼', '🏠', '🛒', '🎯', '💪', '📚', '✈️',

View File

@ -131,6 +131,61 @@
cursor: not-allowed; 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 */ /* Mobile responsive styles */
@media (max-width: 768px) { @media (max-width: 768px) {
.auth-container { .auth-container {

View File

@ -1,7 +1,8 @@
import { useState } from 'react' import { useState, useEffect } from 'react'
import './Auth.css' 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 }) { function Auth({ onLogin }) {
const [isLogin, setIsLogin] = useState(true) const [isLogin, setIsLogin] = useState(true)
@ -12,6 +13,34 @@ function Auth({ onLogin }) {
const [error, setError] = useState('') const [error, setError] = useState('')
const [loading, setLoading] = useState(false) 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) => { const handleSubmit = async (e) => {
e.preventDefault() e.preventDefault()
setError('') 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 ( return (
<div className="auth-container"> <div className="auth-container">
<div className="auth-box"> <div className="auth-box">
@ -143,6 +183,25 @@ function Auth({ onLogin }) {
<button type="submit" className="auth-submit" disabled={loading}> <button type="submit" className="auth-submit" disabled={loading}>
{loading ? 'Please wait...' : (isLogin ? 'Login' : 'Create Account')} {loading ? 'Please wait...' : (isLogin ? 'Login' : 'Create Account')}
</button> </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> </form>
</div> </div>
</div> </div>

View File

@ -4,4 +4,34 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], 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,
},
},
},
}) })