# User Story: Authentification Utilisateur **ID** : US-001 **Epic** : Epic 4 - User Authentication & Access Control **Status** : Draft **Priorité** : P0 - Critique (bloque toutes les autres fonctionnalités) --- ## 📋 Description En tant qu'utilisateur, je veux créer un compte sur Chartbastan pour accéder au dashboard et consulter les prédictions de matchs. ## 🎯 Objectifs d'Utilisateur 1. Créer un compte avec email et mot de passe 2. Me connecter à mon compte 3. Rester connecté entre les sessions 4. Accéder au dashboard personnalisé ## ✅ Critères de Succès ### Scénario 1 : Inscription **Given** : Je suis sur la page d'inscription `http://localhost:3000/register` **When** : Je remplis le formulaire avec des données valides **Then** : - ✅ Mon compte est créé dans la base de données - ✅ Un email de confirmation est envoyé (optionnel) - ✅ Je suis automatiquement connecté et redirigé vers le dashboard - ✅ Mes préférences sont initialisées (langue, notifications) **Validation** : - Email : Format valide, unique dans la base de données - Mot de passe : Minimum 8 caractères, 1 majuscule, 1 chiffre, 1 caractère spécial - Confirmation mot de passe : Doit correspondre ### Scénario 2 : Connexion **Given** : Je suis un utilisateur enregistré **When** : Je vais sur la page de connexion `http://localhost:3000/login` **Then** : - ✅ Je peux me connecter avec email et mot de passe - ✅ Je suis redirigé vers le dashboard - ✅ Ma session persiste (je reste connecté après fermer le navigateur) - ✅ J'accède à mes prédictions personnalisées **Validation** : - Identifiants corrects - Session créée avec token JWT - Cookies sécurisés (HttpOnly, Secure, SameSite) ### Scénario 3 : Déconnexion **Given** : Je suis connecté au dashboard **When** : Je clique sur "Déconnexion" **Then** : - ✅ Ma session est terminée - ✅ Je suis redirigé vers la page de connexion - ✅ Toutes mes données sensibles sont effacées du navigateur ### Scénario 4 : Réinitialisation Mot de Passe **Given** : J'ai oublié mon mot de passe **When** : Je clique sur "Mot de passe oublié ?" **Then** : - ✅ Je peux demander une réinitialisation par email - ✅ Un email avec lien de réinitialisation est envoyé - ✅ Je peux définir un nouveau mot de passe via le lien --- ## 📱 Composants Frontend Requis ### Page d'Inscription (`src/app/(auth)/register/page.tsx`) ```tsx // Interface du formulaire interface RegisterFormData { email: string; password: string; confirmPassword: string; name?: string; referralCode?: string; } // Validation des inputs const registerValidation = { email: { required: "L'email est requis", pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: "Format d'email invalide" }, password: { required: "Le mot de passe est requis", minLength: { value: 8, message: "Le mot de passe doit contenir au moins 8 caractères" }, pattern: { value: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&])/, message: "Le mot de passe doit contenir 1 majuscule, 1 minuscule et 1 chiffre" } }, confirmPassword: { required: "La confirmation est requise", validate: (value: string) => value === watch('password'), message: "Les mots de passe ne correspondent pas" } }; export default function RegisterPage() { const handleSubmit = async (formData: RegisterFormData) => { // Call API backend const response = await fetch('/api/v1/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(formData) }); const data = await response.json(); if (response.ok) { // Success - Store session and redirect window.location.href = '/dashboard'; } else { // Error - Display error message alert(data.error.message || "Erreur lors de l'inscription"); } }; return (
Créer un compte Chartbastan Rejoignez la communauté des prédictions sportives basées sur l'énergie collective.
{/* Email Input */}

Utilisé pour la connexion et la récupération de compte

{/* Password Input */}

Minimum 8 caractères : 1 majuscule, 1 minuscule, 1 chiffre

{/* Confirm Password Input */}
{/* Name Input (Optional) */}
{/* Referral Code Input (Optional) */}

Invitez 3 amis pour obtenir 1 mois premium GRATUIT

{/* Submit Button */} {/* Login Link */}

