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:
38
backend/.env.example
Normal file
38
backend/.env.example
Normal 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
227
backend/email_service.py
Normal 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)
|
||||
81
backend/migrate_user_table.py
Normal file
81
backend/migrate_user_table.py
Normal 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())
|
||||
@@ -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
51
backend/oauth_config.py
Normal 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'
|
||||
}
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user