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:
parent
7a34f5f990
commit
d6eeb9a079
110
GOOGLE_OAUTH_SETUP.md
Normal file
110
GOOGLE_OAUTH_SETUP.md
Normal 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
51
SETUP_COMPLETE.md
Normal 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!
|
||||
@ -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
44
backend/.gitignore
vendored
Normal 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
|
||||
122
backend/main.py
122
backend/main.py
@ -1,11 +1,17 @@
|
||||
from fastapi import FastAPI, HTTPException, Header, Depends
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import RedirectResponse
|
||||
from pydantic import BaseModel, Field
|
||||
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,6 +21,18 @@ 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
|
||||
@ -122,9 +140,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 +198,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 +230,102 @@ 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():
|
||||
"""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
|
||||
@app.get("/lists", response_model=List[TaskListResponse])
|
||||
@app.get("/api/lists", response_model=List[TaskListResponse])
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
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'
|
||||
@ -12,6 +12,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 +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 (
|
||||
<div className="auth-container">
|
||||
<div className="auth-box">
|
||||
@ -143,6 +192,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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user