fix
This commit is contained in:
@@ -6,18 +6,63 @@ from models.notification import PushSubscription
|
||||
from models.user import User
|
||||
from schemas.notification import PushSubscriptionCreate, VapidPublicKeyResponse
|
||||
from utils.security import get_current_active_user
|
||||
from utils.push_service import is_push_configured, send_push_to_user
|
||||
import base64
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_vapid_public_key_for_web_push():
|
||||
"""
|
||||
Convertit la clé publique VAPID au format attendu par Web Push.
|
||||
|
||||
Les clés VAPID peuvent être au format:
|
||||
1. Base64url brut (65 octets décodés) - Format attendu par Web Push
|
||||
2. PEM/DER complet (commence par MFk...) - Doit être converti
|
||||
|
||||
Web Push attend la clé publique non compressée (65 octets = 0x04 + X + Y)
|
||||
"""
|
||||
public_key = settings.VAPID_PUBLIC_KEY
|
||||
if not public_key:
|
||||
return None
|
||||
|
||||
# Nettoyer la clé (enlever les éventuels espaces/newlines)
|
||||
public_key = public_key.strip()
|
||||
|
||||
# Si la clé commence par MFk, c'est au format DER/SubjectPublicKeyInfo
|
||||
# On doit extraire les 65 derniers octets
|
||||
if public_key.startswith('MFk'):
|
||||
try:
|
||||
# Décoder le DER
|
||||
# Ajouter le padding si nécessaire
|
||||
padding = 4 - len(public_key) % 4
|
||||
if padding != 4:
|
||||
public_key += '=' * padding
|
||||
der_bytes = base64.b64decode(public_key)
|
||||
|
||||
# La clé publique non compressée est les 65 derniers octets
|
||||
# (SubjectPublicKeyInfo header + la clé)
|
||||
if len(der_bytes) >= 65:
|
||||
raw_key = der_bytes[-65:]
|
||||
# Ré-encoder en base64url sans padding
|
||||
return base64.urlsafe_b64encode(raw_key).rstrip(b'=').decode('ascii')
|
||||
except Exception as e:
|
||||
print(f"Erreur conversion clé VAPID: {e}")
|
||||
return public_key
|
||||
|
||||
return public_key
|
||||
|
||||
|
||||
@router.get("/vapid-public-key", response_model=VapidPublicKeyResponse)
|
||||
async def get_vapid_public_key(current_user: User = Depends(get_current_active_user)):
|
||||
async def get_vapid_public_key_endpoint(current_user: User = Depends(get_current_active_user)):
|
||||
"""Get the VAPID public key for push notifications."""
|
||||
if not settings.VAPID_PUBLIC_KEY:
|
||||
public_key = get_vapid_public_key_for_web_push()
|
||||
if not public_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="VAPID keys not configured on server"
|
||||
)
|
||||
return {"public_key": settings.VAPID_PUBLIC_KEY}
|
||||
return {"public_key": public_key}
|
||||
|
||||
@router.post("/subscribe")
|
||||
async def subscribe_push(
|
||||
@@ -66,3 +111,60 @@ async def unsubscribe_push(
|
||||
return {"message": "Unsubscribed successfully"}
|
||||
|
||||
|
||||
@router.post("/test")
|
||||
async def test_push_notification(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
"""Send a test push notification to the current user."""
|
||||
if not is_push_configured():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Push notifications not configured. Check VAPID keys."
|
||||
)
|
||||
|
||||
# Vérifier que l'utilisateur a au moins un abonnement
|
||||
sub_count = db.query(PushSubscription).filter(
|
||||
PushSubscription.user_id == current_user.id
|
||||
).count()
|
||||
|
||||
if sub_count == 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Aucun appareil enregistré pour les notifications push"
|
||||
)
|
||||
|
||||
# Envoyer la notification de test
|
||||
sent = send_push_to_user(
|
||||
db,
|
||||
current_user.id,
|
||||
"🔔 Test de notification",
|
||||
"Les notifications push fonctionnent correctement !",
|
||||
"/"
|
||||
)
|
||||
|
||||
return {
|
||||
"message": f"Notification de test envoyée à {sent} appareil(s)",
|
||||
"devices_registered": sub_count,
|
||||
"sent_successfully": sent
|
||||
}
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def get_push_status(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
"""Get the push notification configuration status."""
|
||||
sub_count = db.query(PushSubscription).filter(
|
||||
PushSubscription.user_id == current_user.id
|
||||
).count()
|
||||
|
||||
return {
|
||||
"configured": is_push_configured(),
|
||||
"vapid_public_key_set": bool(settings.VAPID_PUBLIC_KEY),
|
||||
"vapid_private_key_set": bool(settings.VAPID_PRIVATE_KEY),
|
||||
"user_subscriptions": sub_count
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -203,6 +203,21 @@ app.add_middleware(
|
||||
expose_headers=["Content-Range", "Accept-Ranges"],
|
||||
)
|
||||
|
||||
|
||||
# Middleware de debug pour les requêtes POST (à désactiver en production stable)
|
||||
@app.middleware("http")
|
||||
async def debug_auth_middleware(request: Request, call_next):
|
||||
"""Log les informations d'authentification pour debug."""
|
||||
if request.method == "POST" and "/api/" in request.url.path:
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
has_auth = bool(auth_header)
|
||||
auth_preview = auth_header[:30] + "..." if len(auth_header) > 30 else auth_header
|
||||
print(f"🔍 DEBUG POST {request.url.path}: auth_header={'present' if has_auth else 'MISSING'}, preview={auth_preview}")
|
||||
|
||||
response = await call_next(request)
|
||||
return response
|
||||
|
||||
|
||||
# Endpoint personnalisé pour servir les vidéos avec support Range
|
||||
@app.get("/uploads/{file_path:path}")
|
||||
async def serve_media_with_range(request: Request, file_path: str):
|
||||
|
||||
70
backend/scripts/generate_vapid_keys.py
Normal file
70
backend/scripts/generate_vapid_keys.py
Normal file
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script pour générer des clés VAPID pour les notifications push.
|
||||
|
||||
Usage:
|
||||
python scripts/generate_vapid_keys.py
|
||||
|
||||
Les clés générées doivent être ajoutées aux variables d'environnement:
|
||||
VAPID_PUBLIC_KEY=<clé_publique>
|
||||
VAPID_PRIVATE_KEY=<clé_privée>
|
||||
VAPID_CLAIMS_EMAIL=mailto:admin@example.com
|
||||
"""
|
||||
|
||||
import base64
|
||||
import os
|
||||
|
||||
try:
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
except ImportError:
|
||||
print("❌ cryptography n'est pas installé.")
|
||||
print(" Installez-le avec: pip install cryptography")
|
||||
exit(1)
|
||||
|
||||
|
||||
def generate_vapid_keys():
|
||||
"""Génère une paire de clés VAPID au format base64url."""
|
||||
# Générer une clé privée EC P-256
|
||||
private_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
|
||||
|
||||
# Extraire la clé privée brute (32 octets)
|
||||
private_numbers = private_key.private_numbers()
|
||||
private_bytes = private_numbers.private_value.to_bytes(32, byteorder='big')
|
||||
private_key_b64 = base64.urlsafe_b64encode(private_bytes).rstrip(b'=').decode('ascii')
|
||||
|
||||
# Extraire la clé publique non compressée (65 octets = 0x04 + X + Y)
|
||||
public_key = private_key.public_key()
|
||||
public_bytes = public_key.public_bytes(
|
||||
encoding=serialization.Encoding.X962,
|
||||
format=serialization.PublicFormat.UncompressedPoint
|
||||
)
|
||||
public_key_b64 = base64.urlsafe_b64encode(public_bytes).rstrip(b'=').decode('ascii')
|
||||
|
||||
return public_key_b64, private_key_b64
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("🔐 Génération des clés VAPID pour Web Push...\n")
|
||||
|
||||
public_key, private_key = generate_vapid_keys()
|
||||
|
||||
print("=" * 70)
|
||||
print("Clés VAPID générées avec succès !")
|
||||
print("=" * 70)
|
||||
print()
|
||||
print("Ajoutez ces variables à votre fichier .env ou configuration:")
|
||||
print()
|
||||
print(f'VAPID_PUBLIC_KEY={public_key}')
|
||||
print()
|
||||
print(f'VAPID_PRIVATE_KEY={private_key}')
|
||||
print()
|
||||
print('VAPID_CLAIMS_EMAIL=mailto:admin@lediscord.com')
|
||||
print()
|
||||
print("=" * 70)
|
||||
print()
|
||||
print("⚠️ IMPORTANT:")
|
||||
print(" - Gardez la clé privée SECRÈTE")
|
||||
print(" - Les clés doivent être les mêmes en dev et prod")
|
||||
print(" - Après changement de clés, les utilisateurs devront se réabonner")
|
||||
@@ -2,7 +2,7 @@ from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi import Depends, HTTPException, status, Request
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy.orm import Session
|
||||
from config.settings import settings
|
||||
@@ -11,7 +11,9 @@ from models.user import User
|
||||
from schemas.user import TokenData
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
||||
|
||||
# OAuth2 scheme avec auto_error=False pour pouvoir logger les erreurs
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify a plain password against a hashed password."""
|
||||
@@ -45,17 +47,42 @@ def verify_token(token: str, credentials_exception) -> TokenData:
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> User:
|
||||
async def get_current_user(
|
||||
token: Optional[str] = Depends(oauth2_scheme),
|
||||
db: Session = Depends(get_db)
|
||||
) -> User:
|
||||
"""Get the current authenticated user."""
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
token_data = verify_token(token, credentials_exception)
|
||||
|
||||
# Log si pas de token
|
||||
if token is None:
|
||||
print("❌ AUTH: No token provided in Authorization header")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Not authenticated - no token provided",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Log le token (premiers caractères seulement pour la sécurité)
|
||||
print(f"🔐 AUTH: Token received, length={len(token)}, preview={token[:20]}...")
|
||||
|
||||
try:
|
||||
token_data = verify_token(token, credentials_exception)
|
||||
print(f"✅ AUTH: Token valid for user_id={token_data.user_id}")
|
||||
except HTTPException as e:
|
||||
print(f"❌ AUTH: Token validation failed - {e.detail}")
|
||||
raise
|
||||
|
||||
user = db.query(User).filter(User.id == token_data.user_id).first()
|
||||
if user is None:
|
||||
print(f"❌ AUTH: User not found for id={token_data.user_id}")
|
||||
raise credentials_exception
|
||||
|
||||
print(f"✅ AUTH: User authenticated: {user.username}")
|
||||
return user
|
||||
|
||||
async def get_current_active_user(current_user: User = Depends(get_current_user)) -> User:
|
||||
|
||||
Reference in New Issue
Block a user