| | from fastapi import APIRouter, Depends, HTTPException, status |
| | from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm |
| | from sqlalchemy.orm import Session |
| | from sqlalchemy import or_ |
| | from datetime import datetime, timedelta |
| | from jose import JWTError, jwt |
| | from fastapi_mail import FastMail, MessageSchema, ConnectionConfig, MessageType |
| | import bcrypt |
| | import json |
| | import random |
| |
|
| | from src.models import get_db, User, Conversation |
| |
|
| | import os |
| |
|
| | |
| | reset_codes = {} |
| |
|
| | |
| | _mail_from = os.environ.get("MAIL_FROM", "") |
| | _mail_user = os.environ.get("MAIL_USERNAME", "") |
| | _mail_pass = os.environ.get("MAIL_PASSWORD", "") |
| |
|
| | if _mail_from and "@" in _mail_from and _mail_user and _mail_pass: |
| | conf_mail = ConnectionConfig( |
| | MAIL_USERNAME=_mail_user, |
| | MAIL_PASSWORD=_mail_pass, |
| | MAIL_FROM=_mail_from, |
| | MAIL_PORT=587, |
| | MAIL_SERVER="smtp.gmail.com", |
| | MAIL_STARTTLS=True, |
| | MAIL_SSL_TLS=False, |
| | USE_CREDENTIALS=True, |
| | VALIDATE_CERTS=True |
| | ) |
| | fast_mail = FastMail(conf_mail) |
| | else: |
| | conf_mail = None |
| | fast_mail = None |
| |
|
| | |
| | SECRET_KEY = os.environ.get("SECRET_KEY", "fallback_dev_key_change_this") |
| | ALGORITHM = "HS256" |
| | ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 |
| |
|
| | oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login") |
| |
|
| | router = APIRouter(prefix="/api/auth", tags=["auth"]) |
| |
|
| | def verify_password(plain_password: str, hashed_password: str): |
| | if isinstance(hashed_password, str): |
| | hashed_password = hashed_password.encode('utf-8') |
| | return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password) |
| |
|
| | def get_password_hash(password: str): |
| | return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') |
| |
|
| | def create_access_token(data: dict, expires_delta: timedelta = None): |
| | to_encode = data.copy() |
| | if expires_delta: |
| | expire = datetime.utcnow() + expires_delta |
| | else: |
| | expire = datetime.utcnow() + timedelta(minutes=15) |
| | to_encode.update({"exp": expire}) |
| | return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) |
| |
|
| | async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)): |
| | credentials_exception = HTTPException( |
| | status_code=status.HTTP_401_UNAUTHORIZED, |
| | detail="Could not validate credentials", |
| | headers={"WWW-Authenticate": "Bearer"}, |
| | ) |
| | try: |
| | payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) |
| | email: str = payload.get("sub") |
| | if email is None: |
| | raise credentials_exception |
| | except JWTError: |
| | raise credentials_exception |
| | |
| | user = db.query(User).filter(User.email == email).first() |
| | if user is None: |
| | raise credentials_exception |
| | return user |
| |
|
| | from fastapi import Header |
| |
|
| | async def get_user_or_session_id( |
| | authorization: str = Header(None), |
| | x_session_id: str = Header(None) |
| | ) -> str: |
| | """Extracts a private effective user ID to isolate documents and chats.""" |
| | |
| | if authorization and authorization.startswith("Bearer "): |
| | token = authorization.split(" ")[1] |
| | try: |
| | payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) |
| | user_sub = payload.get("sub") |
| | if user_sub: |
| | return f"user_{user_sub}" |
| | except JWTError: |
| | pass |
| | |
| | if x_session_id: |
| | return f"guest_{x_session_id}" |
| | |
| | return "anonymous" |
| |
|
| | from pydantic import BaseModel, EmailStr |
| | from typing import Optional |
| |
|
| | class UserCreate(BaseModel): |
| | name: str |
| | email: EmailStr |
| | password: str |
| |
|
| | class UserUpdate(BaseModel): |
| | name: Optional[str] = None |
| | picture: Optional[str] = None |
| |
|
| | class ForgotPasswordBody(BaseModel): |
| | email: EmailStr |
| |
|
| | class ResetPasswordBody(BaseModel): |
| | email: EmailStr |
| | code: str |
| | new_password: str |
| |
|
| | class GoogleLogin(BaseModel): |
| | token: str |
| |
|
| | @router.post("/register") |
| | def register(user: UserCreate, db: Session = Depends(get_db)): |
| | db_user = db.query(User).filter(User.email == user.email).first() |
| | if db_user: |
| | raise HTTPException(status_code=400, detail="El correo ya está registrado") |
| | |
| | new_user = User( |
| | email=user.email, |
| | name=user.name, |
| | hashed_password=get_password_hash(user.password), |
| | picture="https://ui-avatars.com/api/?name=" + user.name.replace(" ", "+") |
| | ) |
| | db.add(new_user) |
| | db.commit() |
| | db.refresh(new_user) |
| | |
| | access_token = create_access_token( |
| | data={"sub": new_user.email}, expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) |
| | ) |
| | |
| | return {"access_token": access_token, "token_type": "bearer", "user": {"name": new_user.name, "email": new_user.email, "picture": new_user.picture}} |
| |
|
| | @router.post("/login") |
| | def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)): |
| | user = db.query(User).filter(User.email == form_data.username).first() |
| | if not user or not user.hashed_password: |
| | raise HTTPException(status_code=400, detail="Correo o contraseña incorrectos") |
| | if not verify_password(form_data.password, user.hashed_password): |
| | raise HTTPException(status_code=400, detail="Correo o contraseña incorrectos") |
| | |
| | access_token = create_access_token( |
| | data={"sub": user.email}, expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) |
| | ) |
| | return {"access_token": access_token, "token_type": "bearer", "user": {"name": user.name, "email": user.email, "picture": user.picture}} |
| |
|
| | @router.post("/forgot-password") |
| | async def forgot_password(body: ForgotPasswordBody, db: Session = Depends(get_db)): |
| | user = db.query(User).filter(User.email == body.email).first() |
| | if not user: |
| | |
| | return {"status": "ok", "message": "Si el correo está registrado, se envió un código temporal."} |
| | |
| | code = str(random.randint(100000, 999999)) |
| | reset_codes[body.email] = code |
| | |
| | |
| | if fast_mail is not None: |
| | message = MessageSchema( |
| | subject="Recuperación de Contraseña - CareerAI", |
| | recipients=[body.email], |
| | body=f"Hola {user.name},\n\nHemos recibido una solicitud para restablecer tu contraseña.\n\nTu código de recuperación es: {code}\n\nSi no fuiste tú, ignora este mensaje.", |
| | subtype=MessageType.plain |
| | ) |
| | try: |
| | await fast_mail.send_message(message) |
| | print(f"📧 Correo Real enviado exitosamente a {body.email}") |
| | except Exception as e: |
| | print(f"❌ Error enviando el correo real: {str(e)}") |
| | else: |
| | print("\n" + "="*50) |
| | print("📧 SIMULACIÓN (Email no configurado en producción):") |
| | print(f"Para: {body.email}") |
| | print("Asunto: Recuperación de tu contraseña") |
| | print(f"Tu código de recuperación temporal es: {code}") |
| | print("="*50 + "\n") |
| | |
| | return {"status": "ok", "message": "Si el correo está registrado, se envió un código temporal."} |
| |
|
| | @router.post("/reset-password") |
| | def reset_password(body: ResetPasswordBody, db: Session = Depends(get_db)): |
| | if reset_codes.get(body.email) != body.code: |
| | raise HTTPException(status_code=400, detail="Código inválido o ya ha expirado") |
| | |
| | user = db.query(User).filter(User.email == body.email).first() |
| | if not user: |
| | raise HTTPException(status_code=400, detail="Usuario no encontrado") |
| | |
| | user.hashed_password = get_password_hash(body.new_password) |
| | db.commit() |
| | |
| | reset_codes.pop(body.email, None) |
| | return {"status": "ok", "message": "Contraseña actualizada exitosamente"} |
| |
|
| | |
| | try: |
| | from google.oauth2 import id_token |
| | from google.auth.transport import requests as google_requests |
| | GOOGLE_AUTH_AVAILABLE = True |
| | except ImportError: |
| | GOOGLE_AUTH_AVAILABLE = False |
| |
|
| | @router.post("/google") |
| | def google_login(google_data: GoogleLogin, db: Session = Depends(get_db)): |
| | if not GOOGLE_AUTH_AVAILABLE: |
| | raise HTTPException(status_code=500, detail="Google Auth is not installed properly") |
| | |
| | try: |
| | |
| | |
| | idinfo = id_token.verify_oauth2_token( |
| | google_data.token, |
| | google_requests.Request() |
| | ) |
| | |
| | email = idinfo['email'] |
| | name = idinfo.get('name', 'Google User') |
| | picture = idinfo.get('picture', '') |
| | google_id = idinfo['sub'] |
| | |
| | user = db.query(User).filter(or_(User.email == email, User.google_id == google_id)).first() |
| | |
| | if not user: |
| | |
| | user = User(email=email, name=name, picture=picture, google_id=google_id) |
| | db.add(user) |
| | db.commit() |
| | db.refresh(user) |
| | else: |
| | |
| | if not user.google_id: |
| | user.google_id = google_id |
| | if picture: |
| | user.picture = picture |
| | db.commit() |
| | |
| | access_token = create_access_token( |
| | data={"sub": user.email}, expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) |
| | ) |
| | return {"access_token": access_token, "token_type": "bearer", "user": {"name": user.name, "email": user.email, "picture": user.picture}} |
| | |
| | except ValueError as e: |
| | raise HTTPException(status_code=400, detail="Token de Google inválido") |
| |
|
| | @router.get("/me") |
| | def get_me(current_user: User = Depends(get_current_user)): |
| | return {"name": current_user.name, "email": current_user.email, "picture": current_user.picture} |
| |
|
| | @router.post("/me") |
| | def update_me(user_update: UserUpdate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): |
| | if user_update.name is not None: |
| | current_user.name = user_update.name |
| | if user_update.picture is not None: |
| | current_user.picture = user_update.picture |
| | |
| | db.commit() |
| | db.refresh(current_user) |
| | return {"name": current_user.name, "email": current_user.email, "picture": current_user.picture} |
| |
|
| | |
| | conv_router = APIRouter(prefix="/api/conversations", tags=["conversations"]) |
| |
|
| | class ConversationBody(BaseModel): |
| | id: str |
| | title: str |
| | messages: list |
| |
|
| | @conv_router.get("") |
| | def list_conversations(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): |
| | convs = db.query(Conversation).filter(Conversation.user_id == current_user.id).order_by(Conversation.updated_at.desc()).all() |
| | |
| | return [{"id": c.id, "title": c.title, "messages": c.messages} for c in convs] |
| |
|
| | @conv_router.post("") |
| | def save_conversation(data: ConversationBody, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): |
| | conv = db.query(Conversation).filter(Conversation.id == data.id).first() |
| | |
| | if conv: |
| | if conv.user_id != current_user.id: |
| | raise HTTPException(status_code=403, detail="Not authorized") |
| | conv.title = data.title |
| | conv.messages = data.messages |
| | |
| | else: |
| | conv = Conversation( |
| | id=data.id, |
| | user_id=current_user.id, |
| | title=data.title, |
| | messages=data.messages |
| | ) |
| | db.add(conv) |
| | |
| | db.commit() |
| | return {"status": "ok", "message": "Conversación guardada"} |
| |
|
| | @conv_router.delete("/{conv_id}") |
| | def delete_conversation(conv_id: str, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): |
| | conv = db.query(Conversation).filter(Conversation.id == conv_id).first() |
| | if not conv: |
| | raise HTTPException(status_code=404, detail="Not found") |
| | if conv.user_id != current_user.id: |
| | raise HTTPException(status_code=403, detail="Not authorized") |
| | |
| | db.delete(conv) |
| | db.commit() |
| | return {"status": "ok", "message": "Conversación eliminada"} |
| |
|