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
This commit is contained in:
dvirlabs 2026-02-20 16:30:27 +02:00
parent 7a34f5f990
commit d6eeb9a079
8 changed files with 464 additions and 10 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 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

@ -1,11 +1,17 @@
from fastapi import FastAPI, HTTPException, Header, Depends from fastapi import FastAPI, HTTPException, Header, Depends
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import RedirectResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
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,6 +21,18 @@ 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 # Configure CORS
@ -122,9 +140,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 +198,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 +230,102 @@ 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():
"""Initiate Google OAuth login"""
redirect_uri = os.getenv('GOOGLE_REDIRECT_URI', 'http://localhost:8000/auth/google/callback')
return await oauth.google.authorize_redirect(redirect_uri)
@app.get("/auth/google/callback")
async def google_callback(code: str, db: Session = Depends(get_db)):
"""Handle Google OAuth callback"""
try:
# Get access token from Google
token = await oauth.google.authorize_access_token()
# 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()
# 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:
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,8 @@
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

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,4 +1,4 @@
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' const API_URL = import.meta.env.VITE_API_URL || 'http://10.188.50.221:8000'
@ -12,6 +12,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 +90,27 @@ function Auth({ onLogin }) {
} }
} }
const handleGoogleLogin = async () => {
setLoading(true)
setError('')
try {
const response = await fetch(`${API_URL}/auth/google/url`)
const data = await response.json()
if (data.url) {
window.location.href = data.url
} else {
setError('Failed to initiate Google login')
setLoading(false)
}
} catch (error) {
setError('Failed to connect to Google login')
setLoading(false)
console.error('Google login error:', error)
}
}
return ( return (
<div className="auth-container"> <div className="auth-container">
<div className="auth-box"> <div className="auth-box">
@ -143,6 +192,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>