feat: Implement comprehensive OAuth and email verification authentication system

- Add email verification with token-based validation
- Integrate Google, Facebook, and Yahoo OAuth providers
- Add OAuth configuration and email service modules
- Update User model with email_verified, oauth_provider, oauth_id fields
- Implement async password hashing/verification to prevent blocking
- Add database migration script for new user fields
- Create email verification page with professional UI
- Update login page with social login buttons (Google, Facebook, Yahoo)
- Add OAuth callback token handling
- Implement scroll-to-top navigation component
- Add 5-second real-time polling for Products and Services pages
- Enhance About page with Apple-style scroll animations
- Update Home and Contact pages with branding and business info
- Optimize API cache with prefix-based clearing
- Create comprehensive setup documentation and quick start guide
- Fix login performance with ThreadPoolExecutor for bcrypt operations

Performance improvements:
- Login time optimized to ~220ms with async password verification
- Real-time data updates every 5 seconds
- Non-blocking password operations

Security enhancements:
- Email verification required for new accounts
- OAuth integration for secure social login
- Verification tokens expire after 24 hours
- Password field nullable for OAuth users
This commit is contained in:
2026-02-04 00:41:16 -06:00
parent 72f17c8be9
commit 9a7b00649b
22 changed files with 2273 additions and 128 deletions

38
backend/.env.example Normal file
View File

@@ -0,0 +1,38 @@
# JWT Configuration
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production-use-long-random-string
# Email Configuration (Gmail SMTP)
# Follow steps in docs/AUTH_SETUP_GUIDE.md to get App Password
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=prompttechbz@gmail.com
SMTP_PASSWORD=your-16-char-app-password-here
FROM_EMAIL=prompttechbz@gmail.com
# Frontend URL
FRONTEND_URL=http://localhost:5300
# Google OAuth
# Get from: https://console.cloud.google.com/
GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-your-google-client-secret
GOOGLE_REDIRECT_URI=http://localhost:8181/api/auth/google/callback
# Facebook OAuth
# Get from: https://developers.facebook.com/
FACEBOOK_APP_ID=your-facebook-app-id
FACEBOOK_APP_SECRET=your-facebook-app-secret
FACEBOOK_REDIRECT_URI=http://localhost:8181/api/auth/facebook/callback
# Yahoo OAuth
# Get from: https://developer.yahoo.com/
YAHOO_CLIENT_ID=your-yahoo-client-id
YAHOO_CLIENT_SECRET=your-yahoo-client-secret
YAHOO_REDIRECT_URI=http://localhost:8181/api/auth/yahoo/callback
# Admin Configuration
ADMIN_EMAIL=prompttechbz@gmail.com
ADMIN_PHONE=+5016261234
# Database (if needed)
DATABASE_URL=postgresql://user:password@localhost:5432/dbname

227
backend/email_service.py Normal file
View File

