feat: fix registration 500, add forgot-password flow, frontend validation
Some checks failed
Some checks failed
- Fix MissingGreenlet: sync_engine now uses psycopg2 instead of asyncpg - Fix bcrypt/passlib compat: pin bcrypt<4.1 in requirements - Fix legacy password_hash NOT NULL: alter column to nullable in migration - Add frontend password validation (uppercase + lowercase + digit) - Add forgot-password and reset-password backend endpoints - Add forgot-password and reset-password frontend pages - Add email_service.py (SMTP via admin settings) - Add reset_token/reset_token_expires columns to User model - Migrate legacy JSON-only users to DB on password reset request - Mount data/ volume in docker-compose.local.yml for persistence - Add production deployment config (Dockerfile, nginx, deploy.sh) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,9 @@ Story 3.6: Documentation OpenAPI complète avec exemples et codes d'erreur
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import timedelta
|
||||
import secrets
|
||||
import logging
|
||||
from datetime import timedelta, datetime, timezone
|
||||
from fastapi import APIRouter, HTTPException, Depends, Header, Request, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
@@ -44,6 +46,8 @@ from services.payment_service import (
|
||||
is_stripe_configured,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
@@ -895,6 +899,236 @@ async def get_billing_portal_v1(user=Depends(require_user)):
|
||||
return JSONResponse(status_code=200, content={"data": {"url": url}, "meta": {}})
|
||||
|
||||
|
||||
# ============== Forgot / Reset password ==============
|
||||
|
||||
|
||||
class ForgotPasswordRequest(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class ResetPasswordRequest(BaseModel):
|
||||
token: str
|
||||
password: str
|
||||
|
||||
|
||||
@router_v1.post(
|
||||
"/forgot-password",
|
||||
summary="Demander une reinitialisation de mot de passe",
|
||||
description="""
|
||||
Envoie un email de reinitialisation si un compte existe avec cette adresse.
|
||||
|
||||
**Securite:** Retourne toujours 200 pour ne pas reveler si l'email existe.
|
||||
""",
|
||||
)
|
||||
async def forgot_password(request: Request):
|
||||
from services.email_service import is_smtp_configured, send_email_async
|
||||
|
||||
if not is_smtp_configured():
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={
|
||||
"error": "EMAIL_SERVICE_UNAVAILABLE",
|
||||
"message": "Service email non configure",
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_REQUEST",
|
||||
"message": "Corps de requete JSON invalide",
|
||||
},
|
||||
)
|
||||
|
||||
email = body.get("email", "").strip()
|
||||
if not email:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_REQUEST",
|
||||
"message": "Email requis",
|
||||
},
|
||||
)
|
||||
|
||||
# Always return 200 to avoid email enumeration
|
||||
user = get_user_by_email(email)
|
||||
|
||||
if user and user.id:
|
||||
token = secrets.token_urlsafe(32)
|
||||
expires = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
|
||||
# Store token in database
|
||||
try:
|
||||
from database.connection import get_sync_session
|
||||
from database.repositories import UserRepository
|
||||
from database.models import User as DBUser
|
||||
|
||||
with get_sync_session() as session:
|
||||
repo = UserRepository(session)
|
||||
|
||||
# Check if user exists in DB; if not (legacy JSON-only user), create DB record
|
||||
db_user = repo.get_by_email(email)
|
||||
if not db_user:
|
||||
db_user = repo.create(
|
||||
email=user.email,
|
||||
name=user.name,
|
||||
hashed_password=user.password_hash or "",
|
||||
tier="free",
|
||||
)
|
||||
|
||||
repo.update(
|
||||
db_user.id,
|
||||
reset_token=token,
|
||||
reset_token_expires=expires,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to store reset token: {exc}")
|
||||
# Still return 200 for security
|
||||
|
||||
# Send email with reset link
|
||||
frontend_url = os.getenv("FRONTEND_URL", os.getenv("NEXT_PUBLIC_APP_URL", "http://localhost:3000"))
|
||||
reset_link = f"{frontend_url}/auth/reset-password?token={token}"
|
||||
|
||||
html_body = f"""
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif; padding: 20px;">
|
||||
<h2>Reinitialisation de votre mot de passe</h2>
|
||||
<p>Vous avez demande la reinitialisation de votre mot de passe.</p>
|
||||
<p>Cliquez sur le lien ci-dessous pour definir un nouveau mot de passe :</p>
|
||||
<p><a href="{reset_link}" style="background-color: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;">Reinitialiser mon mot de passe</a></p>
|
||||
<p>Ce lien expire dans 1 heure.</p>
|
||||
<p>Si vous n'avez pas fait cette demande, ignorez cet email.</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
await send_email_async(
|
||||
to=email,
|
||||
subject="Reinitialisation de votre mot de passe - Office Translator",
|
||||
body=html_body,
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"data": {
|
||||
"message": "Si un compte existe avec cette adresse, un email de reinitialisation a ete envoye."
|
||||
},
|
||||
"meta": {},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router_v1.post(
|
||||
"/reset-password",
|
||||
summary="Reinitialiser le mot de passe",
|
||||
description="""
|
||||
Reinitialise le mot de passe a l'aide d'un token valide.
|
||||
|
||||
**Parametres requis:**
|
||||
- `token`: Le token recu par email
|
||||
- `password`: Le nouveau mot de passe (min 8 caracteres, 1 majuscule, 1 minuscule, 1 chiffre)
|
||||
""",
|
||||
)
|
||||
async def reset_password(request: Request):
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_REQUEST",
|
||||
"message": "Corps de requete JSON invalide",
|
||||
},
|
||||
)
|
||||
|
||||
token = body.get("token", "").strip()
|
||||
password = body.get("password", "")
|
||||
|
||||
if not token or not password:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_REQUEST",
|
||||
"message": "Token et mot de passe requis",
|
||||
},
|
||||
)
|
||||
|
||||
# Validate password strength
|
||||
if len(password) < 8 or not any(c.isupper() for c in password) or not any(c.islower() for c in password) or not any(c.isdigit() for c in password):
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "WEAK_PASSWORD",
|
||||
"message": "Le mot de passe doit contenir au moins 8 caracteres, une majuscule, une minuscule et un chiffre",
|
||||
},
|
||||
)
|
||||
|
||||
# Find user by reset token
|
||||
try:
|
||||
from database.connection import get_sync_session
|
||||
from database.models import User as DBUser
|
||||
from services.auth_service import hash_password
|
||||
|
||||
with get_sync_session() as session:
|
||||
db_user = (
|
||||
session.query(DBUser)
|
||||
.filter(DBUser.reset_token == token)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not db_user:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_TOKEN",
|
||||
"message": "Token invalide ou expire",
|
||||
},
|
||||
)
|
||||
|
||||
# Check token expiry
|
||||
if db_user.reset_token_expires:
|
||||
expires = db_user.reset_token_expires
|
||||
if expires.tzinfo is None:
|
||||
expires = expires.replace(tzinfo=timezone.utc)
|
||||
if expires < datetime.now(timezone.utc):
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "TOKEN_EXPIRED",
|
||||
"message": "Token expire, veuillez redemander une reinitialisation",
|
||||
},
|
||||
)
|
||||
|
||||
# Update password and invalidate token
|
||||
db_user.hashed_password = hash_password(password)
|
||||
db_user.reset_token = None
|
||||
db_user.reset_token_expires = None
|
||||
db_user.updated_at = datetime.utcnow()
|
||||
session.commit()
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"Reset password failed: {exc}")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"error": "RESET_FAILED",
|
||||
"message": "Erreur lors de la reinitialisation",
|
||||
},
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"data": {"message": "Mot de passe reinitialise avec succes"},
|
||||
"meta": {},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ============== Stripe webhook (versioned) ==============
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user