Déjà inscrit ?{" "} Connectez-vous

); } ``` ### Page de Connexion (`src/app/(auth)/login/page.tsx`) ```tsx // Interface du formulaire interface LoginFormData { email: string; password: string; rememberMe: boolean; } export default function LoginPage() { const handleSubmit = async (formData: LoginFormData) => { // Call API backend const response = await fetch('/api/v1/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(formData) }); const data = await response.json(); if (response.ok) { // Success - Store session and redirect window.location.href = '/dashboard'; } else { // Error - Display error message alert(data.error.message || "Email ou mot de passe incorrect"); } }; return (
Connexion Accédez à votre dashboard et consultez les prédictions de matchs.
{/* Email Input */}
{/* Password Input */}
{/* Remember Me Checkbox */}
{/* Forgot Password Link */}
Mot de passe oublié ?
{/* Submit Button */} {/* Register Link */}

Pas encore de compte ?{" "} Créer un compte

); } ``` ### Session Management Hook (`src/hooks/useAuth.ts`) ```typescript import { useState, useEffect } from 'react'; export interface User { id: number; email: string; name?: string; isPremium: boolean; createdAt: string; } export interface AuthState { user: User | null; isAuthenticated: boolean; isLoading: boolean; error: string | null; } export function useAuth() { const [state, setState] = useState({ user: null, isAuthenticated: false, isLoading: false, error: null }); // Load session from localStorage on mount useEffect(() => { const loadSession = () => { const session = localStorage.getItem('chartbastan_session'); if (session) { const user = JSON.parse(session); setState({ user, isAuthenticated: true, isLoading: false, error: null }); } }; loadSession(); }, []); const login = async (email: string, password: string, rememberMe: boolean = false) => { setState({ isLoading: true, error: null }); try { const response = await fetch('/api/v1/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }) }); const data = await response.json(); if (response.ok) { const { user, token } = data; // Store session if (rememberMe) { localStorage.setItem('chartbastan_session', JSON.stringify(user)); localStorage.setItem('chartbastan_token', token); } setState({ user, isAuthenticated: true, isLoading: false, error: null }); return true; } else { throw new Error(data.error?.message || 'Échec de la connexion'); } } catch (error) { setState({ user: null, isAuthenticated: false, isLoading: false, error: error instanceof Error ? error.message : 'Erreur de connexion' }); return false; } }; const logout = async () => { setState({ isLoading: true, error: null }); try { await fetch('/api/v1/auth/logout', { method: 'POST', headers: { 'Content-Type': 'application/json' } }); // Clear local storage localStorage.removeItem('chartbastan_session'); localStorage.removeItem('chartbastan_token'); setState({ user: null, isAuthenticated: false, isLoading: false, error: null }); window.location.href = '/login'; } catch (error) { setState({ error: error instanceof Error ? error.message : 'Erreur de déconnexion' }); } }; const register = async (formData: { email: string; password: string; name?: string; referralCode?: string; }) => { setState({ isLoading: true, error: null }); try { const response = await fetch('/api/v1/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(formData) }); const data = await response.json(); if (response.ok) { const { user, token } = data; // Auto-login after registration localStorage.setItem('chartbastan_session', JSON.stringify(user)); localStorage.setItem('chartbastan_token', token); setState({ user, isAuthenticated: true, isLoading: false, error: null }); return true; } else { throw new Error(data.error?.message || 'Échec de l\'inscription'); } } catch (error) { setState({ user: null, isAuthenticated: false, isLoading: false, error: error instanceof Error ? error.message : 'Erreur d\'inscription' }); return false; } }; return { ...state, login, logout, register, clearError: () => setState({ error: null }) }; } ``` --- ## 🔌 API Backend Requis ### Endpoints à Créer dans `backend/app/api/v1/auth.py` ```python from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session from pydantic import BaseModel, EmailStr, Field from app.database import get_db from app.models.user import User from passlib.context import CryptContext router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) # Configuration hashing pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") # Schemas class RegisterRequest(BaseModel): email: EmailStr password: str = Field(..., min_length=8, regex=r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)') name: str | None = None referral_code: str | None = None class LoginRequest(BaseModel): email: EmailStr password: str remember_me: bool = False class UserResponse(BaseModel): id: int email: str name: str | None is_premium: bool created_at: str token: str # Endpoints @router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED) async def register(request: RegisterRequest, db: Session = Depends(get_db)): """ Inscription d'un nouvel utilisateur. Validation: - Email unique dans la base - Mot de passe hashé avec pbkdf2_sha256 - Création automatique de la session """ # Vérifier si l'email existe déjà existing_user = db.query(User).filter(User.email == request.email).first() if existing_user: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Cet email est déjà associé à un compte" ) # Hasher le mot de passe password_hash = pwd_context.hash(request.password) # Créer l'utilisateur new_user = User( email=request.email, password_hash=password_hash, name=request.name, is_premium=False, # Utilisateur gratuit par défaut referral_code=request.referral_code ) db.add(new_user) db.commit() db.refresh(new_user) # Générer le token JWT (simulation pour Phase 1) # TODO: Intégrer une vraie librairie JWT dans Phase 2 token = f"token_{new_user.id}_{new_user.email}" return UserResponse( id=new_user.id, email=new_user.email, name=new_user.name, is_premium=new_user.is_premium, created_at=new_user.created_at.isoformat() if new_user.created_at else "", token=token ) @router.post("/login", response_model=UserResponse) async def login(request: LoginRequest, db: Session = Depends(get_db)): """ Connexion d'un utilisateur. Validation: - Vérification du hash du mot de passe - Génération d'un nouveau token de session """ # Rechercher l'utilisateur user = db.query(User).filter(User.email == request.email).first() if not user or not user.password_hash: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Email ou mot de passe incorrect" ) # Vérifier le mot de passe if not pwd_context.verify(request.password, user.password_hash): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Email ou mot de passe incorrect" ) # Générer un nouveau token token = f"token_{user.id}_{user.email}" return UserResponse( id=user.id, email=user.email, name=user.name, is_premium=user.is_premium, created_at=user.created_at.isoformat() if user.created_at else "", token=token ) @router.post("/logout", status_code=status.HTTP_200_OK) async def logout(db: Session = Depends(get_db)): """ Déconnexion d'un utilisateur. En Phase 1, c'est principalement côté client (suppression du localStorage). En Phase 2, le backend invalidera le token. """ # TODO: Invalider le token dans Redis/Blacklist (Phase 2) return {"message": "Déconnexion réussie"} @router.post("/forgot-password", status_code=status.HTTP_200_OK) async def forgot_password(request: dict, db: Session = Depends(get_db)): """ Réinitialisation du mot de passe (optionnel). Envoie un email avec un lien de réinitialisation. """ email = request.get("email") # Vérifier que l'utilisateur existe user = db.query(User).filter(User.email == email).first() if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Aucun compte trouvé avec cet email" ) # TODO: Envoyer un email avec token de réinitialisation (Phase 2) # Pour l'instant, on simule l'envoi reset_token = f"reset_{user.id}_{user.email[:8]}" return { "message": "Si cet email existe, un lien de réinitialisation sera envoyé", "note": "Fonctionnalité simulée pour Phase 1" } @router.get("/me", response_model=UserResponse) async def get_current_user(db: Session = Depends(get_db)): """ Récupère l'utilisateur connecté. Utilise le token pour identifier l'utilisateur. En Phase 2, utilise une dépendance JWT réelle. """ # TODO: Valider le token JWT (Phase 2) # Pour l'instant, on retourne un utilisateur factice ou utilise l'ID # Simulation : retourner le premier utilisateur si aucun token user = db.query(User).first() if not user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Utilisateur non connecté" ) return UserResponse( id=user.id, email=user.email, name=user.name, is_premium=user.is_premium, created_at=user.created_at.isoformat() if user.created_at else "", token="current_session_token" ) ``` --- ## 📊 Tests Acceptation ### Tests Unitaires Frontend ```typescript // tests/use-auth.test.ts import { renderHook, waitFor } from '@testing-library/react'; import { useAuth } from '@/hooks/useAuth'; describe('useAuth Hook', () => { it('devrait se charger avec null par défaut', () => { const { result } = renderHook(() => useAuth()); expect(result.current.user).toBeNull(); expect(result.current.isAuthenticated).toBe(false); expect(result.current.isLoading).toBe(false); }); it('devrait connecter avec succès', async () => { const { result, waitForNextUpdate } = renderHook(() => useAuth()); global.fetch = jest.fn(() => Promise.resolve({ ok: true, json: async () => ({ user: { id: 1, email: 'test@test.com', is_premium: false }, token: 'token_123' }) }) ); await act(async () => { const success = await result.current.login('test@test.com', 'password123', false); expect(success).toBe(true); }); await waitForNextUpdate(); expect(result.current.user).toEqual({ id: 1, email: 'test@test.com', is_premium: false }); expect(result.current.isAuthenticated).toBe(true); expect(localStorage.getItem('chartbastan_session')).toBeTruthy(); }); it('devrait gérer les erreurs de connexion', async () => { const { result } = renderHook(() => useAuth()); global.fetch = jest.fn(() => Promise.resolve({ ok: false, json: async () => ({ error: { message: 'Email ou mot de passe incorrect' } }) }) ); await act(async () => { const success = await result.current.login('test@test.com', 'wrongpass'); expect(success).toBe(false); }); expect(result.current.error).toBe('Email ou mot de passe incorrect'); expect(result.current.isAuthenticated).toBe(false); }); }); ``` ### Tests d'Intégration Backend ```python # tests/test_auth.py import pytest from fastapi.testclient import TestClient from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from app.database import Base, get_db from app.models.user import User from app.main import app # Setup base de données de test TEST_DATABASE_URL = "sqlite:///./test_chartbastan.db" engine = create_engine(TEST_DATABASE_URL, connect_args={"check_same_thread": False}) TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) def override_get_db(): try: db = TestingSessionLocal() yield db finally: db.close() client = TestClient(app) def test_register_success(): """Test l'inscription réussie.""" response = client.post( "/api/v1/auth/register", json={ "email": "newuser@test.com", "password": "Password123", "name": "Test User" } ) assert response.status_code == 201 data = response.json() assert data["email"] == "newuser@test.com" assert data["name"] == "Test User" assert "password" not in data assert "token" in data def test_register_duplicate_email(): """Test l'inscription avec email déjà existant.""" # Créer un utilisateur client.post( "/api/v1/auth/register", json={ "email": "duplicate@test.com", "password": "Password123" } ) # Essayer de créer le même email response = client.post( "/api/v1/auth/register", json={ "email": "duplicate@test.com", "password": "Password456" } ) assert response.status_code == 400 assert "déjà" in response.json()["detail"].lower() def test_login_success(): """Test la connexion réussie.""" # Créer un utilisateur client.post( "/api/v1/auth/register", json={ "email": "loginuser@test.com", "password": "Password123" } ) # Se connecter response = client.post( "/api/v1/auth/login", json={ "email": "loginuser@test.com", "password": "Password123" } ) assert response.status_code == 200 data = response.json() assert data["email"] == "loginuser@test.com" assert "token" in data def test_login_wrong_password(): """Test la connexion avec mauvais mot de passe.""" # Créer un utilisateur client.post( "/api/v1/auth/register", json={ "email": "wrongpass@test.com", "password": "Password123" } ) # Se connecter avec mauvais mot de passe response = client.post( "/api/v1/auth/login", json={ "email": "wrongpass@test.com", "password": "WrongPassword" } ) assert response.status_code == 401 assert "incorrect" in response.json()["detail"].lower() ``` --- ## 📝 Définitions de Succès - [ ] Un utilisateur peut créer un compte avec email et mot de passe - [ ] Le mot de passe est hashé de manière sécurisée (pbkdf2_sha256) - [ ] Un utilisateur peut se connecter avec ses identifiants - [ ] La session persiste après fermeture du navigateur - [ ] Un utilisateur peut se déconnecter - [ ] Les erreurs de connexion sont clairement affichées - [ ] L'inscription valide l'unicité de l'email --- ## 🔗 Dépendances **Frontend** : - `@/components/ui/card`, `label`, `input`, `button` - `@/hooks/useAuth` (hook personnalisé) **Backend** : - `@/models/user` (modèle utilisateur) - `passlib` (hashing pbkdf2_sha256) - SQLAlchemy ORM **Tests** : - `@testing-library/react` (tests frontend) - `pytest` (tests backend) --- ## 🎯 KPIs de Mesure - **Taux de conversion inscription** : % d'utilisateurs qui s'inscrivent - **Taux de réussite connexion** : % de tentatives de connexion réussies - **Temps de chargement page** : < 2s pour page inscription/connexion - **Temps de réponse API** : < 500ms pour endpoints auth - **Taux d'erreurs 401** : < 5% (trop d'erreurs = problème) --- **Note** : Cette User Story couvre les fonctionnalités critiques de l'Epic 4. Une fois implémentée, les utilisateurs pourront créer des comptes et accéder à toutes les autres fonctionnalités du système.