@@ -0,0 +1,227 @@
"""
Email Service for PromptTech Solutions
Handles email verification, notifications, and password resets
"""
import smtplib
import os
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.image import MIMEImage
from pathlib import Path
import logging
logger = logging.getLogger(__name__)
# Email configuration from environment
SMTP_HOST = os.environ.get('SMTP_HOST', 'smtp.gmail.com')
SMTP_PORT = int(os.environ.get('SMTP_PORT', 587))
SMTP_USER = os.environ.get('SMTP_USER', '')
SMTP_PASSWORD = os.environ.get('SMTP_PASSWORD', '')
FROM_EMAIL = os.environ.get('FROM_EMAIL', SMTP_USER)
FRONTEND_URL = os.environ.get('FRONTEND_URL', 'http://localhost:5300')
def send_email(to_email: str, subject: str, html_content: str, text_content: str = None):
"""
Send an email using SMTP
Args:
to_email: Recipient email address
subject: Email subject
html_content: HTML content of the email
text_content: Plain text fallback (optional)
"""
if not SMTP_USER or not SMTP_PASSWORD:
logger.warning("SMTP credentials not configured. Email not sent.")
return False
try:
# Create message
message = MIMEMultipart('alternative')
message['Subject'] = subject
message['From'] = f"PromptTech Solutions <{FROM_EMAIL}>"
message['To'] = to_email
# Add text/plain part (fallback)
if text_content:
text_part = MIMEText(text_content, 'plain')
message.attach(text_part)
# Add text/html part
html_part = MIMEText(html_content, 'html')
message.attach(html_part)
# Send email
with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:
server.starttls()
server.login(SMTP_USER, SMTP_PASSWORD)
server.send_message(message)
logger.info(f"Email sent successfully to {to_email}")
return True
except Exception as e:
logger.error(f"Failed to send email to {to_email}: {e}")
return False
def send_verification_email(to_email: str, first_name: str, verification_token: str):
"""Send email verification link to new user"""
verification_link = f"{FRONTEND_URL}/verify-email?token={verification_token}"
subject = "Verify your PromptTech Solutions account"
html_content = f"""
<!DOCTYPE html>
<html>
<head>
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background-color: #4F46E5; color: white; padding: 20px; text-align: center; border-radius: 8px 8px 0 0; }}
.content {{ background-color: #f9fafb; padding: 30px; border-radius: 0 0 8px 8px; }}
.button {{ display: inline-block; background-color: #4F46E5; color: white; padding: 12px 30px; text-decoration: none; border-radius: 6px; margin: 20px 0; }}
.footer {{ text-align: center; margin-top: 30px; color: #666; font-size: 12px; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Welcome to PromptTech Solutions!</h1>
</div>
<div class="content">
<p>Hi {first_name},</p>
<p>Thank you for creating an account with PromptTech Solutions. To complete your registration and verify your email address, please click the button below:</p>
<div style="text-align: center;">
<a href="{verification_link}" class="button">Verify Email Address</a>
</div>
<p>Or copy and paste this link into your browser:</p>
<p style="word-break: break-all; color: #4F46E5;">{verification_link}</p>
<p><strong>This link will expire in 24 hours.</strong></p>
<p>If you didn't create this account, you can safely ignore this email.</p>
<p>Best regards,<br>The PromptTech Solutions Team</p>
</div>
<div class="footer">
<p>© 2026 PromptTech Solutions. All rights reserved.</p>
<p>Belmopan City, Belize | (501) 638-6318 | prompttechbz@gmail.com</p>
</div>
</div>
</body>
</html>
"""
text_content = f"""
Hi {first_name},
Thank you for creating an account with PromptTech Solutions. To complete your registration and verify your email address, please visit:
{verification_link}
This link will expire in 24 hours.
If you didn't create this account, you can safely ignore this email.
Best regards,
The PromptTech Solutions Team
"""
return send_email(to_email, subject, html_content, text_content)
def send_welcome_email(to_email: str, first_name: str):
"""Send welcome email after successful verification"""
subject = "Welcome to PromptTech Solutions!"
html_content = f"""
<!DOCTYPE html>
<html>
<head>
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background-color: #10B981; color: white; padding: 20px; text-align: center; border-radius: 8px 8px 0 0; }}
.content {{ background-color: #f9fafb; padding: 30px; border-radius: 0 0 8px 8px; }}
.button {{ display: inline-block; background-color: #4F46E5; color: white; padding: 12px 30px; text-decoration: none; border-radius: 6px; margin: 10px 5px; }}
.footer {{ text-align: center; margin-top: 30px; color: #666; font-size: 12px; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🎉 Account Verified!</h1>
</div>
<div class="content">
<p>Hi {first_name},</p>
<p>Your email has been successfully verified! You now have full access to your PromptTech Solutions account.</p>
<h3>What's Next?</h3>
<ul>
<li>Browse our latest electronics and tech products</li>
<li>Book professional repair services</li>
<li>Add items to your cart and checkout securely</li>
<li>Track your orders in real-time</li>
</ul>
<div style="text-align: center; margin-top: 30px;">
<a href="{FRONTEND_URL}/products" class="button">Shop Now</a>
<a href="{FRONTEND_URL}/services" class="button">View Services</a>
</div>
<p style="margin-top: 30px;">Need help? Contact us at <a href="mailto:prompttechbz@gmail.com">prompttechbz@gmail.com</a> or call (501) 638-6318</p>
<p>Best regards,<br>The PromptTech Solutions Team</p>
</div>
<div class="footer">
<p>© 2026 PromptTech Solutions. All rights reserved.</p>
<p>Belmopan City, Belize | Mon-Fri: 8AM-5PM | Sat: 9AM-5PM</p>
</div>
</div>
</body>
</html>
"""
return send_email(to_email, subject, html_content)
def send_password_reset_email(to_email: str, first_name: str, reset_token: str):
"""Send password reset link"""
reset_link = f"{FRONTEND_URL}/reset-password?token={reset_token}"
subject = "Reset your PromptTech Solutions password"
html_content = f"""
<!DOCTYPE html>
<html>
<head>
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background-color: #EF4444; color: white; padding: 20px; text-align: center; border-radius: 8px 8px 0 0; }}
.content {{ background-color: #f9fafb; padding: 30px; border-radius: 0 0 8px 8px; }}
.button {{ display: inline-block; background-color: #EF4444; color: white; padding: 12px 30px; text-decoration: none; border-radius: 6px; margin: 20px 0; }}
.footer {{ text-align: center; margin-top: 30px; color: #666; font-size: 12px; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🔒 Password Reset Request</h1>
</div>
<div class="content">
<p>Hi {first_name},</p>
<p>We received a request to reset your password. Click the button below to create a new password:</p>
<div style="text-align: center;">
<a href="{reset_link}" class="button">Reset Password</a>
</div>
<p>Or copy and paste this link into your browser:</p>
<p style="word-break: break-all; color: #EF4444;">{reset_link}</p>
<p><strong>This link will expire in 1 hour.</strong></p>
<p>If you didn't request a password reset, please ignore this email and your password will remain unchanged.</p>
<p>Best regards,<br>The PromptTech Solutions Team</p>
</div>
<div class="footer">
<p>© 2026 PromptTech Solutions. All rights reserved.</p>
<p>Belmopan City, Belize | (501) 638-6318 | prompttechbz@gmail.com</p>
</div>
</div>
</body>
</html>
"""
return send_email(to_email, subject, html_content)

