feat: fix registration 500, add forgot-password flow, frontend validation
Some checks failed
Build and Deploy / Backend Tests (push) Has been cancelled
Build and Deploy / Frontend Build Check (push) Has been cancelled
Build and Deploy / Build Docker Images (push) Has been cancelled
Build and Deploy / Deploy to Server (push) Has been cancelled

- 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:
Sepehr Ramezani
2026-05-01 16:23:51 +02:00
parent 26bd096a06
commit 2f7347b4db
25 changed files with 2765 additions and 64 deletions

View File

@@ -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) ==============