feat: Connexion utilisateur avec sécurité renforcée
Implémente la Story 1.4 du système d'authentification avec plusieurs couches de protection contre les attaques par force brute. Sécurité backend : - Authentification JWT avec access token (15min) + refresh token (7j) - Rotation automatique des refresh tokens avec détection de replay - Rate limiting progressif par IP (délai Fibonacci après échecs) - Intégration Cloudflare Turnstile CAPTCHA après 5 tentatives - Alerte email à l'utilisateur après blocage temporaire - Isolation multi-tenant (un utilisateur ne peut se connecter que sur son établissement) Frontend : - Page de connexion avec feedback visuel des délais et erreurs - Composant TurnstileCaptcha réutilisable - Gestion d'état auth avec stockage sécurisé des tokens - Tests E2E Playwright pour login, tenant isolation, et activation Infrastructure : - Configuration Symfony Security avec json_login + jwt - Cache pools séparés (filesystem en test, Redis en prod) - NullLoginRateLimiter pour environnement de test (évite blocage CI) - Génération des clés JWT en CI après démarrage du backend
This commit is contained in:
361
frontend/e2e/login.spec.ts
Normal file
361
frontend/e2e/login.spec.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { execSync } from 'child_process';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Test credentials - must match what's created by the command
|
||||
const _TEST_EMAIL = 'e2e-login@example.com'; // Base email pattern used by getTestEmail()
|
||||
const TEST_PASSWORD = 'TestPassword123';
|
||||
const WRONG_PASSWORD = 'WrongPassword123';
|
||||
|
||||
// eslint-disable-next-line no-empty-pattern
|
||||
test.beforeAll(async ({ }, testInfo) => {
|
||||
const browserName = testInfo.project.name;
|
||||
|
||||
// Create a test user for login tests
|
||||
try {
|
||||
const projectRoot = join(__dirname, '../..');
|
||||
const composeFile = join(projectRoot, 'compose.yaml');
|
||||
|
||||
// Create a unique email for this browser project to avoid conflicts
|
||||
const email = `e2e-login-${browserName}@example.com`;
|
||||
|
||||
const result = execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --email=${email} --password=${TEST_PASSWORD} 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
|
||||
|
||||
console.warn(`[${browserName}] Test user created or exists:`, result.includes('already exists') ? 'exists' : 'created');
|
||||
} catch (error) {
|
||||
console.error(`[${browserName}] Failed to create test user:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
function getTestEmail(browserName: string): string {
|
||||
return `e2e-login-${browserName}@example.com`;
|
||||
}
|
||||
|
||||
test.describe('Login Flow', () => {
|
||||
test.describe('Successful Login', () => {
|
||||
test('logs in successfully and redirects to dashboard', async ({ page }, testInfo) => {
|
||||
const email = getTestEmail(testInfo.project.name);
|
||||
|
||||
await page.goto('/login');
|
||||
|
||||
// Verify we're on the login page
|
||||
await expect(page.getByRole('heading', { name: /connexion/i })).toBeVisible();
|
||||
|
||||
// Fill in credentials
|
||||
await page.locator('#email').fill(email);
|
||||
await page.locator('#password').fill(TEST_PASSWORD);
|
||||
|
||||
// Submit button should be enabled
|
||||
const submitButton = page.getByRole('button', { name: /se connecter/i });
|
||||
await expect(submitButton).toBeEnabled();
|
||||
|
||||
// Submit and wait for navigation to dashboard
|
||||
await Promise.all([
|
||||
page.waitForURL('/', { timeout: 10000 }),
|
||||
submitButton.click()
|
||||
]);
|
||||
|
||||
// We should be on the dashboard (root)
|
||||
await expect(page).toHaveURL('/');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Failed Login', () => {
|
||||
test('shows error message for invalid credentials', async ({ page }, testInfo) => {
|
||||
const email = getTestEmail(testInfo.project.name);
|
||||
|
||||
await page.goto('/login');
|
||||
|
||||
// Fill in wrong credentials
|
||||
await page.locator('#email').fill(email);
|
||||
await page.locator('#password').fill(WRONG_PASSWORD);
|
||||
|
||||
// Submit
|
||||
const submitButton = page.getByRole('button', { name: /se connecter/i });
|
||||
await submitButton.click();
|
||||
|
||||
// Wait for error message
|
||||
const errorBanner = page.locator('.error-banner');
|
||||
await expect(errorBanner).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Error should be generic (not reveal if email exists)
|
||||
await expect(errorBanner).toContainText(/email ou .* mot de passe .* incorrect/i);
|
||||
});
|
||||
|
||||
test('shows error for non-existent user', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
// Use a random email that doesn't exist
|
||||
await page.locator('#email').fill(`nonexistent-${Date.now()}@example.com`);
|
||||
await page.locator('#password').fill('SomePassword123');
|
||||
|
||||
// Submit
|
||||
const submitButton = page.getByRole('button', { name: /se connecter/i });
|
||||
await submitButton.click();
|
||||
|
||||
// Wait for error message
|
||||
const errorBanner = page.locator('.error-banner');
|
||||
await expect(errorBanner).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Error should be the same generic message (security: don't reveal if email exists)
|
||||
await expect(errorBanner).toContainText(/email ou .* mot de passe .* incorrect/i);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Form Validation', () => {
|
||||
test('submit button is disabled until both fields are filled', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
const submitButton = page.getByRole('button', { name: /se connecter/i });
|
||||
|
||||
// Initially disabled (no fields filled)
|
||||
await expect(submitButton).toBeDisabled();
|
||||
|
||||
// Fill only email
|
||||
await page.locator('#email').fill('test@example.com');
|
||||
await expect(submitButton).toBeDisabled();
|
||||
|
||||
// Clear email, fill only password
|
||||
await page.locator('#email').fill('');
|
||||
await page.locator('#password').fill('password123');
|
||||
await expect(submitButton).toBeDisabled();
|
||||
|
||||
// Fill both
|
||||
await page.locator('#email').fill('test@example.com');
|
||||
await expect(submitButton).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Rate Limiting - Fibonacci Delay', () => {
|
||||
// Skip rate limiting tests in CI - they require the real rate limiter which is
|
||||
// replaced by NullLoginRateLimiter in test environment to avoid IP blocking
|
||||
test.skip(!!process.env.CI, 'Rate limiting tests require real rate limiter (skipped in CI)');
|
||||
|
||||
test('shows progressive delay after failed attempts', async ({ page }, testInfo) => {
|
||||
const browserName = testInfo.project.name;
|
||||
// Use a unique email to avoid affecting other tests
|
||||
const rateLimitEmail = `rate-limit-${browserName}-${Date.now()}@example.com`;
|
||||
|
||||
await page.goto('/login');
|
||||
|
||||
// First attempt - no delay expected
|
||||
await page.locator('#email').fill(rateLimitEmail);
|
||||
await page.locator('#password').fill('WrongPassword');
|
||||
await page.getByRole('button', { name: /se connecter/i }).click();
|
||||
|
||||
// Wait for error
|
||||
await expect(page.locator('.error-banner')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Second attempt - should have 1 second delay
|
||||
await page.locator('#password').fill('WrongPassword2');
|
||||
await page.getByRole('button', { name: /se connecter/i }).click();
|
||||
|
||||
// After second failed attempt, button should show delay countdown
|
||||
const _submitButton = page.getByRole('button', { name: /patientez|se connecter/i });
|
||||
|
||||
// Wait for response - the button should briefly show "Patientez Xs..."
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Check that error message is displayed
|
||||
await expect(page.locator('.error-banner')).toBeVisible();
|
||||
});
|
||||
|
||||
test('delays increase with Fibonacci sequence', async ({ page }, testInfo) => {
|
||||
const browserName = testInfo.project.name;
|
||||
const rateLimitEmail = `fibo-${browserName}-${Date.now()}@example.com`;
|
||||
|
||||
await page.goto('/login');
|
||||
|
||||
// Make 4 failed attempts to see increasing delays
|
||||
// Fibonacci: attempt 2 = 1s, attempt 3 = 1s, attempt 4 = 2s, attempt 5 = 3s
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await page.locator('#email').fill(rateLimitEmail);
|
||||
await page.locator('#password').fill(`WrongPassword${i}`);
|
||||
|
||||
const submitButton = page.getByRole('button', { name: /se connecter|patientez/i });
|
||||
|
||||
// Wait for button to be enabled if there's a delay
|
||||
await expect(submitButton).toBeEnabled({ timeout: 10000 });
|
||||
|
||||
await submitButton.click();
|
||||
|
||||
// Wait for response
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
// After 4 attempts, should see error
|
||||
await expect(page.locator('.error-banner')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('CAPTCHA after failed attempts', () => {
|
||||
// Skip CAPTCHA tests in CI - they require the real rate limiter which is
|
||||
// replaced by NullLoginRateLimiter in test environment to avoid IP blocking
|
||||
test.skip(!!process.env.CI, 'CAPTCHA tests require real rate limiter (skipped in CI)');
|
||||
|
||||
test('shows CAPTCHA after 5 failed login attempts', async ({ page }, testInfo) => {
|
||||
const browserName = testInfo.project.name;
|
||||
const captchaEmail = `captcha-${browserName}-${Date.now()}@example.com`;
|
||||
|
||||
await page.goto('/login');
|
||||
|
||||
// Make 5 failed attempts to trigger CAPTCHA requirement
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await page.locator('#email').fill(captchaEmail);
|
||||
await page.locator('#password').fill(`WrongPassword${i}`);
|
||||
|
||||
const submitButton = page.getByRole('button', { name: /se connecter|patientez/i });
|
||||
|
||||
// Wait for button to be enabled
|
||||
await expect(submitButton).toBeEnabled({ timeout: 15000 });
|
||||
|
||||
await submitButton.click();
|
||||
|
||||
// Wait for response
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
// After 5 failed attempts, CAPTCHA should appear
|
||||
const captchaSection = page.locator('.captcha-section');
|
||||
await expect(captchaSection).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Should see the security verification label
|
||||
await expect(page.getByText(/vérification de sécurité/i)).toBeVisible();
|
||||
|
||||
// Turnstile container should be present
|
||||
await expect(page.locator('.turnstile-container')).toBeVisible();
|
||||
});
|
||||
|
||||
test('submit button disabled when CAPTCHA required but not completed', async ({ page }, testInfo) => {
|
||||
const browserName = testInfo.project.name;
|
||||
const captchaEmail = `captcha-btn-${browserName}-${Date.now()}@example.com`;
|
||||
|
||||
await page.goto('/login');
|
||||
|
||||
// Make 5 failed attempts
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await page.locator('#email').fill(captchaEmail);
|
||||
await page.locator('#password').fill(`WrongPassword${i}`);
|
||||
|
||||
const submitButton = page.getByRole('button', { name: /se connecter|patientez/i });
|
||||
await expect(submitButton).toBeEnabled({ timeout: 15000 });
|
||||
await submitButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
// Wait for CAPTCHA to appear
|
||||
await expect(page.locator('.captcha-section')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Wait for any delay to expire
|
||||
await expect(page.getByRole('button', { name: /se connecter/i })).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Submit button should be disabled because CAPTCHA is not completed
|
||||
const submitButton = page.getByRole('button', { name: /se connecter/i });
|
||||
await expect(submitButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Success Message After Activation', () => {
|
||||
test('shows success message when redirected after activation', async ({ page }) => {
|
||||
await page.goto('/login?activated=true');
|
||||
|
||||
await expect(page.getByText(/compte a été activé avec succès/i)).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: /connexion/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('does not show success message without query param', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
await expect(page.getByText(/compte a été activé avec succès/i)).not.toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: /connexion/i })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Tenant Isolation', () => {
|
||||
// Use environment variable for port (5174 in dev, 4173 in CI)
|
||||
const PORT = process.env.CI ? '4173' : '5174';
|
||||
const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`;
|
||||
const BETA_URL = `http://ecole-beta.classeo.local:${PORT}`;
|
||||
const ALPHA_EMAIL = 'tenant-test-alpha@example.com';
|
||||
const BETA_EMAIL = 'tenant-test-beta@example.com';
|
||||
const PASSWORD = 'TenantTest123';
|
||||
|
||||
// Create test users on different tenants before running these tests
|
||||
test.beforeAll(async () => {
|
||||
const projectRoot = join(__dirname, '../..');
|
||||
const composeFile = join(projectRoot, 'compose.yaml');
|
||||
|
||||
try {
|
||||
// Create user on ecole-alpha
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ALPHA_EMAIL} --password=${PASSWORD} 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
// Create user on ecole-beta
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-beta --email=${BETA_EMAIL} --password=${PASSWORD} 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Tenant isolation test users created');
|
||||
} catch (error) {
|
||||
console.error('Failed to create tenant test users:', error);
|
||||
}
|
||||
});
|
||||
|
||||
test('user can login on their own tenant', async ({ page }) => {
|
||||
// Alpha user on Alpha tenant - should succeed
|
||||
await page.goto(`${ALPHA_URL}/login`);
|
||||
|
||||
await page.locator('#email').fill(ALPHA_EMAIL);
|
||||
await page.locator('#password').fill(PASSWORD);
|
||||
|
||||
const submitButton = page.getByRole('button', { name: /se connecter/i });
|
||||
await submitButton.click();
|
||||
|
||||
// Should redirect to dashboard (successful login)
|
||||
await expect(page).toHaveURL(`${ALPHA_URL}/`, { timeout: 10000 });
|
||||
});
|
||||
|
||||
test('user cannot login on different tenant', async ({ page }) => {
|
||||
// Alpha user on Beta tenant - should fail
|
||||
await page.goto(`${BETA_URL}/login`);
|
||||
|
||||
await page.locator('#email').fill(ALPHA_EMAIL);
|
||||
await page.locator('#password').fill(PASSWORD);
|
||||
|
||||
const submitButton = page.getByRole('button', { name: /se connecter/i });
|
||||
await submitButton.click();
|
||||
|
||||
// Should show error (user doesn't exist in this tenant)
|
||||
const errorBanner = page.locator('.error-banner');
|
||||
await expect(errorBanner).toBeVisible({ timeout: 5000 });
|
||||
await expect(errorBanner).toContainText(/email ou .* mot de passe .* incorrect/i);
|
||||
|
||||
// Should still be on login page
|
||||
await expect(page).toHaveURL(`${BETA_URL}/login`);
|
||||
});
|
||||
|
||||
test('each tenant has isolated users', async ({ page }) => {
|
||||
// Beta user on Beta tenant - should succeed
|
||||
await page.goto(`${BETA_URL}/login`);
|
||||
|
||||
await page.locator('#email').fill(BETA_EMAIL);
|
||||
await page.locator('#password').fill(PASSWORD);
|
||||
|
||||
const submitButton = page.getByRole('button', { name: /se connecter/i });
|
||||
await submitButton.click();
|
||||
|
||||
// Should redirect to dashboard (successful login)
|
||||
await expect(page).toHaveURL(`${BETA_URL}/`, { timeout: 10000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user