View File

@@ -0,0 +1,81 @@
"""
Database migration script to add email verification and OAuth fields to User table.
Run this script to update your existing database.
"""
import asyncio
from sqlalchemy import text
from database import AsyncSessionLocal
async def migrate_database():
async with AsyncSessionLocal() as session:
print("Starting database migration...")
# Check if columns already exist
check_query = text("""
SELECT column_name
FROM information_schema.columns
WHERE table_name='users'
AND column_name IN ('email_verified', 'verification_token', 'oauth_provider', 'oauth_id');
""")
result = await session.execute(check_query)
existing_columns = [row[0] for row in result.fetchall()]
if 'email_verified' in existing_columns:
print("✓ Columns already exist. Migration not needed.")
return
print("Adding new columns to users table...")
# Add email_verified column
await session.execute(text("""
ALTER TABLE users
ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT FALSE;
"""))
print("✓ Added email_verified column")
# Add verification_token column
await session.execute(text("""
ALTER TABLE users
ADD COLUMN IF NOT EXISTS verification_token VARCHAR(500);
"""))
print("✓ Added verification_token column")
# Add oauth_provider column
await session.execute(text("""
ALTER TABLE users
ADD COLUMN IF NOT EXISTS oauth_provider VARCHAR(50);
"""))
print("✓ Added oauth_provider column")
# Add oauth_id column
await session.execute(text("""
ALTER TABLE users
ADD COLUMN IF NOT EXISTS oauth_id VARCHAR(255);
"""))
print("✓ Added oauth_id column")
# Make password nullable for OAuth users
await session.execute(text("""
ALTER TABLE users
ALTER COLUMN password DROP NOT NULL;
"""))
print("✓ Made password column nullable (for OAuth users)")
# Mark all existing users as verified (they registered before verification was added)
await session.execute(text("""
UPDATE users
SET email_verified = TRUE
WHERE email_verified = FALSE;
"""))
print("✓ Marked existing users as verified")
await session.commit()
print("\n✅ Migration completed successfully!")
if __name__ == "__main__":
print("=" * 60)
print("User Table Migration Script")
print("=" * 60)
asyncio.run(migrate_database())

