Implémentation complète du flux de réinitialisation de mot de passe (Story 1.5): Backend: - Aggregate PasswordResetToken avec TTL 1h, UUID v7, usage unique - Endpoint POST /api/password/forgot avec rate limiting (3/h par email, 10/h par IP) - Endpoint POST /api/password/reset avec validation token - Templates email (demande + confirmation) - Repository Redis avec TTL 2h pour distinguer expiré/invalide Frontend: - Page /mot-de-passe-oublie avec message générique (anti-énumération) - Page /reset-password/[token] avec validation temps réel des critères - Gestion erreurs: token invalide, expiré, déjà utilisé Tests: - 14 tests unitaires PasswordResetToken - 7 tests unitaires RequestPasswordResetHandler - 7 tests unitaires ResetPasswordHandler - Tests E2E Playwright pour le flux complet
114 lines
3.7 KiB
PHP
114 lines
3.7 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Administration\Infrastructure\Security;
|
|
|
|
use App\Administration\Domain\Exception\EmailInvalideException;
|
|
use App\Administration\Domain\Model\User\Email;
|
|
use App\Administration\Domain\Repository\UserRepository;
|
|
use App\Shared\Infrastructure\Tenant\TenantId;
|
|
use App\Shared\Infrastructure\Tenant\TenantNotFoundException;
|
|
use App\Shared\Infrastructure\Tenant\TenantResolver;
|
|
use InvalidArgumentException;
|
|
use Symfony\Component\HttpFoundation\RequestStack;
|
|
use Symfony\Component\Security\Core\Exception\UserNotFoundException as SymfonyUserNotFoundException;
|
|
use Symfony\Component\Security\Core\User\UserInterface;
|
|
use Symfony\Component\Security\Core\User\UserProviderInterface;
|
|
|
|
/**
|
|
* Loads users from the domain for Symfony authentication.
|
|
*
|
|
* This provider bridges Symfony Security with our Domain Layer.
|
|
* It never reveals whether a user exists or not for security reasons.
|
|
* Users are isolated by tenant (school).
|
|
*
|
|
* @implements UserProviderInterface<SecurityUser>
|
|
*
|
|
* @see Story 1.4 - User login (AC2: no account existence disclosure)
|
|
*/
|
|
final readonly class DatabaseUserProvider implements UserProviderInterface
|
|
{
|
|
public function __construct(
|
|
private UserRepository $userRepository,
|
|
private TenantResolver $tenantResolver,
|
|
private RequestStack $requestStack,
|
|
private SecurityUserFactory $securityUserFactory,
|
|
) {
|
|
}
|
|
|
|
public function loadUserByIdentifier(string $identifier): UserInterface
|
|
{
|
|
$tenantId = $this->getCurrentTenantId();
|
|
|
|
try {
|
|
$email = new Email($identifier);
|
|
} catch (EmailInvalideException) {
|
|
// Malformed email = treat as user not found (security: generic error)
|
|
throw new SymfonyUserNotFoundException();
|
|
}
|
|
|
|
$user = $this->userRepository->findByEmail($email, $tenantId);
|
|
|
|
// Generic message to not reveal account existence
|
|
if ($user === null) {
|
|
throw new SymfonyUserNotFoundException();
|
|
}
|
|
|
|
// Do not allow login if the account is not active
|
|
if (!$user->peutSeConnecter()) {
|
|
throw new SymfonyUserNotFoundException();
|
|
}
|
|
|
|
return $this->securityUserFactory->fromDomainUser($user);
|
|
}
|
|
|
|
public function refreshUser(UserInterface $user): UserInterface
|
|
{
|
|
if (!$user instanceof SecurityUser) {
|
|
throw new InvalidArgumentException('Expected instance of ' . SecurityUser::class);
|
|
}
|
|
|
|
return $this->loadUserByIdentifier($user->email());
|
|
}
|
|
|
|
public function supportsClass(string $class): bool
|
|
{
|
|
return $class === SecurityUser::class;
|
|
}
|
|
|
|
/**
|
|
* Resolves the current tenant from the request host.
|
|
*
|
|
* @throws SymfonyUserNotFoundException if tenant cannot be resolved (security: generic error)
|
|
*/
|
|
private function getCurrentTenantId(): TenantId
|
|
{
|
|
$request = $this->requestStack->getCurrentRequest();
|
|
|
|
if ($request === null) {
|
|
throw new SymfonyUserNotFoundException();
|
|
}
|
|
|
|
$host = $request->getHost();
|
|
|
|
// Dev/test fallback: localhost uses ecole-alpha tenant
|
|
if ($host === 'localhost' || $host === '127.0.0.1') {
|
|
try {
|
|
return $this->tenantResolver->resolve('ecole-alpha.classeo.local')->tenantId;
|
|
} catch (TenantNotFoundException) {
|
|
throw new SymfonyUserNotFoundException();
|
|
}
|
|
}
|
|
|
|
try {
|
|
$tenantConfig = $this->tenantResolver->resolve($host);
|
|
|
|
return $tenantConfig->tenantId;
|
|
} catch (TenantNotFoundException) {
|
|
// Don't reveal tenant doesn't exist - use same error as invalid credentials
|
|
throw new SymfonyUserNotFoundException();
|
|
}
|
|
}
|
|
}
|