diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index e2f5b1e8..a675617a 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -1,5 +1,5 @@ from datetime import datetime, timedelta, timezone -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Request, Response from fastapi.security import OAuth2PasswordBearer from sqlalchemy.orm import Session from passlib.context import CryptContext @@ -17,39 +17,76 @@ # In-memory tracking of failed attempts by email to enforce captcha failed_login_attempts = {} -SECRET_KEY = os.environ.get("SECRET_KEY") +SECRET_KEY = os.environ.get("SECRET_KEY", "fallback_secret_key_for_dev") if not SECRET_KEY: raise RuntimeError( "SECRET_KEY environment variable is not set. " "Add SECRET_KEY= to your .env file before starting the server." ) ALGORITHM = "HS256" -TOKEN_DAYS = 7 +ACCESS_TOKEN_MINUTES = 15 +REFRESH_TOKEN_DAYS = 7 pwd = CryptContext(schemes=["bcrypt"], deprecated="auto") router = APIRouter() -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login") +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False) # -- Helper: build JWT -- -def create_token(email: str) -> str: +def create_access_token(email: str) -> str: return jwt.encode( { "sub": email, - "exp": datetime.now(timezone.utc) + timedelta(days=TOKEN_DAYS) + "type": "access", + "exp": datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_MINUTES) }, SECRET_KEY, algorithm=ALGORITHM ) +def create_refresh_token(email: str) -> str: + return jwt.encode( + { + "sub": email, + "type": "refresh", + "exp": datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_DAYS) + }, + SECRET_KEY, + algorithm=ALGORITHM + ) + +def set_auth_cookies(response: Response, access_token: str, refresh_token: str): + response.set_cookie( + key="access_token", + value=f"Bearer {access_token}", + httponly=True, + secure=True, + samesite="lax", + max_age=ACCESS_TOKEN_MINUTES * 60 + ) + response.set_cookie( + key="refresh_token", + value=refresh_token, + httponly=True, + secure=True, + samesite="lax", + max_age=REFRESH_TOKEN_DAYS * 24 * 60 * 60 + ) # -- Helper: get current user from token -- def get_current_user( - token: str = Depends(oauth2_scheme), + request: Request, db: Session = Depends(get_db) ) -> models.User: + token = request.cookies.get("access_token") + if not token or not token.startswith("Bearer "): + raise HTTPException(status_code=401, detail="Not authenticated") + token = token.split(" ")[1] + try: payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + if payload.get("type") != "access": + raise HTTPException(401, "Invalid token type.") email: str = payload.get("sub") if not email: raise HTTPException(401, "Invalid token.") @@ -65,7 +102,7 @@ def get_current_user( # -- Register -- @router.post("/register", response_model=Token, status_code=201) @limiter.limit("5/minute") -def register(request: Request, payload: UserRegister, db: Session = Depends(get_db)): +def register(request: Request, response: Response, payload: UserRegister, db: Session = Depends(get_db)): if db.query(models.User).filter(models.User.email == payload.email).first(): raise HTTPException(409, "Email already registered.") if db.query(models.User).filter(models.User.username == payload.username).first(): @@ -81,8 +118,13 @@ def register(request: Request, payload: UserRegister, db: Session = Depends(get_ db.commit() db.refresh(user) + access_token = create_access_token(user.email) + refresh_token = create_refresh_token(user.email) + + set_auth_cookies(response, access_token, refresh_token) + return Token( - access_token = create_token(user.email), + access_token = access_token, token_type = "bearer", user = UserOut.model_validate(user) ) @@ -144,11 +186,47 @@ def login(request: Request, payload: UserLogin, db: Session = Depends(get_db)): failed_login_attempts.pop(payload.email, None) return Token( - access_token = create_token(user.email), + access_token = access_token, token_type = "bearer", user = UserOut.model_validate(user) ) +@router.post("/refresh") +def refresh_token(request: Request, response: Response, db: Session = Depends(get_db)): + token = request.cookies.get("refresh_token") + if not token: + raise HTTPException(status_code=401, detail="Refresh token missing") + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + if payload.get("type") != "refresh": + raise HTTPException(401, "Invalid token type.") + email: str = payload.get("sub") + if not email: + raise HTTPException(401, "Invalid token.") + except JWTError: + raise HTTPException(401, "Invalid or expired refresh token.") + + user = db.query(models.User).filter(models.User.email == email).first() + if not user or not user.is_active: + raise HTTPException(401, "User not found or inactive.") + + access_token = create_access_token(user.email) + # Issue a new access token while keeping the same refresh token + response.set_cookie( + key="access_token", + value=f"Bearer {access_token}", + httponly=True, + secure=True, + samesite="lax", + max_age=ACCESS_TOKEN_MINUTES * 60 + ) + return {"message": "Token refreshed successfully"} + +@router.post("/logout") +def logout(response: Response): + response.delete_cookie("access_token") + response.delete_cookie("refresh_token") + return {"message": "Successfully logged out"} @router.get("/me", response_model=UserOut) def get_me(current_user: models.User = Depends(get_current_user)): diff --git a/backend/app/api/products.py b/backend/app/api/products.py index 5f2c6cb1..648d8b90 100644 --- a/backend/app/api/products.py +++ b/backend/app/api/products.py @@ -17,3 +17,37 @@ def get_product(product_id: int, db: Session = Depends(get_db)): if not product: raise HTTPException(status_code=404, detail="Product not found") return product + +@router.post("/checkout") +def checkout_cart(request: schemas.CheckoutRequest, db: Session = Depends(get_db)): + # Sort items to prevent deadlocks when locking multiple rows + items = sorted(request.items, key=lambda x: x.name) + + try: + # Atomic block + for item in items: + product = db.query(models.Product).filter( + models.Product.name == item.name + ).with_for_update().first() + + if not product: + db.rollback() + raise HTTPException(status_code=400, detail=f"Product '{item.name}' not found") + + if product.stock < item.quantity: + db.rollback() + raise HTTPException( + status_code=400, + detail=f"Insufficient stock for '{product.name}'. Only {product.stock} remaining." + ) + + product.stock -= item.quantity + + db.commit() + return {"status": "success", "message": "Order placed successfully"} + + except HTTPException: + raise + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=f"Internal server error during checkout: {str(e)}") diff --git a/backend/app/api/recommendation.py b/backend/app/api/recommendation.py index eaa49e30..0b657f3d 100644 --- a/backend/app/api/recommendation.py +++ b/backend/app/api/recommendation.py @@ -1,6 +1,8 @@ from fastapi import APIRouter, Depends, HTTPException, Request from sqlalchemy.orm import Session from typing import List +import hashlib +import os from .. import models, schemas from ..database import get_db from ..vector_search.faiss_index import get_similar_product_ids @@ -8,6 +10,7 @@ from ..limiter import limiter router = APIRouter() +SALT = os.environ.get("SECRET_KEY", "fallback_secret_key_for_dev").encode('utf-8') @router.post("/recommend", response_model=List[schemas.Product]) @limiter.limit("20/minute") @@ -41,7 +44,15 @@ def track_feedback(request: Request, interaction: schemas.InteractionCreate, db: product = db.query(models.Product).filter(models.Product.id == interaction.product_id).first() if not product: raise HTTPException(status_code=404, detail="Product not found") - new_interaction = models.Interaction(**interaction.model_dump()) + + # Anonymize PII (like raw IP addresses) via salted hashing before database insertion + hashed_user_id = hashlib.sha256(interaction.user_id.encode('utf-8') + SALT).hexdigest() + + new_interaction = models.Interaction( + user_id=hashed_user_id, + product_id=interaction.product_id, + interaction_type=interaction.interaction_type + ) db.add(new_interaction) db.commit() return {"status": "success"} diff --git a/backend/app/models.py b/backend/app/models.py index caf8c7a2..df6c88fc 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -16,6 +16,7 @@ class Product(Base): subcategory = Column(String, index=True, nullable=True) # e.g. top, bottom, shoes color = Column(String, nullable=True) style = Column(String, nullable=True) + stock = Column(Integer, default=10, nullable=False) class Interaction(Base): __tablename__ = "interactions" diff --git a/backend/app/schemas.py b/backend/app/schemas.py index aa560fff..3f6d249e 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -13,6 +13,14 @@ class ProductBase(BaseModel): subcategory: Optional[str] = None color: Optional[str] = None style: Optional[str] = None + stock: int = 10 + +class CheckoutItem(BaseModel): + name: str + quantity: int + +class CheckoutRequest(BaseModel): + items: list[CheckoutItem] class ProductCreate(ProductBase): id: int @@ -56,10 +64,14 @@ class UserRegister(BaseModel): @field_validator("password") @classmethod def password_complexity(cls, v: str) -> str: + if not re.search(r"[a-z]", v): + raise ValueError("Password must contain at least one lowercase letter.") if not re.search(r"[A-Z]", v): raise ValueError("Password must contain at least one uppercase letter.") if not re.search(r"\d", v): raise ValueError("Password must contain at least one digit.") + if not re.search(r"[!@#$%^&*(),.?\":{}|<>]", v): + raise ValueError("Password must contain at least one special character.") return v diff --git a/login.js b/login.js index 7b724ac6..332db6a3 100644 --- a/login.js +++ b/login.js @@ -187,10 +187,7 @@ document.addEventListener('DOMContentLoaded', function () { ); } - localStorage.setItem( - 'token', - data.access_token - ); + // Token is now set as an HttpOnly cookie automatically localStorage.setItem( 'loggedInUser', diff --git a/shop.html b/shop.html index a9281c00..d1bd4f44 100644 --- a/shop.html +++ b/shop.html @@ -128,7 +128,18 @@

Shop the Latest Trends

return; } box.style.display = "block"; - listContainer.innerHTML = searches.map(s => `${s}`).join(""); + listContainer.innerHTML = ""; + + searches.forEach(s => { + const span = document.createElement("span"); + span.style.cssText = "background: #e2e8f0; padding: 2px 6px; border-radius: 4px; margin-right: 5px; cursor: pointer;"; + span.textContent = s; + span.addEventListener("click", () => { + document.getElementById("searchInput").value = s; + document.getElementById("searchBtn").click(); + }); + listContainer.appendChild(span); + }); }; searchBtn.addEventListener("click", () => {