diff --git a/Backend/app/config.py b/Backend/app/config.py index e69de29..39f658d 100644 --- a/Backend/app/config.py +++ b/Backend/app/config.py @@ -0,0 +1,13 @@ +import os +from pydantic_settings import BaseSettings + +class Settings(BaseSettings): + """Handles application configuration using environment variables.""" + # This will now throw an error if SECRET_KEY is missing from .env + SECRET_KEY: str + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 + + model_config = {"env_file": ".env"} + +settings = Settings() \ No newline at end of file diff --git a/Backend/app/db/seed.py b/Backend/app/db/seed.py index 77a015e..548f8bb 100644 --- a/Backend/app/db/seed.py +++ b/Backend/app/db/seed.py @@ -1,9 +1,13 @@ -from datetime import datetime +from datetime import datetime, timezone from app.db.db import AsyncSessionLocal from app.models.models import User - +from ..services.auth_service import hash_password # Import the hashing utility async def seed_db(): + """ + Seeds the database with initial creator and brand users. + Includes hashed passwords to satisfy database constraints. + """ users = [ { "id": "aabb1fd8-ba93-4e8c-976e-35e5c40b809c", @@ -13,7 +17,7 @@ async def seed_db(): "role": "creator", "bio": "Lifestyle and travel content creator", "profile_image": None, - "created_at": datetime.utcnow() + "created_at": datetime.now(timezone.utc) # Timezone-aware }, { "id": "6dbfcdd5-795f-49c1-8f7a-a5538b8c6f6f", @@ -23,27 +27,24 @@ async def seed_db(): "role": "brand", "bio": "Sustainable fashion brand looking for influencers", "profile_image": None, - "created_at": datetime.utcnow() + "created_at": datetime.now(timezone.utc) # Timezone-aware }, ] - # Insert or update the users async with AsyncSessionLocal() as session: for user_data in users: - # Check if user exists - existing_user = await session.execute( - User.__table__.select().where(User.email == user_data["email"]) - ) - existing_user = existing_user.scalar_one_or_none() + # Correct check for user existence + existing_user = await session.get(User, user_data["id"]) if existing_user: continue else: - # Create new user + # Create new user with hashed_password user = User( id=user_data["id"], username=user_data["username"], email=user_data["email"], + hashed_password=hash_password(user_data["password"]), # Fixes seed failure role=user_data["role"], profile_image=user_data["profile_image"], bio=user_data["bio"], @@ -52,6 +53,5 @@ async def seed_db(): session.add(user) print(f"Created user: {user_data['email']}") - # Commit the session await session.commit() - print("✅ Users seeded successfully.") + print("✅ Users seeded successfully.") \ No newline at end of file diff --git a/Backend/app/main.py b/Backend/app/main.py index 86d892a..e641278 100644 --- a/Backend/app/main.py +++ b/Backend/app/main.py @@ -7,6 +7,7 @@ from .routes.chat import router as chat_router from .routes.match import router as match_router from sqlalchemy.exc import SQLAlchemyError +from .routes import auth import logging import os from dotenv import load_dotenv @@ -56,6 +57,7 @@ async def lifespan(app: FastAPI): app.include_router(match_router) app.include_router(ai.router) app.include_router(ai.youtube_router) +app.include_router(auth.router) @app.get("/") diff --git a/Backend/app/models/models.py b/Backend/app/models/models.py index 56681ab..c0991d4 100644 --- a/Backend/app/models/models.py +++ b/Backend/app/models/models.py @@ -26,17 +26,22 @@ class User(Base): __tablename__ = "users" id = Column(String, primary_key=True, default=generate_uuid) - username = Column(String, unique=True, nullable=False) - email = Column(String, unique=True, nullable=False) - # password_hash = Column(Text, nullable=False) # Removed as Supabase handles auth - role = Column(String, nullable=False) # 'creator' or 'brand' + # Added index=True to username and email for faster login lookups + username = Column(String, unique=True, index=True, nullable=False) + email = Column(String, unique=True, index=True, nullable=False) + + # Restored hashed_password for custom JWT authentication + hashed_password = Column(String, nullable=False) + + role = Column(String, nullable=False, default="creator") # 'creator' or 'brand' profile_image = Column(Text, nullable=True) bio = Column(Text, nullable=True) created_at = Column(TIMESTAMP, default=datetime.utcnow) - is_online = Column(Boolean, default=False) # ✅ Track if user is online + is_online = Column(Boolean, default=False) last_seen = Column(TIMESTAMP, default=datetime.utcnow) + # Existing Relationships audience = relationship("AudienceInsights", back_populates="user", uselist=False) sponsorships = relationship("Sponsorship", back_populates="brand") posts = relationship("UserPost", back_populates="user") @@ -66,7 +71,7 @@ class AudienceInsights(Base): time_of_attention = Column(Integer) # in seconds price_expectation = Column(DECIMAL(10, 2)) created_at = Column( - DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + DateTime(timezone=True), default=datetime.utcnow ) user = relationship("User", back_populates="audience") @@ -80,12 +85,12 @@ class Sponsorship(Base): brand_id = Column(String, ForeignKey("users.id"), nullable=False) title = Column(String, nullable=False) description = Column(Text, nullable=False) - required_audience = Column(JSON) # {"age": ["18-24"], "location": ["USA", "UK"]} + required_audience = Column(JSON) budget = Column(DECIMAL(10, 2)) engagement_minimum = Column(Float) status = Column(String, default="open") created_at = Column( - DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + DateTime(timezone=True), default=datetime.utcnow ) brand = relationship("User", back_populates="sponsorships") @@ -102,9 +107,9 @@ class UserPost(Base): content = Column(Text, nullable=False) post_url = Column(Text, nullable=True) category = Column(String, nullable=True) - engagement_metrics = Column(JSON) # {"likes": 500, "comments": 100, "shares": 50} + engagement_metrics = Column(JSON) created_at = Column( - DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + DateTime(timezone=True), default=datetime.utcnow ) user = relationship("User", back_populates="posts") @@ -121,7 +126,7 @@ class SponsorshipApplication(Base): proposal = Column(Text, nullable=False) status = Column(String, default="pending") applied_at = Column( - DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + DateTime(timezone=True), default=datetime.utcnow ) creator = relationship("User", back_populates="applications") @@ -138,7 +143,7 @@ class Collaboration(Base): collaboration_details = Column(Text, nullable=False) status = Column(String, default="pending") created_at = Column( - DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + DateTime(timezone=True), default=datetime.utcnow ) @@ -153,10 +158,10 @@ class SponsorshipPayment(Base): amount = Column(DECIMAL(10, 2), nullable=False) status = Column(String, default="pending") transaction_date = Column( - DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + DateTime(timezone=True), default=datetime.utcnow ) creator = relationship("User", foreign_keys=[creator_id], back_populates="payments") brand = relationship( "User", foreign_keys=[brand_id], back_populates="brand_payments" - ) + ) \ No newline at end of file diff --git a/Backend/app/models/users.py b/Backend/app/models/users.py deleted file mode 100644 index e69de29..0000000 diff --git a/Backend/app/routes/auth.py b/Backend/app/routes/auth.py index 19d59a2..c8b6383 100644 --- a/Backend/app/routes/auth.py +++ b/Backend/app/routes/auth.py @@ -1,7 +1,60 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from ..db.db import get_db +from ..models.models import User +from ..services.auth_service import hash_password, verify_password, create_access_token +from pydantic import BaseModel, EmailStr +from ..services.get_current_user import get_current_user -router = APIRouter() +router = APIRouter(prefix="/auth", tags=["Authentication"]) -@router.get("/auth/ping") -def ping(): - return {"message": "Auth route is working!"} +# Pydantic schemas for data validation +class UserCreate(BaseModel): + username: str + email: EmailStr + password: str + +class UserLogin(BaseModel): + email: EmailStr + password: str + +@router.post("/signup") +async def signup(user_data: UserCreate, db: AsyncSession = Depends(get_db)): + # Check if user already exists + result = await db.execute(select(User).where(User.email == user_data.email)) + if result.scalars().first(): + raise HTTPException(status_code=400, detail="Email already registered") + + new_user = User( + username=user_data.username, + email=user_data.email, + hashed_password=hash_password(user_data.password) + ) + db.add(new_user) + await db.commit() + await db.refresh(new_user) + return {"message": "User created successfully"} + +@router.post("/login") +async def login(credentials: UserLogin, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(User).where(User.email == credentials.email)) + user = result.scalars().first() + + if not user or not verify_password(credentials.password, user.hashed_password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password" + ) + + access_token = create_access_token(data={"sub": str(user.id)}) + return {"access_token": access_token, "token_type": "bearer"} + +@router.get("/me") +async def get_me(current_user: User = Depends(get_current_user)): + return { + "id": str(current_user.id), + "username": current_user.username, + "email": current_user.email, + "role": current_user.role + } \ No newline at end of file diff --git a/Backend/app/services/auth_service.py b/Backend/app/services/auth_service.py new file mode 100644 index 0000000..f366e85 --- /dev/null +++ b/Backend/app/services/auth_service.py @@ -0,0 +1,22 @@ +from datetime import datetime, timedelta, timezone +from jose import jwt +from passlib.context import CryptContext +from ..config import settings + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +def hash_password(password: str): + """Hashes a plain text password using bcrypt.""" + return pwd_context.hash(password) + +def verify_password(plain_password: str, hashed_password: str): + """Verifies a plain text password against a stored hash.""" + return pwd_context.verify(plain_password, hashed_password) + +def create_access_token(data: dict): + """Generates a secure JWT access token with a timezone-aware expiry.""" + to_encode = data.copy() + # Using timezone-aware UTC to fix CodeRabbit's 'Minor' issue + expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + to_encode.update({"exp": expire}) + return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) \ No newline at end of file diff --git a/Backend/app/services/get_current_user.py b/Backend/app/services/get_current_user.py new file mode 100644 index 0000000..04e5007 --- /dev/null +++ b/Backend/app/services/get_current_user.py @@ -0,0 +1,37 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jose import JWTError, jwt +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from ..db.db import get_db +from ..models.models import User +from ..config import settings + +# This tells FastAPI where to look for the token (the /auth/login route) +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login") + +async def get_current_user( + token: str = Depends(oauth2_scheme), + db: AsyncSession = Depends(get_db) +): + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + # Decode the JWT token + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + user_id: str = payload.get("sub") + if user_id is None: + raise credentials_exception + except JWTError: + raise credentials_exception + + # Fetch the user from the DB + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalars().first() + + if user is None: + raise credentials_exception + return user \ No newline at end of file diff --git a/Backend/requirements.txt b/Backend/requirements.txt index ea1ab73..17115d2 100644 --- a/Backend/requirements.txt +++ b/Backend/requirements.txt @@ -31,7 +31,6 @@ postgrest==1.0.1 propcache==0.3.1 pydantic==2.11.1 pydantic_core==2.33.0 -PyJWT==2.10.1 pytest==8.3.5 pytest-mock==3.14.0 python-dateutil==2.9.0.post0 @@ -53,3 +52,7 @@ urllib3==2.3.0 uvicorn==0.34.0 websockets==14.2 yarl==1.18.3 +passlib[bcrypt]==1.7.4 +bcrypt==4.0.1 +python-jose[cryptography]==3.4.0 +email-validator==2.1.0.post1pydantic-settings==2.7.1