2026-02-01 09:31:38 +01:00

26 KiB

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)

// 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 (
    <div className="min-h-screen flex items-center justify-center bg-background">
      <Card className="w-full max-w-md p-8">
        <CardHeader>
          <CardTitle>Créer un compte Chartbastan</CardTitle>
          <CardDescription>
            Rejoignez la communauté des prédictions sportives basées sur lnergie collective.
          </CardDescription>
        </CardHeader>
        <CardContent>
          <form onSubmit={handleSubmit} className="space-y-4">
            {/* Email Input */}
            <div className="space-y-2">
              <Label htmlFor="email">Email</Label>
              <Input
                id="email"
                type="email"
                placeholder="votre.email@exemple.com"
                required
              />
              <p className="text-xs text-muted-foreground">
                Utilisé pour la connexion et la récupération de compte
              </p>
            </div>

            {/* Password Input */}
            <div className="space-y-2">
              <Label htmlFor="password">Mot de passe</Label>
              <Input
                id="password"
                type="password"
                placeholder="•••••••••"
                required
              />
              <p className="text-xs text-muted-foreground">
                Minimum 8 caractères : 1 majuscule, 1 minuscule, 1 chiffre
              </p>
            </div>

            {/* Confirm Password Input */}
            <div className="space-y-2">
              <Label htmlFor="confirmPassword">Confirmer le mot de passe</Label>
              <Input
                id="confirmPassword"
                type="password"
                placeholder="••••••••••"
                required
              />
            </div>

            {/* Name Input (Optional) */}
            <div className="space-y-2">
              <Label htmlFor="name">Nom (optionnel)</Label>
              <Input
                id="name"
                type="text"
                placeholder="Votre prénom"
              />
            </div>

            {/* Referral Code Input (Optional) */}
            <div className="space-y-2">
              <Label htmlFor="referralCode">Code de parrainage (optionnel)</Label>
              <Input
                id="referralCode"
                type="text"
                placeholder="EX: ABC12345"
              />
              <p className="text-xs text-muted-foreground">
                Invitez 3 amis pour obtenir 1 mois premium GRATUIT
              </p>
            </div>

            {/* Submit Button */}
            <Button type="submit" className="w-full">
              Créer mon compte
            </Button>

            {/* Login Link */}
            <div className="text-center mt-4">
              <p className="text-sm text-muted-foreground">
                Déjà inscrit ?{" "}
                <Link href="/login" className="text-primary hover:underline font-medium">
                  Connectez-vous
                </Link>
              </p>
            </div>
          </form>
        </CardContent>
      </Card>
    </div>
  );
}

Page de Connexion (src/app/(auth)/login/page.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 (
    <div className="min-h-screen flex items-center justify-center bg-background">
      <Card className="w-full max-w-md p-8">
        <CardHeader>
          <CardTitle>Connexion</CardTitle>
          <CardDescription>
            Accédez à votre dashboard et consultez les prédictions de matchs.
          </CardDescription>
        </CardHeader>
        <CardContent>
          <form onSubmit={handleSubmit} className="space-y-4">
            {/* Email Input */}
            <div className="space-y-2">
              <Label htmlFor="email">Email</Label>
              <Input
                id="email"
                type="email"
                placeholder="votre.email@exemple.com"
                required
                autoFocus
              />
            </div>

            {/* Password Input */}
            <div className="space-y-2">
              <Label htmlFor="password">Mot de passe</Label>
              <Input
                id="password"
                type="password"
                placeholder="••••••••••"
                required
              />
            </div>

            {/* Remember Me Checkbox */}
            <div className="flex items-center space-y-2">
              <input
                id="rememberMe"
                type="checkbox"
                className="h-4 w-4 rounded border-gray-300"
              />
              <Label htmlFor="rememberMe" className="text-sm">
                Se souvenir de moi
              </Label>
            </div>

            {/* Forgot Password Link */}
            <div className="text-center">
              <Link href="/forgot-password" className="text-sm text-primary hover:underline">
                Mot de passe oublié ?
              </Link>
            </div>

            {/* Submit Button */}
            <Button type="submit" className="w-full">
              Se connecter
            </Button>

            {/* Register Link */}
            <div className="text-center mt-4">
              <p className="text-sm text-muted-foreground">
                Pas encore de compte ?{" "}
                <Link href="/register" className="text-primary hover:underline font-medium">
                  Créer un compte
                </Link>
              </p>
            </div>
          </form>
        </CardContent>
      </Card>
    </div>
  );
}

Session Management Hook (src/hooks/useAuth.ts)

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<AuthState>({
    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

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

// 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

# 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.