Files
Classeo/backend/src/Administration/Infrastructure/Security/DatabaseUserProvider.php
Mathias STRASSER affad287f9 feat: Réinitialisation de mot de passe avec tokens sécurisés
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
2026-02-02 09:45:15 +01:00

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();
}
}
}