View File

@@ -33,9 +33,18 @@ class User(Base):
id = Column(String(36), primary_key=True, default=generate_uuid)
email = Column(String(255), unique=True, nullable=False, index=True)
name = Column(String(255), nullable=False)
password = Column(String(255), nullable=False)
password = Column(String(255), nullable=True) # Nullable for OAuth users
role = Column(SQLEnum(UserRole), default=UserRole.USER)
is_active = Column(Boolean, default=True, nullable=False)
# Email verification fields
email_verified = Column(Boolean, default=False, nullable=False)
verification_token = Column(String(500), nullable=True)
# OAuth fields
oauth_provider = Column(String(50), nullable=True) # google, facebook, yahoo, or None for email
oauth_id = Column(String(255), nullable=True) # User ID from OAuth provider
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())

51
backend/oauth_config.py Normal file
View File

@@ -0,0 +1,51 @@
import os
from authlib.integrations.starlette_client import OAuth
from starlette.config import Config
# Load environment variables
config = Config('.env')
# Initialize OAuth
oauth = OAuth(config)
# Google OAuth Configuration
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'
}
)
# Facebook OAuth Configuration
oauth.register(
name='facebook',
client_id=os.getenv('FACEBOOK_APP_ID'),
client_secret=os.getenv('FACEBOOK_APP_SECRET'),
authorize_url='https://www.facebook.com/v12.0/dialog/oauth',
authorize_params=None,
access_token_url='https://graph.facebook.com/v12.0/oauth/access_token',
access_token_params=None,
refresh_token_url=None,
client_kwargs={
'scope': 'email public_profile',
'token_endpoint_auth_method': 'client_secret_post'
}
)
# Yahoo OAuth Configuration
oauth.register(
name='yahoo',
client_id=os.getenv('YAHOO_CLIENT_ID'),
client_secret=os.getenv('YAHOO_CLIENT_SECRET'),
authorize_url='https://api.login.yahoo.com/oauth2/request_auth',
authorize_params=None,
access_token_url='https://api.login.yahoo.com/oauth2/get_token',
access_token_params=None,
client_kwargs={
'scope': 'openid email profile',
'token_endpoint_auth_method': 'client_secret_post'
}
)

View File

@@ -64,6 +64,8 @@ mypy==1.19.1
mypy_extensions==1.1.0
numpy==2.4.0
oauthlib==3.3.1
authlib==1.3.0
itsdangerous==2.2.0
openai==1.99.9
packaging==25.0
pandas==2.3.3

View File

