diff --git a/backend/.env.example b/backend/.env.example index ec7a048..f420a06 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -25,3 +25,19 @@ SECRET_KEY=cambia-esta-clave-en-produccion # Con Vite directo en local: http://localhost:5173 # En producción: https://tu-dominio.com FRONTEND_URL=http://localhost:5173 + +# Verificación de email por código numérico +# Si SMTP_HOST no está configurado, el backend imprimirá el código en consola. +EMAIL_VERIFICATION_CODE_TTL_MINUTES=15 +EMAIL_VERIFICATION_MAX_ATTEMPTS=5 +EMAIL_VERIFICATION_SECRET=cambia-este-secreto-en-produccion + +# SMTP para enviar los códigos de verificación +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USE_TLS=true +SMTP_USE_SSL=false +SMTP_USERNAME=tu-correo@gmail.com +SMTP_PASSWORD=tu-password-o-app-password +SMTP_FROM_EMAIL=tu-correo@gmail.com +SMTP_FROM_NAME=Deck of Cards diff --git a/backend/api/models/user_models.py b/backend/api/models/user_models.py index 3049a70..499d7b0 100644 --- a/backend/api/models/user_models.py +++ b/backend/api/models/user_models.py @@ -45,10 +45,21 @@ class UserLogin(BaseModel): password: str +class EmailVerificationRequest(BaseModel): + email: EmailStr + code: str + + +class EmailVerificationResendRequest(BaseModel): + email: EmailStr + + class UserInDB(BaseModel): id: Optional[str] = Field(default=None, alias="_id") username: str email: EmailStr - password_hash: str + password_hash: Optional[str] = None token: Optional[str] = None history: List[HistoryItem] = [] + is_email_verified: bool = False + email_verified_at: Optional[datetime] = None diff --git a/backend/api/routers/auth.py b/backend/api/routers/auth.py index e0d6c68..f05aa5d 100644 --- a/backend/api/routers/auth.py +++ b/backend/api/routers/auth.py @@ -1,14 +1,29 @@ -from fastapi import APIRouter, HTTPException, status -from api.database.mongodb import users_collection -from api.models.user_models import UserCreate, UserLogin -from api.utils.security import hash_password, verify_password, generate_token +from datetime import datetime + from bson import ObjectId -from fastapi import APIRouter, HTTPException, status, Depends +from fastapi import APIRouter, Depends, HTTPException, status + +from api.database.mongodb import users_collection +from api.models.user_models import ( + EmailVerificationRequest, + EmailVerificationResendRequest, + UserCreate, + UserLogin, +) +from api.services.email_service import send_verification_email +from api.utils.email_verification import ( + MAX_EMAIL_VERIFICATION_ATTEMPTS, + generate_verification_code, + get_verification_code_expiration, + hash_verification_code, + is_verification_code_expired, + verify_verification_code, +) from api.utils.security import ( - hash_password, - verify_password, generate_token, get_current_user, + hash_password, + verify_password, ) router = APIRouter(prefix="/auth", tags=["auth"]) @@ -16,6 +31,8 @@ router = APIRouter(prefix="/auth", tags=["auth"]) @router.post("/register") async def register_user(user: UserCreate): + email = str(user.email).strip().lower() + existing_username = await users_collection.find_one({"username": user.username}) if existing_username: raise HTTPException( @@ -23,47 +40,214 @@ async def register_user(user: UserCreate): detail="El nombre de usuario ya está en uso", ) - existing_email = await users_collection.find_one({"email": user.email}) + existing_email = await users_collection.find_one({"email": email}) if existing_email: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="El email ya está registrado", ) - token = generate_token() + verification_code = generate_verification_code() user_doc = { "username": user.username, - "email": user.email, + "email": email, "password_hash": hash_password(user.password), - "token": token, + "token": None, "history": [], + "is_email_verified": False, + "email_verification_code_hash": hash_verification_code( + email, + verification_code, + ), + "email_verification_expires_at": get_verification_code_expiration(), + "email_verification_attempts": 0, + "email_verification_requested_at": datetime.utcnow(), } result = await users_collection.insert_one(user_doc) + try: + send_verification_email(email, user.username, verification_code) + except Exception as exc: + await users_collection.delete_one({"_id": result.inserted_id}) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="No se pudo enviar el correo de verificación", + ) from exc + return { - "message": "Usuario registrado correctamente", + "message": "Usuario registrado. Revisa tu correo para verificar la cuenta.", "user_id": str(result.inserted_id), - "token": token, + "email": email, + "requires_verification": True, } +@router.post("/verify-email") +async def verify_email(payload: EmailVerificationRequest): + email = str(payload.email).strip().lower() + code = payload.code.strip() + + if len(code) != 6 or not code.isdigit(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="El código debe tener 6 dígitos", + ) + + user = await users_collection.find_one({"email": email}) + + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Usuario no encontrado", + ) + + if user.get("is_email_verified"): + new_token = generate_token() + await users_collection.update_one( + {"_id": user["_id"]}, + {"$set": {"token": new_token}}, + ) + return { + "message": "Email ya verificado", + "user_id": str(user["_id"]), + "username": user["username"], + "email": user["email"], + "token": new_token, + } + + code_hash = user.get("email_verification_code_hash") + expires_at = user.get("email_verification_expires_at") + attempts = user.get("email_verification_attempts", 0) + + if not code_hash or not expires_at: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Solicita un nuevo código de verificación", + ) + + if is_verification_code_expired(expires_at): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="El código de verificación ha caducado", + ) + + if attempts >= MAX_EMAIL_VERIFICATION_ATTEMPTS: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Demasiados intentos. Solicita un nuevo código.", + ) + + if not verify_verification_code(email, code, code_hash): + attempts += 1 + await users_collection.update_one( + {"_id": user["_id"]}, + {"$set": {"email_verification_attempts": attempts}}, + ) + raise HTTPException( + status_code=( + status.HTTP_429_TOO_MANY_REQUESTS + if attempts >= MAX_EMAIL_VERIFICATION_ATTEMPTS + else status.HTTP_400_BAD_REQUEST + ), + detail="Código de verificación inválido", + ) + + new_token = generate_token() + await users_collection.update_one( + {"_id": user["_id"]}, + { + "$set": { + "is_email_verified": True, + "email_verified_at": datetime.utcnow(), + "token": new_token, + }, + "$unset": { + "email_verification_code_hash": "", + "email_verification_expires_at": "", + "email_verification_attempts": "", + "email_verification_requested_at": "", + }, + }, + ) + + return { + "message": "Email verificado correctamente", + "user_id": str(user["_id"]), + "username": user["username"], + "email": user["email"], + "token": new_token, + } + + +@router.post("/resend-verification") +async def resend_verification_email(payload: EmailVerificationResendRequest): + email = str(payload.email).strip().lower() + user = await users_collection.find_one({"email": email}) + + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Usuario no encontrado", + ) + + if user.get("is_email_verified"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="El email ya está verificado", + ) + + verification_code = generate_verification_code() + await users_collection.update_one( + {"_id": user["_id"]}, + { + "$set": { + "email_verification_code_hash": hash_verification_code( + email, + verification_code, + ), + "email_verification_expires_at": get_verification_code_expiration(), + "email_verification_attempts": 0, + "email_verification_requested_at": datetime.utcnow(), + }, + }, + ) + + try: + send_verification_email(email, user["username"], verification_code) + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="No se pudo enviar el correo de verificación", + ) from exc + + return {"message": "Nuevo código de verificación enviado"} + + @router.post("/login") async def login_user(credentials: UserLogin): - user = await users_collection.find_one({"email": credentials.email}) + email = str(credentials.email).strip().lower() + user = await users_collection.find_one({"email": email}) if not user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Credenciales inválidas", ) - if not verify_password(credentials.password, user["password_hash"]): + password_hash = user.get("password_hash") + if not password_hash or not verify_password(credentials.password, password_hash): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Credenciales inválidas", ) + if user.get("is_email_verified") is False: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Debes verificar tu email antes de iniciar sesión", + ) + new_token = generate_token() await users_collection.update_one( @@ -75,6 +259,7 @@ async def login_user(credentials: UserLogin): "message": "Login correcto", "user_id": str(user["_id"]), "username": user["username"], + "email": user["email"], "token": new_token, } diff --git a/backend/api/routers/google_auth.py b/backend/api/routers/google_auth.py index 1c787b8..c168acb 100644 --- a/backend/api/routers/google_auth.py +++ b/backend/api/routers/google_auth.py @@ -76,6 +76,8 @@ async def google_callback(request: Request): "password_hash": None, "google_id": google_id, "history": [], + "is_email_verified": True, + "email_verified_at": datetime.utcnow(), } result = await users_collection.insert_one(new_user) user_id = result.inserted_id @@ -95,7 +97,18 @@ async def google_callback(request: Request): await users_collection.update_one( {"_id": user_id}, - {"$set": {"token": token}} + { + "$set": { + "token": token, + "is_email_verified": True, + }, + "$unset": { + "email_verification_code_hash": "", + "email_verification_expires_at": "", + "email_verification_attempts": "", + "email_verification_requested_at": "", + }, + }, ) return RedirectResponse(f"{FRONTEND_URL}/login?token={token}") \ No newline at end of file diff --git a/backend/api/services/email_service.py b/backend/api/services/email_service.py new file mode 100644 index 0000000..c2e9411 --- /dev/null +++ b/backend/api/services/email_service.py @@ -0,0 +1,65 @@ +import os +import smtplib +import ssl +from email.message import EmailMessage + + +def _env_bool(name: str, default: str = "false") -> bool: + return os.getenv(name, default).strip().lower() in {"1", "true", "yes", "on"} + + +def send_verification_email(recipient_email: str, username: str, code: str) -> bool: + smtp_host = os.getenv("SMTP_HOST") + if not smtp_host: + print( + "[email-verification] SMTP_HOST no configurado. " + f"Código para {recipient_email}: {code}" + ) + return False + + smtp_port = int(os.getenv("SMTP_PORT", "587")) + smtp_username = os.getenv("SMTP_USERNAME") + smtp_password = os.getenv("SMTP_PASSWORD") + smtp_from_email = os.getenv("SMTP_FROM_EMAIL") or smtp_username + smtp_from_name = os.getenv("SMTP_FROM_NAME", "Deck of Cards") + use_tls = _env_bool("SMTP_USE_TLS", "true") + use_ssl = _env_bool("SMTP_USE_SSL", "false") + + if not smtp_from_email: + raise RuntimeError("SMTP_FROM_EMAIL o SMTP_USERNAME debe estar configurado") + + message = EmailMessage() + message["Subject"] = "Código de verificación de Deck of Cards" + message["From"] = f"{smtp_from_name} <{smtp_from_email}>" + message["To"] = recipient_email + message.set_content( + "\n".join( + [ + f"Hola {username},", + "", + "Tu código de verificación para Deck of Cards es:", + "", + code, + "", + "Este código caduca en unos minutos. Si no has creado esta cuenta,", + "puedes ignorar este correo.", + ] + ) + ) + + context = ssl.create_default_context() + + if use_ssl: + with smtplib.SMTP_SSL(smtp_host, smtp_port, context=context) as server: + if smtp_username and smtp_password: + server.login(smtp_username, smtp_password) + server.send_message(message) + else: + with smtplib.SMTP(smtp_host, smtp_port) as server: + if use_tls: + server.starttls(context=context) + if smtp_username and smtp_password: + server.login(smtp_username, smtp_password) + server.send_message(message) + + return True diff --git a/backend/api/utils/email_verification.py b/backend/api/utils/email_verification.py new file mode 100644 index 0000000..c7923c5 --- /dev/null +++ b/backend/api/utils/email_verification.py @@ -0,0 +1,45 @@ +import hashlib +import hmac +import os +import secrets +from datetime import datetime, timedelta + + +EMAIL_VERIFICATION_CODE_TTL_MINUTES = int( + os.getenv("EMAIL_VERIFICATION_CODE_TTL_MINUTES", "15") +) +MAX_EMAIL_VERIFICATION_ATTEMPTS = int( + os.getenv("EMAIL_VERIFICATION_MAX_ATTEMPTS", "5") +) + + +def generate_verification_code() -> str: + return f"{secrets.randbelow(1_000_000):06d}" + + +def get_verification_code_expiration() -> datetime: + return datetime.utcnow() + timedelta(minutes=EMAIL_VERIFICATION_CODE_TTL_MINUTES) + + +def _verification_secret() -> bytes: + secret = ( + os.getenv("EMAIL_VERIFICATION_SECRET") + or os.getenv("SECRET_KEY") + or "development-email-verification-secret" + ) + return secret.encode("utf-8") + + +def hash_verification_code(email: str, code: str) -> str: + normalized_email = email.strip().lower() + payload = f"{normalized_email}:{code}".encode("utf-8") + return hmac.new(_verification_secret(), payload, hashlib.sha256).hexdigest() + + +def verify_verification_code(email: str, code: str, code_hash: str) -> bool: + expected_hash = hash_verification_code(email, code) + return hmac.compare_digest(expected_hash, code_hash) + + +def is_verification_code_expired(expires_at: datetime) -> bool: + return expires_at < datetime.utcnow() diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx index a7d012f..0afb43b 100644 --- a/frontend/src/pages/Login.jsx +++ b/frontend/src/pages/Login.jsx @@ -62,7 +62,7 @@ export default function Login() { login(data); navigate('/'); } catch (err) { - setError('Credenciales incorrectas.'); + setError(err.response?.data?.detail || err.backendData?.detail || 'Credenciales incorrectas.'); } }; diff --git a/frontend/src/pages/Register.jsx b/frontend/src/pages/Register.jsx index 7642dd9..02881ff 100644 --- a/frontend/src/pages/Register.jsx +++ b/frontend/src/pages/Register.jsx @@ -9,30 +9,88 @@ export default function Register() { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); + const [verificationCode, setVerificationCode] = useState(''); + const [pendingEmail, setPendingEmail] = useState(''); + const [verificationRequired, setVerificationRequired] = useState(false); const [showPassword, setShowPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false); const [error, setError] = useState(''); + const [infoMessage, setInfoMessage] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isResending, setIsResending] = useState(false); const navigate = useNavigate(); const { login } = useAuth(); - const handleSubmit = async (e) => { + const getErrorMessage = (err, fallback) => ( + err.response?.data?.detail || err.backendData?.detail || fallback + ); + + const handleRegisterSubmit = async (e) => { e.preventDefault(); setError(''); + setInfoMessage(''); if (password !== confirmPassword) { setError('Las contraseñas no coinciden. Por favor, revísalas.'); return; } + setIsSubmitting(true); try { const data = await authService.register(username, email, password); - const userData = { id: data.user_id, username: username, email: email }; - login(userData, data.token); + setPendingEmail(data.email || email.trim().toLowerCase()); + setVerificationRequired(true); + setInfoMessage(data.message || 'Te hemos enviado un código de verificación por correo.'); + } catch (err) { + setError(getErrorMessage(err, 'Error al registrar el usuario.')); + } finally { + setIsSubmitting(false); + } + }; + + const handleVerificationSubmit = async (e) => { + e.preventDefault(); + setError(''); + setInfoMessage(''); + + if (!verificationCode.trim()) { + setError('Introduce el código de verificación.'); + return; + } + + setIsSubmitting(true); + try { + const data = await authService.verifyEmail(pendingEmail, verificationCode); + login({ + user: { + id: data.user_id, + username: data.username, + email: data.email, + }, + token: data.token, + }); navigate('/'); } catch (err) { - setError(err.response?.data?.detail || 'Error al registrar el usuario.'); + setError(getErrorMessage(err, 'No se pudo verificar el email.')); + } finally { + setIsSubmitting(false); + } + }; + + const handleResendVerification = async () => { + setError(''); + setInfoMessage(''); + setIsResending(true); + + try { + const data = await authService.resendVerification(pendingEmail); + setInfoMessage(data.message || 'Nuevo código enviado.'); + } catch (err) { + setError(getErrorMessage(err, 'No se pudo reenviar el código.')); + } finally { + setIsResending(false); } }; @@ -41,8 +99,14 @@ export default function Register() {
Inicia sesión para guardar tu progreso
++ {verificationRequired + ? `Introduce el código enviado a ${pendingEmail}` + : 'Inicia sesión para guardar tu progreso'} +
¿Ya tienes cuenta? Inicia sesión aquí diff --git a/frontend/src/services/authService.js b/frontend/src/services/authService.js index f568a1d..831a7b1 100644 --- a/frontend/src/services/authService.js +++ b/frontend/src/services/authService.js @@ -11,6 +11,16 @@ export const authService = { return response.data; }, + verifyEmail: async (email, code) => { + const response = await api.post('/auth/verify-email', { email, code }); + return response.data; + }, + + resendVerification: async (email) => { + const response = await api.post('/auth/resend-verification', { email }); + return response.data; + }, + getCurrentUser: async () => { const response = await api.get('/auth/me'); return response.data;