app / src /auth.py
CareerAI-app's picture
Deploy CareerAI to HuggingFace Spaces
b7934cd
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
# Memory dictionary to mock sent emails for recovery
reset_codes = {}
# REAL EMAIL CONFIGURATION (reads from .env) — only if configured
_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
# JWT configuration (reads from .env)
SECRET_KEY = os.environ.get("SECRET_KEY", "fallback_dev_key_change_this")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days validity
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."""
# 1. Try logged-in user from JWT
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
# 2. Try anonymous session header
if x_session_id:
return f"guest_{x_session_id}"
# 3. Fallback
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:
# Prevent user-enumeration, always return ok
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
# Send actual email if configured, otherwise print to terminal
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) # Invalidate token safely
return {"status": "ok", "message": "Contraseña actualizada exitosamente"}
# Try to import Google Auth (if installed)
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:
# Avoid verifying clientId to allow any client side requests for demo purposes
# In production use ONLY your registered CLIENT_ID
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:
# Create user automatically
user = User(email=email, name=name, picture=picture, google_id=google_id)
db.add(user)
db.commit()
db.refresh(user)
else:
# Update user info if needed
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}
# ================= Conversations Router Endpoints =================
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()
# Format according to frontend expectations
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
# updated_at will auto-update
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"}