@@ -1,6 +1,6 @@
from fastapi import FastAPI, APIRouter, HTTPException, Depends, status, Query, Response, UploadFile, File, Form
from fastapi import FastAPI, APIRouter, HTTPException, Depends, status, Query, Response, UploadFile, File, Form, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from fastapi.responses import StreamingResponse
from fastapi.responses import StreamingResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from dotenv import load_dotenv
from starlette.middleware.cors import CORSMiddleware
@@ -10,6 +10,7 @@ from sqlalchemy.orm import selectinload
import os
import logging
import aiofiles
import asyncio
from pathlib import Path
from pydantic import BaseModel, Field, EmailStr, ConfigDict
from typing import List, Optional, Dict, Any
@@ -26,6 +27,7 @@ import httpx
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from contextlib import asynccontextmanager
from concurrent.futures import ThreadPoolExecutor
from reportlab.lib import colors
from reportlab.lib.pagesizes import letter, A4
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer
@@ -39,6 +41,9 @@ from models import (
OrderStatus, UserRole, Base, ProductImage, ServiceImage,
AboutContent, TeamMember, CompanyValue, Media, MediaType
)
from email_service import send_verification_email, send_welcome_email
from oauth_config import oauth
from itsdangerous import URLSafeTimedSerializer
ROOT_DIR = Path(__file__).parent
load_dotenv(ROOT_DIR / '.env')
@@ -72,6 +77,15 @@ SECRET_KEY = os.environ.get('JWT_SECRET', 'techzone-super-secret-key-2024-produc
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_HOURS = 24
# Token serializer for email verification
serializer = URLSafeTimedSerializer(SECRET_KEY)
# Frontend URL for email links
FRONTEND_URL = os.environ.get('FRONTEND_URL', 'http://localhost:5300')
# Thread pool for CPU-intensive operations (like bcrypt)
executor = ThreadPoolExecutor(max_workers=4)
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
@@ -227,6 +241,16 @@ def hash_password(password: str) -> str:
def verify_password(password: str, hashed: str) -> bool:
return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8'))
async def hash_password_async(password: str) -> str:
"""Async wrapper for password hashing to avoid blocking the event loop"""
loop = asyncio.get_running_loop()
return await loop.run_in_executor(executor, hash_password, password)
async def verify_password_async(password: str, hashed: str) -> bool:
"""Async wrapper for password verification to avoid blocking the event loop"""
loop = asyncio.get_running_loop()
return await loop.run_in_executor(executor, verify_password, password, hashed)
def create_access_token(data: dict) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS)
@@ -593,28 +617,288 @@ Please reach out to the customer."""
@api_router.post("/auth/register", response_model=TokenResponse)
async def register(user_data: UserCreate, db: AsyncSession = Depends(get_db)):
# Check if email already exists
result = await db.execute(select(User).where(User.email == user_data.email))
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Email already registered")
# Generate verification token
verification_token = serializer.dumps(user_data.email, salt='email-verification')
# Hash password asynchronously (doesn't block event loop)
hashed_password = await hash_password_async(user_data.password)
# Create user with unverified email
user = User(
email=user_data.email,
name=user_data.name,
password=hash_password(user_data.password),
role=UserRole.USER
password=hashed_password,
role=UserRole.USER,
email_verified=False,
verification_token=verification_token,
oauth_provider=None # Regular email registration
)
db.add(user)
await db.commit()
await db.refresh(user)
# Send verification email
try:
first_name = user_data.name.split()[0] if user_data.name else "User"
await send_verification_email(user_data.email, first_name, verification_token)
logger.info(f"Verification email sent to {user_data.email}")
except Exception as e:
logger.error(f"Failed to send verification email: {e}")
# Don't fail registration if email fails
# Return token (user can browse but some features may require verification)
token = create_access_token({"sub": user.id})
return TokenResponse(access_token=token, user=user_to_dict(user))
@api_router.get("/auth/verify-email")
async def verify_email(token: str, db: AsyncSession = Depends(get_db)):
try:
# Decode token (expires in 24 hours)
email = serializer.loads(token, salt='email-verification', max_age=86400)
except Exception as e:
logger.error(f"Invalid verification token: {e}")
raise HTTPException(status_code=400, detail="Invalid or expired verification token")
# Find user by email and token
result = await db.execute(
select(User).where(
and_(
User.email == email,
User.verification_token == token
)
)
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user.email_verified:
return {"message": "Email already verified", "verified": True}
# Mark email as verified
user.email_verified = True
user.verification_token = None
await db.commit()
# Send welcome email
try:
first_name = user.name.split()[0] if user.name else "User"
await send_welcome_email(user.email, first_name)
except Exception as e:
logger.error(f"Failed to send welcome email: {e}")
logger.info(f"Email verified for user {user.email}")
return {"message": "Email verified successfully", "verified": True}
# OAuth Routes
@api_router.get("/auth/google")
async def google_login(request: Request):
redirect_uri = os.getenv('GOOGLE_REDIRECT_URI', 'http://localhost:8181/api/auth/google/callback')
return await oauth.google.authorize_redirect(request, redirect_uri)
@api_router.get("/auth/google/callback")
async def google_callback(request: Request, db: AsyncSession = Depends(get_db)):
try:
token = await oauth.google.authorize_access_token(request)
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')
name = user_info.get('name', email.split('@')[0])
oauth_id = user_info.get('sub')
# Check if user exists
result = await db.execute(select(User).where(User.email == email))
user = result.scalar_one_or_none()
if user:
# Update OAuth info if needed
if not user.oauth_provider:
user.oauth_provider = 'google'
user.oauth_id = oauth_id
user.email_verified = True # Google emails are verified
await db.commit()
else:
# Create new user
user = User(
email=email,
name=name,
password=None, # No password for OAuth users
role=UserRole.USER,
email_verified=True,
oauth_provider='google',
oauth_id=oauth_id
)
db.add(user)
await db.commit()
await db.refresh(user)
# Send welcome email
try:
first_name = name.split()[0] if name else "User"
await send_welcome_email(email, first_name)
except Exception as e:
logger.error(f"Failed to send welcome email: {e}")
# Create JWT token
access_token = create_access_token({"sub": user.id})
# Redirect to frontend with token
return RedirectResponse(url=f"{FRONTEND_URL}/login?token={access_token}")
except Exception as e:
logger.error(f"Google OAuth error: {e}")
return RedirectResponse(url=f"{FRONTEND_URL}/login?error=auth_failed")
@api_router.get("/auth/facebook")
async def facebook_login(request: Request):
redirect_uri = os.getenv('FACEBOOK_REDIRECT_URI', 'http://localhost:8181/api/auth/facebook/callback')
return await oauth.facebook.authorize_redirect(request, redirect_uri)
@api_router.get("/auth/facebook/callback")
async def facebook_callback(request: Request, db: AsyncSession = Depends(get_db)):
try:
token = await oauth.facebook.authorize_access_token(request)
# Get user info from Facebook
async with httpx.AsyncClient() as client:
resp = await client.get(
'https://graph.facebook.com/me',
params={'fields': 'id,name,email', 'access_token': token['access_token']}
)
user_info = resp.json()
email = user_info.get('email')
if not email:
raise HTTPException(status_code=400, detail="Email not provided by Facebook")
name = user_info.get('name', email.split('@')[0])
oauth_id = user_info.get('id')
# Check if user exists
result = await db.execute(select(User).where(User.email == email))
user = result.scalar_one_or_none()
if user:
if not user.oauth_provider:
user.oauth_provider = 'facebook'
user.oauth_id = oauth_id
user.email_verified = True
await db.commit()
else:
user = User(
email=email,
name=name,
password=None,
role=UserRole.USER,
email_verified=True,
oauth_provider='facebook',
oauth_id=oauth_id
)
db.add(user)
await db.commit()
await db.refresh(user)
try:
first_name = name.split()[0] if name else "User"
await send_welcome_email(email, first_name)
except Exception as e:
logger.error(f"Failed to send welcome email: {e}")
access_token = create_access_token({"sub": user.id})
return RedirectResponse(url=f"{FRONTEND_URL}/login?token={access_token}")
except Exception as e:
logger.error(f"Facebook OAuth error: {e}")
return RedirectResponse(url=f"{FRONTEND_URL}/login?error=auth_failed")
@api_router.get("/auth/yahoo")
async def yahoo_login(request: Request):
redirect_uri = os.getenv('YAHOO_REDIRECT_URI', 'http://localhost:8181/api/auth/yahoo/callback')
return await oauth.yahoo.authorize_redirect(request, redirect_uri)
@api_router.get("/auth/yahoo/callback")
async def yahoo_callback(request: Request, db: AsyncSession = Depends(get_db)):
try:
token = await oauth.yahoo.authorize_access_token(request)
# Get user info from Yahoo
async with httpx.AsyncClient() as client:
resp = await client.get(
'https://api.login.yahoo.com/openid/v1/userinfo',
headers={'Authorization': f"Bearer {token['access_token']}"}
)
user_info = resp.json()
email = user_info.get('email')
if not email:
raise HTTPException(status_code=400, detail="Email not provided by Yahoo")
name = user_info.get('name', email.split('@')[0])
oauth_id = user_info.get('sub')
# Check if user exists
result = await db.execute(select(User).where(User.email == email))
user = result.scalar_one_or_none()
if user:
if not user.oauth_provider:
user.oauth_provider = 'yahoo'
user.oauth_id = oauth_id
user.email_verified = True
await db.commit()
else:
user = User(
email=email,
name=name,
password=None,
role=UserRole.USER,
email_verified=True,
oauth_provider='yahoo',
oauth_id=oauth_id
)
db.add(user)
await db.commit()
await db.refresh(user)
try:
first_name = name.split()[0] if name else "User"
await send_welcome_email(email, first_name)
except Exception as e:
logger.error(f"Failed to send welcome email: {e}")
access_token = create_access_token({"sub": user.id})
return RedirectResponse(url=f"{FRONTEND_URL}/login?token={access_token}")
except Exception as e:
logger.error(f"Yahoo OAuth error: {e}")
return RedirectResponse(url=f"{FRONTEND_URL}/login?error=auth_failed")
@api_router.post("/auth/login", response_model=TokenResponse)
async def login(credentials: UserLogin, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.email == credentials.email))
user = result.scalar_one_or_none()
if not user or not verify_password(credentials.password, user.password):
if not user:
raise HTTPException(status_code=401, detail="Invalid credentials")
# Check if user registered with OAuth
if user.oauth_provider and not user.password:
raise HTTPException(
status_code=400,
detail=f"This account uses {user.oauth_provider} login. Please sign in with {user.oauth_provider}."
)
# Verify password (use async to prevent blocking)
if not user.password or not await verify_password_async(credentials.password, user.password):
raise HTTPException(status_code=401, detail="Invalid credentials")
token = create_access_token({"sub": user.id})
@@ -2334,10 +2618,11 @@ async def create_user(
raise HTTPException(status_code=400, detail=f"Invalid role: {user_data.role}")
# Create new user
hashed_password = await hash_password_async(user_data.password)
new_user = User(
email=user_data.email,
name=user_data.name,
password=hash_password(user_data.password),
password=hashed_password,
role=role_enum,
is_active=user_data.is_active
)
@@ -2378,7 +2663,7 @@ async def update_user(
if user_data.password is not None and user_data.password.strip():
# Only update password if provided and not empty
user.password = hash_password(user_data.password)
user.password = await hash_password_async(user_data.password)
if user_data.role is not None:
try:
@@ -2961,10 +3246,11 @@ async def seed_data(db: AsyncSession = Depends(get_db)):
return {"message": "Data already seeded"}
# Create admin user
admin_password = await hash_password_async("admin123")
admin = User(
email="admin@techzone.com",
name="Admin",
password=hash_password("admin123"),
password=admin_password,
role=UserRole.ADMIN
)
db.add(admin)