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

927 lines
26 KiB
Markdown

# 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 (
<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`)
```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`)
```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<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`
```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.