feat: Activation de compte utilisateur avec validation token
L'inscription Classeo se fait via invitation : un admin crée un compte, l'utilisateur reçoit un lien d'activation par email pour définir son mot de passe. Ce flow sécurisé évite les inscriptions non autorisées et garantit que seuls les utilisateurs légitimes accèdent au système. Points clés de l'implémentation : - Tokens d'activation à usage unique stockés en cache (Redis/filesystem) - Validation du consentement parental pour les mineurs < 15 ans (RGPD) - L'échec d'activation ne consume pas le token (retry possible) - Users dans un cache séparé sans TTL (pas d'expiration) - Hot reload en dev (FrankenPHP sans mode worker) Story: 1.3 - Inscription et activation de compte
This commit is contained in:
@@ -63,3 +63,12 @@ DEFAULT_URI=http://localhost
|
||||
# Base domain for tenant resolution (e.g., classeo.fr, classeo.local)
|
||||
TENANT_BASE_DOMAIN=classeo.local
|
||||
###< multi-tenant ###
|
||||
|
||||
###> app ###
|
||||
# Frontend URL for emails and links
|
||||
APP_URL=http://localhost:5173
|
||||
###< app ###
|
||||
|
||||
###> nelmio/cors-bundle ###
|
||||
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
|
||||
###< nelmio/cors-bundle ###
|
||||
|
||||
@@ -74,7 +74,10 @@ RUN echo "xdebug.mode=develop,debug,coverage" >> "$PHP_INI_DIR/conf.d/xdebug.ini
|
||||
|
||||
# Caddy config for FrankenPHP
|
||||
ENV SERVER_NAME=:8000
|
||||
ENV FRANKENPHP_CONFIG="worker ./public/index.php"
|
||||
# In dev mode, we do NOT use worker mode to enable automatic file reloading
|
||||
# Each request loads PHP fresh, so code changes are picked up immediately
|
||||
# Worker mode is only used in production for performance
|
||||
ENV FRANKENPHP_CONFIG=""
|
||||
|
||||
# Entrypoint: detect host UID/GID and run as matching user
|
||||
# Uses gosu with UID:GID directly (no need to create user in Dockerfile)
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"doctrine/doctrine-migrations-bundle": "^3.4",
|
||||
"doctrine/orm": "^3.3",
|
||||
"lexik/jwt-authentication-bundle": "^3.2",
|
||||
"nelmio/cors-bundle": "^2.6",
|
||||
"ramsey/uuid": "^4.7",
|
||||
"symfony/amqp-messenger": "^8.0",
|
||||
"symfony/asset": "^8.0",
|
||||
@@ -24,6 +25,7 @@
|
||||
"symfony/dotenv": "^8.0",
|
||||
"symfony/flex": "^2",
|
||||
"symfony/framework-bundle": "^8.0",
|
||||
"symfony/mailer": "8.0.*",
|
||||
"symfony/messenger": "^8.0",
|
||||
"symfony/monolog-bundle": "^4.0",
|
||||
"symfony/property-access": "^8.0",
|
||||
|
||||
387
backend/composer.lock
generated
387
backend/composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "5db4139b65c041189bc59e0582d6f82d",
|
||||
"content-hash": "e5abd2128a53127e2298b296ed587025",
|
||||
"packages": [
|
||||
{
|
||||
"name": "api-platform/core",
|
||||
@@ -1390,6 +1390,73 @@
|
||||
},
|
||||
"time": "2025-10-26T09:35:14+00:00"
|
||||
},
|
||||
{
|
||||
"name": "egulias/email-validator",
|
||||
"version": "4.0.4",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/egulias/EmailValidator.git",
|
||||
"reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/egulias/EmailValidator/zipball/d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa",
|
||||
"reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"doctrine/lexer": "^2.0 || ^3.0",
|
||||
"php": ">=8.1",
|
||||
"symfony/polyfill-intl-idn": "^1.26"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^10.2",
|
||||
"vimeo/psalm": "^5.12"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-intl": "PHP Internationalization Libraries are required to use the SpoofChecking validation"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "4.0.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Egulias\\EmailValidator\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Eduardo Gulias Davis"
|
||||
}
|
||||
],
|
||||
"description": "A library for validating emails against several RFCs",
|
||||
"homepage": "https://github.com/egulias/EmailValidator",
|
||||
"keywords": [
|
||||
"email",
|
||||
"emailvalidation",
|
||||
"emailvalidator",
|
||||
"validation",
|
||||
"validator"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/egulias/EmailValidator/issues",
|
||||
"source": "https://github.com/egulias/EmailValidator/tree/4.0.4"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/egulias",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2025-03-06T22:45:56+00:00"
|
||||
},
|
||||
{
|
||||
"name": "lcobucci/jwt",
|
||||
"version": "5.6.0",
|
||||
@@ -1682,6 +1749,71 @@
|
||||
],
|
||||
"time": "2026-01-02T08:56:05+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nelmio/cors-bundle",
|
||||
"version": "2.6.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/nelmio/NelmioCorsBundle.git",
|
||||
"reference": "3d80dbcd5d1eb5f8b20ed5199e1778d44c2e4d1c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/nelmio/NelmioCorsBundle/zipball/3d80dbcd5d1eb5f8b20ed5199e1778d44c2e4d1c",
|
||||
"reference": "3d80dbcd5d1eb5f8b20ed5199e1778d44c2e4d1c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"psr/log": "^1.0 || ^2.0 || ^3.0",
|
||||
"symfony/framework-bundle": "^5.4 || ^6.0 || ^7.0 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpstan/phpstan": "^1.11.5",
|
||||
"phpstan/phpstan-deprecation-rules": "^1.2.0",
|
||||
"phpstan/phpstan-phpunit": "^1.4",
|
||||
"phpstan/phpstan-symfony": "^1.4.4",
|
||||
"phpunit/phpunit": "^8"
|
||||
},
|
||||
"type": "symfony-bundle",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "2.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Nelmio\\CorsBundle\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nelmio",
|
||||
"homepage": "http://nelm.io"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://github.com/nelmio/NelmioCorsBundle/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Adds CORS (Cross-Origin Resource Sharing) headers support in your Symfony application",
|
||||
"keywords": [
|
||||
"api",
|
||||
"cors",
|
||||
"crossdomain"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/nelmio/NelmioCorsBundle/issues",
|
||||
"source": "https://github.com/nelmio/NelmioCorsBundle/tree/2.6.1"
|
||||
},
|
||||
"time": "2026-01-12T15:59:08+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/cache",
|
||||
"version": "3.0.0",
|
||||
@@ -3883,6 +4015,86 @@
|
||||
],
|
||||
"time": "2026-01-28T10:46:31+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/mailer",
|
||||
"version": "v8.0.4",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/mailer.git",
|
||||
"reference": "a074d353f5b5a81d356652e8a2034fdd0501420b"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/mailer/zipball/a074d353f5b5a81d356652e8a2034fdd0501420b",
|
||||
"reference": "a074d353f5b5a81d356652e8a2034fdd0501420b",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"egulias/email-validator": "^2.1.10|^3|^4",
|
||||
"php": ">=8.4",
|
||||
"psr/event-dispatcher": "^1",
|
||||
"psr/log": "^1|^2|^3",
|
||||
"symfony/event-dispatcher": "^7.4|^8.0",
|
||||
"symfony/mime": "^7.4|^8.0",
|
||||
"symfony/service-contracts": "^2.5|^3"
|
||||
},
|
||||
"conflict": {
|
||||
"symfony/http-client-contracts": "<2.5"
|
||||
},
|
||||
"require-dev": {
|
||||
"symfony/console": "^7.4|^8.0",
|
||||
"symfony/http-client": "^7.4|^8.0",
|
||||
"symfony/messenger": "^7.4|^8.0",
|
||||
"symfony/twig-bridge": "^7.4|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\Mailer\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Helps sending emails",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/mailer/tree/v8.0.4"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-01-08T08:40:07+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/messenger",
|
||||
"version": "v8.0.4",
|
||||
@@ -3973,6 +4185,92 @@
|
||||
],
|
||||
"time": "2026-01-08T22:36:47+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/mime",
|
||||
"version": "v8.0.5",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/mime.git",
|
||||
"reference": "543d01b6ee4b8eb80ce9349186ad530eb8704252"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/mime/zipball/543d01b6ee4b8eb80ce9349186ad530eb8704252",
|
||||
"reference": "543d01b6ee4b8eb80ce9349186ad530eb8704252",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.4",
|
||||
"symfony/polyfill-intl-idn": "^1.10",
|
||||
"symfony/polyfill-mbstring": "^1.0"
|
||||
},
|
||||
"conflict": {
|
||||
"egulias/email-validator": "~3.0.0",
|
||||
"phpdocumentor/reflection-docblock": "<5.2|>=6",
|
||||
"phpdocumentor/type-resolver": "<1.5.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"egulias/email-validator": "^2.1.10|^3.1|^4",
|
||||
"league/html-to-markdown": "^5.0",
|
||||
"phpdocumentor/reflection-docblock": "^5.2",
|
||||
"symfony/dependency-injection": "^7.4|^8.0",
|
||||
"symfony/process": "^7.4|^8.0",
|
||||
"symfony/property-access": "^7.4|^8.0",
|
||||
"symfony/property-info": "^7.4|^8.0",
|
||||
"symfony/serializer": "^7.4|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\Mime\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Allows manipulating MIME messages",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"mime",
|
||||
"mime-type"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/mime/tree/v8.0.5"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-01-27T09:06:10+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/monolog-bridge",
|
||||
"version": "v8.0.4",
|
||||
@@ -4284,6 +4582,93 @@
|
||||
],
|
||||
"time": "2025-06-27T09:58:17+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-intl-idn",
|
||||
"version": "v1.33.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-intl-idn.git",
|
||||
"reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3",
|
||||
"reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.2",
|
||||
"symfony/polyfill-intl-normalizer": "^1.10"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-intl": "For best performance"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"thanks": {
|
||||
"url": "https://github.com/symfony/polyfill",
|
||||
"name": "symfony/polyfill"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"bootstrap.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Symfony\\Polyfill\\Intl\\Idn\\": ""
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Laurent Bassin",
|
||||
"email": "laurent@bassin.info"
|
||||
},
|
||||
{
|
||||
"name": "Trevor Rowbotham",
|
||||
"email": "trevor.rowbotham@pm.me"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"compatibility",
|
||||
"idn",
|
||||
"intl",
|
||||
"polyfill",
|
||||
"portable",
|
||||
"shim"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2024-09-10T14:38:51+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-intl-normalizer",
|
||||
"version": "v1.33.0",
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
|
||||
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
|
||||
@@ -15,4 +13,5 @@ return [
|
||||
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
|
||||
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
|
||||
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
|
||||
Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true],
|
||||
];
|
||||
|
||||
@@ -3,6 +3,17 @@ framework:
|
||||
# Unique name of your app: used to compute stable namespaces for cache keys.
|
||||
prefix_seed: classeo/backend
|
||||
|
||||
pools:
|
||||
# Pool dédié aux tokens d'activation (7 jours TTL)
|
||||
activation_tokens.cache:
|
||||
adapter: cache.adapter.filesystem
|
||||
default_lifetime: 604800 # 7 jours
|
||||
|
||||
# Pool dédié aux utilisateurs (pas de TTL - données persistantes)
|
||||
users.cache:
|
||||
adapter: cache.adapter.filesystem
|
||||
default_lifetime: 0 # Pas d'expiration
|
||||
|
||||
when@prod:
|
||||
framework:
|
||||
cache:
|
||||
@@ -11,3 +22,11 @@ when@prod:
|
||||
adapter: cache.adapter.system
|
||||
doctrine.result_cache_pool:
|
||||
adapter: cache.adapter.system
|
||||
activation_tokens.cache:
|
||||
adapter: cache.adapter.redis
|
||||
provider: '%env(REDIS_URL)%'
|
||||
default_lifetime: 604800 # 7 jours
|
||||
users.cache:
|
||||
adapter: cache.adapter.redis
|
||||
provider: '%env(REDIS_URL)%'
|
||||
default_lifetime: 0 # Pas d'expiration
|
||||
|
||||
3
backend/config/packages/mailer.yaml
Normal file
3
backend/config/packages/mailer.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
framework:
|
||||
mailer:
|
||||
dsn: '%env(MAILER_DSN)%'
|
||||
10
backend/config/packages/nelmio_cors.yaml
Normal file
10
backend/config/packages/nelmio_cors.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
nelmio_cors:
|
||||
defaults:
|
||||
origin_regex: true
|
||||
allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
|
||||
allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
|
||||
allow_headers: ['Content-Type', 'Authorization']
|
||||
expose_headers: ['Link']
|
||||
max_age: 3600
|
||||
paths:
|
||||
'^/': null
|
||||
@@ -2,6 +2,9 @@ security:
|
||||
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
|
||||
password_hashers:
|
||||
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
|
||||
# Named hasher for domain services (decoupled from User entity)
|
||||
common:
|
||||
algorithm: auto
|
||||
|
||||
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
|
||||
providers:
|
||||
@@ -16,6 +19,10 @@ security:
|
||||
dev:
|
||||
pattern: ^/(_(profiler|wdt)|css|images|js)/
|
||||
security: false
|
||||
api_public:
|
||||
pattern: ^/api/(activation-tokens|activate|login|docs)(/|$)
|
||||
stateless: true
|
||||
security: false
|
||||
api:
|
||||
pattern: ^/api
|
||||
stateless: true
|
||||
@@ -29,6 +36,8 @@ security:
|
||||
access_control:
|
||||
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/login, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/activation-tokens, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/activate, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
|
||||
|
||||
when@test:
|
||||
|
||||
@@ -5,12 +5,21 @@
|
||||
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
|
||||
parameters:
|
||||
tenant.base_domain: '%env(TENANT_BASE_DOMAIN)%'
|
||||
app.url: '%env(APP_URL)%'
|
||||
|
||||
services:
|
||||
# default configuration for services in this file
|
||||
_defaults:
|
||||
autowire: true # Automatically injects dependencies in your services.
|
||||
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
|
||||
bind:
|
||||
# Bind activation tokens cache pool (7-day TTL)
|
||||
Psr\Cache\CacheItemPoolInterface $activationTokensCache: '@activation_tokens.cache'
|
||||
# Bind users cache pool (no TTL - persistent data)
|
||||
Psr\Cache\CacheItemPoolInterface $usersCache: '@users.cache'
|
||||
# Bind named message buses
|
||||
Symfony\Component\Messenger\MessageBusInterface $eventBus: '@event.bus'
|
||||
Symfony\Component\Messenger\MessageBusInterface $commandBus: '@command.bus'
|
||||
|
||||
# makes classes in src/ available to be used as services
|
||||
# this creates a service per class whose id is the fully-qualified class name
|
||||
@@ -43,3 +52,27 @@ services:
|
||||
App\Shared\Infrastructure\Tenant\Command\TenantMigrateCommand:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
|
||||
# Administration services
|
||||
# Bind Repository interfaces to their implementations
|
||||
App\Administration\Domain\Repository\ActivationTokenRepository:
|
||||
alias: App\Administration\Infrastructure\Persistence\Redis\RedisActivationTokenRepository
|
||||
|
||||
App\Administration\Domain\Repository\UserRepository:
|
||||
alias: App\Administration\Infrastructure\Persistence\Cache\CacheUserRepository
|
||||
|
||||
App\Administration\Application\Port\PasswordHasher:
|
||||
alias: App\Administration\Infrastructure\Security\SymfonyPasswordHasher
|
||||
|
||||
# Clock interface binding
|
||||
App\Shared\Domain\Clock:
|
||||
alias: App\Shared\Infrastructure\Clock\SystemClock
|
||||
|
||||
# Domain policies (need explicit registration as Domain is excluded from autowiring)
|
||||
App\Administration\Domain\Policy\ConsentementParentalPolicy:
|
||||
autowire: true
|
||||
|
||||
# Email handlers
|
||||
App\Administration\Infrastructure\Messaging\SendActivationConfirmationHandler:
|
||||
arguments:
|
||||
$appUrl: '%app.url%'
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\ActivateAccount;
|
||||
|
||||
/**
|
||||
* Command to activate a user account using an activation token.
|
||||
*
|
||||
* This command is dispatched when a user clicks their activation link
|
||||
* and submits a valid password.
|
||||
*/
|
||||
final readonly class ActivateAccountCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $tokenValue,
|
||||
public string $password,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\ActivateAccount;
|
||||
|
||||
use App\Administration\Application\Port\PasswordHasher;
|
||||
use App\Administration\Domain\Exception\ActivationTokenNotFoundException;
|
||||
use App\Administration\Domain\Repository\ActivationTokenRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class ActivateAccountHandler
|
||||
{
|
||||
public function __construct(
|
||||
private ActivationTokenRepository $tokenRepository,
|
||||
private PasswordHasher $passwordHasher,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ActivationTokenNotFoundException if token does not exist
|
||||
* @throws \App\Administration\Domain\Exception\ActivationTokenExpiredException if token is expired
|
||||
* @throws \App\Administration\Domain\Exception\ActivationTokenAlreadyUsedException if token was already used
|
||||
*/
|
||||
public function __invoke(ActivateAccountCommand $command): ActivateAccountResult
|
||||
{
|
||||
$token = $this->tokenRepository->findByTokenValue($command->tokenValue);
|
||||
|
||||
if ($token === null) {
|
||||
throw ActivationTokenNotFoundException::withTokenValue($command->tokenValue);
|
||||
}
|
||||
|
||||
$now = $this->clock->now();
|
||||
|
||||
// Validate token can be used (throws if expired or already used)
|
||||
// Note: Token is NOT marked as used here - that's deferred to the processor
|
||||
// after successful user activation, so failed activations don't burn the token
|
||||
$token->validateForUse($now);
|
||||
|
||||
// Hash the password for User model
|
||||
$hashedPassword = $this->passwordHasher->hash($command->password);
|
||||
|
||||
return new ActivateAccountResult(
|
||||
userId: $token->userId,
|
||||
email: $token->email,
|
||||
tenantId: $token->tenantId,
|
||||
role: $token->role,
|
||||
hashedPassword: $hashedPassword,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\ActivateAccount;
|
||||
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
|
||||
/**
|
||||
* Result of the ActivateAccountCommand execution.
|
||||
*
|
||||
* Contains the information needed to complete the activation process,
|
||||
* including the hashed password to be stored on the User aggregate.
|
||||
*/
|
||||
final readonly class ActivateAccountResult
|
||||
{
|
||||
public function __construct(
|
||||
public string $userId,
|
||||
public string $email,
|
||||
public TenantId $tenantId,
|
||||
public string $role,
|
||||
public string $hashedPassword,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Port;
|
||||
|
||||
/**
|
||||
* Port interface for password hashing operations.
|
||||
*
|
||||
* This abstracts the password hashing mechanism, allowing the Application
|
||||
* layer to remain independent of the specific hashing implementation
|
||||
* (e.g., Symfony PasswordHasher with Argon2id).
|
||||
*/
|
||||
interface PasswordHasher
|
||||
{
|
||||
/**
|
||||
* Hash a plain text password.
|
||||
*/
|
||||
public function hash(string $plainPassword): string;
|
||||
|
||||
/**
|
||||
* Verify a plain password against a hash.
|
||||
*/
|
||||
public function verify(string $hashedPassword, string $plainPassword): bool;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Event;
|
||||
|
||||
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
final readonly class ActivationTokenGenerated implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public ActivationTokenId $tokenId,
|
||||
public string $userId,
|
||||
public string $email,
|
||||
public TenantId $tenantId,
|
||||
private DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function occurredOn(): DateTimeImmutable
|
||||
{
|
||||
return $this->occurredOn;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
return $this->tokenId->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Event;
|
||||
|
||||
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
final readonly class ActivationTokenUsed implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public ActivationTokenId $tokenId,
|
||||
public string $userId,
|
||||
private DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function occurredOn(): DateTimeImmutable
|
||||
{
|
||||
return $this->occurredOn;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
return $this->tokenId->value;
|
||||
}
|
||||
}
|
||||
42
backend/src/Administration/Domain/Event/CompteActive.php
Normal file
42
backend/src/Administration/Domain/Event/CompteActive.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Event;
|
||||
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
/**
|
||||
* Event emitted when a user account is activated.
|
||||
*
|
||||
* This event triggers the sending of a confirmation email
|
||||
* and any other side effects related to account activation.
|
||||
*/
|
||||
final readonly class CompteActive implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public string $userId,
|
||||
public string $email,
|
||||
public TenantId $tenantId,
|
||||
public string $role,
|
||||
private DateTimeImmutable $occurredOn,
|
||||
private UuidInterface $aggregateId,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function occurredOn(): DateTimeImmutable
|
||||
{
|
||||
return $this->occurredOn;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
return $this->aggregateId;
|
||||
}
|
||||
}
|
||||
39
backend/src/Administration/Domain/Event/CompteCreated.php
Normal file
39
backend/src/Administration/Domain/Event/CompteCreated.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Event;
|
||||
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
/**
|
||||
* Event émis lors de la création d'un nouveau compte utilisateur.
|
||||
*/
|
||||
final readonly class CompteCreated implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public UserId $userId,
|
||||
public string $email,
|
||||
public string $role,
|
||||
public TenantId $tenantId,
|
||||
private DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function occurredOn(): DateTimeImmutable
|
||||
{
|
||||
return $this->occurredOn;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
return $this->userId->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
|
||||
use RuntimeException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class ActivationTokenAlreadyUsedException extends RuntimeException
|
||||
{
|
||||
public static function forToken(ActivationTokenId $tokenId): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Activation token "%s" has already been used.',
|
||||
$tokenId,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
|
||||
use RuntimeException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class ActivationTokenExpiredException extends RuntimeException
|
||||
{
|
||||
public static function forToken(ActivationTokenId $tokenId): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Activation token "%s" has expired.',
|
||||
$tokenId,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
|
||||
use RuntimeException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class ActivationTokenNotFoundException extends RuntimeException
|
||||
{
|
||||
public static function withId(ActivationTokenId $tokenId): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Activation token with ID "%s" not found.',
|
||||
$tokenId,
|
||||
));
|
||||
}
|
||||
|
||||
public static function withTokenValue(string $tokenValue): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Activation token with value "%s" not found.',
|
||||
$tokenValue,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use App\Administration\Domain\Model\User\StatutCompte;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use RuntimeException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class CompteNonActivableException extends RuntimeException
|
||||
{
|
||||
public static function carStatutIncompatible(UserId $userId, StatutCompte $statut): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Le compte "%s" ne peut pas être activé car son statut est "%s".',
|
||||
$userId,
|
||||
$statut->value,
|
||||
));
|
||||
}
|
||||
|
||||
public static function carConsentementManquant(UserId $userId): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Le compte "%s" ne peut pas être activé : consentement parental manquant.',
|
||||
$userId,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class EmailInvalideException extends RuntimeException
|
||||
{
|
||||
public static function pourAdresse(string $email): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'L\'adresse email "%s" n\'est pas valide.',
|
||||
$email,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use App\Administration\Domain\Model\User\Email;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use RuntimeException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class UserNotFoundException extends RuntimeException
|
||||
{
|
||||
public static function withId(UserId $userId): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'User with ID "%s" not found.',
|
||||
$userId,
|
||||
));
|
||||
}
|
||||
|
||||
public static function withEmail(Email $email): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'User with email "%s" not found.',
|
||||
$email,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\ActivationToken;
|
||||
|
||||
use App\Administration\Domain\Event\ActivationTokenGenerated;
|
||||
use App\Administration\Domain\Event\ActivationTokenUsed;
|
||||
use App\Administration\Domain\Exception\ActivationTokenAlreadyUsedException;
|
||||
use App\Administration\Domain\Exception\ActivationTokenExpiredException;
|
||||
use App\Shared\Domain\AggregateRoot;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class ActivationToken extends AggregateRoot
|
||||
{
|
||||
private const int EXPIRATION_DAYS = 7;
|
||||
|
||||
public private(set) ?DateTimeImmutable $usedAt = null;
|
||||
|
||||
private function __construct(
|
||||
public private(set) ActivationTokenId $id,
|
||||
public private(set) string $tokenValue,
|
||||
public private(set) string $userId,
|
||||
public private(set) string $email,
|
||||
public private(set) TenantId $tenantId,
|
||||
public private(set) string $role,
|
||||
public private(set) string $schoolName,
|
||||
public private(set) DateTimeImmutable $createdAt,
|
||||
public private(set) DateTimeImmutable $expiresAt,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function generate(
|
||||
string $userId,
|
||||
string $email,
|
||||
TenantId $tenantId,
|
||||
string $role,
|
||||
string $schoolName,
|
||||
DateTimeImmutable $createdAt,
|
||||
): self {
|
||||
$token = new self(
|
||||
id: ActivationTokenId::generate(),
|
||||
tokenValue: Uuid::uuid4()->toString(),
|
||||
userId: $userId,
|
||||
email: $email,
|
||||
tenantId: $tenantId,
|
||||
role: $role,
|
||||
schoolName: $schoolName,
|
||||
createdAt: $createdAt,
|
||||
expiresAt: $createdAt->modify(sprintf('+%d days', self::EXPIRATION_DAYS)),
|
||||
);
|
||||
|
||||
$token->recordEvent(new ActivationTokenGenerated(
|
||||
tokenId: $token->id,
|
||||
userId: $userId,
|
||||
email: $email,
|
||||
tenantId: $tenantId,
|
||||
occurredOn: $createdAt,
|
||||
));
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstitute an ActivationToken from storage.
|
||||
* Does NOT record domain events (this is not a new creation).
|
||||
*
|
||||
* @internal For use by Infrastructure layer only
|
||||
*/
|
||||
public static function reconstitute(
|
||||
ActivationTokenId $id,
|
||||
string $tokenValue,
|
||||
string $userId,
|
||||
string $email,
|
||||
TenantId $tenantId,
|
||||
string $role,
|
||||
string $schoolName,
|
||||
DateTimeImmutable $createdAt,
|
||||
DateTimeImmutable $expiresAt,
|
||||
?DateTimeImmutable $usedAt,
|
||||
): self {
|
||||
$token = new self(
|
||||
id: $id,
|
||||
tokenValue: $tokenValue,
|
||||
userId: $userId,
|
||||
email: $email,
|
||||
tenantId: $tenantId,
|
||||
role: $role,
|
||||
schoolName: $schoolName,
|
||||
createdAt: $createdAt,
|
||||
expiresAt: $expiresAt,
|
||||
);
|
||||
|
||||
$token->usedAt = $usedAt;
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
public function isExpired(DateTimeImmutable $at): bool
|
||||
{
|
||||
return $at >= $this->expiresAt;
|
||||
}
|
||||
|
||||
public function isUsed(): bool
|
||||
{
|
||||
return $this->usedAt !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the token can be used (not expired, not already used).
|
||||
* Does NOT mark the token as used - use use() for that after successful activation.
|
||||
*
|
||||
* @throws ActivationTokenAlreadyUsedException if token was already used
|
||||
* @throws ActivationTokenExpiredException if token is expired
|
||||
*/
|
||||
public function validateForUse(DateTimeImmutable $at): void
|
||||
{
|
||||
if ($this->isUsed()) {
|
||||
throw ActivationTokenAlreadyUsedException::forToken($this->id);
|
||||
}
|
||||
|
||||
if ($this->isExpired($at)) {
|
||||
throw ActivationTokenExpiredException::forToken($this->id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the token as used. Should only be called after successful user activation.
|
||||
*
|
||||
* @throws ActivationTokenAlreadyUsedException if token was already used
|
||||
* @throws ActivationTokenExpiredException if token is expired
|
||||
*/
|
||||
public function use(DateTimeImmutable $at): void
|
||||
{
|
||||
$this->validateForUse($at);
|
||||
|
||||
$this->usedAt = $at;
|
||||
|
||||
$this->recordEvent(new ActivationTokenUsed(
|
||||
tokenId: $this->id,
|
||||
userId: $this->userId,
|
||||
occurredOn: $at,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\ActivationToken;
|
||||
|
||||
use App\Shared\Domain\EntityId;
|
||||
|
||||
final readonly class ActivationTokenId extends EntityId
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\ConsentementParental;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Value Object représentant le consentement parental.
|
||||
*
|
||||
* Requis pour les utilisateurs mineurs (< 15 ans) conformément au RGPD (NFR-C1).
|
||||
* Le consentement doit être donné par un parent avant que l'élève puisse activer son compte.
|
||||
*/
|
||||
final readonly class ConsentementParental
|
||||
{
|
||||
public function __construct(
|
||||
public string $parentId,
|
||||
public string $eleveId,
|
||||
public DateTimeImmutable $dateConsentement,
|
||||
public string $ipAddress,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un nouveau consentement parental horodaté.
|
||||
*/
|
||||
public static function accorder(
|
||||
string $parentId,
|
||||
string $eleveId,
|
||||
DateTimeImmutable $at,
|
||||
string $ipAddress,
|
||||
): self {
|
||||
return new self(
|
||||
parentId: $parentId,
|
||||
eleveId: $eleveId,
|
||||
dateConsentement: $at,
|
||||
ipAddress: $ipAddress,
|
||||
);
|
||||
}
|
||||
|
||||
public function estPourEleve(string $eleveId): bool
|
||||
{
|
||||
return $this->eleveId === $eleveId;
|
||||
}
|
||||
}
|
||||
39
backend/src/Administration/Domain/Model/User/Email.php
Normal file
39
backend/src/Administration/Domain/Model/User/Email.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\User;
|
||||
|
||||
use App\Administration\Domain\Exception\EmailInvalideException;
|
||||
|
||||
use const FILTER_VALIDATE_EMAIL;
|
||||
|
||||
/**
|
||||
* Value Object représentant une adresse email valide.
|
||||
*
|
||||
* Note: Les property hooks PHP 8.5 ne sont pas compatibles avec readonly.
|
||||
* La validation reste dans le constructeur pour préserver l'immutabilité du Value Object.
|
||||
*/
|
||||
final readonly class Email
|
||||
{
|
||||
public string $value;
|
||||
|
||||
public function __construct(string $value)
|
||||
{
|
||||
if (filter_var($value, FILTER_VALIDATE_EMAIL) === false) {
|
||||
throw EmailInvalideException::pourAdresse($value);
|
||||
}
|
||||
|
||||
$this->value = $value;
|
||||
}
|
||||
|
||||
public function equals(self $other): bool
|
||||
{
|
||||
return strtolower($this->value) === strtolower($other->value);
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
}
|
||||
74
backend/src/Administration/Domain/Model/User/Role.php
Normal file
74
backend/src/Administration/Domain/Model/User/Role.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\User;
|
||||
|
||||
use function in_array;
|
||||
|
||||
/**
|
||||
* Enum représentant les rôles utilisateur dans Classeo.
|
||||
*
|
||||
* Hiérarchie RBAC :
|
||||
* - ROLE_SUPER_ADMIN → Accès à tous les établissements
|
||||
* - ROLE_ADMIN → Direction d'un établissement
|
||||
* - ROLE_PROF → Enseignant
|
||||
* - ROLE_VIE_SCOLAIRE → Personnel vie scolaire
|
||||
* - ROLE_SECRETARIAT → Personnel administratif
|
||||
* - ROLE_PARENT → Parent d'élève
|
||||
* - ROLE_ELEVE → Élève
|
||||
*/
|
||||
enum Role: string
|
||||
{
|
||||
case SUPER_ADMIN = 'ROLE_SUPER_ADMIN';
|
||||
case ADMIN = 'ROLE_ADMIN';
|
||||
case PROF = 'ROLE_PROF';
|
||||
case VIE_SCOLAIRE = 'ROLE_VIE_SCOLAIRE';
|
||||
case SECRETARIAT = 'ROLE_SECRETARIAT';
|
||||
case PARENT = 'ROLE_PARENT';
|
||||
case ELEVE = 'ROLE_ELEVE';
|
||||
|
||||
/**
|
||||
* Vérifie si ce rôle inclut implicitement un autre rôle (hiérarchie).
|
||||
*/
|
||||
public function inclut(Role $autre): bool
|
||||
{
|
||||
$hierarchie = [
|
||||
self::SUPER_ADMIN->value => [
|
||||
self::ADMIN, self::PROF, self::VIE_SCOLAIRE,
|
||||
self::SECRETARIAT, self::PARENT, self::ELEVE,
|
||||
],
|
||||
self::ADMIN->value => [
|
||||
self::PROF, self::VIE_SCOLAIRE, self::SECRETARIAT,
|
||||
],
|
||||
];
|
||||
|
||||
$rolesInclus = $hierarchie[$this->value] ?? [];
|
||||
|
||||
return in_array($autre, $rolesInclus, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne le libellé français du rôle.
|
||||
*/
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::SUPER_ADMIN => 'Super Administrateur',
|
||||
self::ADMIN => 'Directeur',
|
||||
self::PROF => 'Enseignant',
|
||||
self::VIE_SCOLAIRE => 'Vie Scolaire',
|
||||
self::SECRETARIAT => 'Secrétariat',
|
||||
self::PARENT => 'Parent',
|
||||
self::ELEVE => 'Élève',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si ce rôle nécessite un consentement parental potentiel.
|
||||
*/
|
||||
public function peutEtreMineur(): bool
|
||||
{
|
||||
return $this === self::ELEVE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\User;
|
||||
|
||||
/**
|
||||
* Enum représentant le statut d'activation d'un compte utilisateur.
|
||||
*/
|
||||
enum StatutCompte: string
|
||||
{
|
||||
case EN_ATTENTE = 'pending'; // Compte créé, en attente d'activation
|
||||
case CONSENTEMENT_REQUIS = 'consent'; // Mineur < 15 ans, en attente consentement parental
|
||||
case ACTIF = 'active'; // Compte activé et utilisable
|
||||
case SUSPENDU = 'suspended'; // Compte temporairement désactivé
|
||||
case ARCHIVE = 'archived'; // Compte archivé (fin de scolarité)
|
||||
|
||||
/**
|
||||
* Vérifie si l'utilisateur peut se connecter avec ce statut.
|
||||
*/
|
||||
public function peutSeConnecter(): bool
|
||||
{
|
||||
return $this === self::ACTIF;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si l'utilisateur peut activer son compte.
|
||||
*/
|
||||
public function peutActiver(): bool
|
||||
{
|
||||
return $this === self::EN_ATTENTE;
|
||||
}
|
||||
}
|
||||
173
backend/src/Administration/Domain/Model/User/User.php
Normal file
173
backend/src/Administration/Domain/Model/User/User.php
Normal file
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\User;
|
||||
|
||||
use App\Administration\Domain\Event\CompteActive;
|
||||
use App\Administration\Domain\Event\CompteCreated;
|
||||
use App\Administration\Domain\Exception\CompteNonActivableException;
|
||||
use App\Administration\Domain\Model\ConsentementParental\ConsentementParental;
|
||||
use App\Administration\Domain\Policy\ConsentementParentalPolicy;
|
||||
use App\Shared\Domain\AggregateRoot;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Aggregate Root représentant un utilisateur dans Classeo.
|
||||
*
|
||||
* Un utilisateur appartient à un établissement (tenant) et possède un rôle.
|
||||
* Le cycle de vie du compte passe par plusieurs statuts : création → activation.
|
||||
* Les mineurs (< 15 ans) nécessitent un consentement parental avant activation.
|
||||
*/
|
||||
final class User extends AggregateRoot
|
||||
{
|
||||
public private(set) ?string $hashedPassword = null;
|
||||
public private(set) ?DateTimeImmutable $activatedAt = null;
|
||||
public private(set) ?ConsentementParental $consentementParental = null;
|
||||
|
||||
private function __construct(
|
||||
public private(set) UserId $id,
|
||||
public private(set) Email $email,
|
||||
public private(set) Role $role,
|
||||
public private(set) TenantId $tenantId,
|
||||
public private(set) string $schoolName,
|
||||
public private(set) StatutCompte $statut,
|
||||
public private(set) ?DateTimeImmutable $dateNaissance,
|
||||
public private(set) DateTimeImmutable $createdAt,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un nouveau compte utilisateur en attente d'activation.
|
||||
*/
|
||||
public static function creer(
|
||||
Email $email,
|
||||
Role $role,
|
||||
TenantId $tenantId,
|
||||
string $schoolName,
|
||||
?DateTimeImmutable $dateNaissance,
|
||||
DateTimeImmutable $createdAt,
|
||||
): self {
|
||||
$user = new self(
|
||||
id: UserId::generate(),
|
||||
email: $email,
|
||||
role: $role,
|
||||
tenantId: $tenantId,
|
||||
schoolName: $schoolName,
|
||||
statut: StatutCompte::EN_ATTENTE,
|
||||
dateNaissance: $dateNaissance,
|
||||
createdAt: $createdAt,
|
||||
);
|
||||
|
||||
$user->recordEvent(new CompteCreated(
|
||||
userId: $user->id,
|
||||
email: (string) $user->email,
|
||||
role: $user->role->value,
|
||||
tenantId: $user->tenantId,
|
||||
occurredOn: $createdAt,
|
||||
));
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Active le compte avec le mot de passe hashé.
|
||||
*
|
||||
* @throws CompteNonActivableException si le compte ne peut pas être activé
|
||||
*/
|
||||
public function activer(
|
||||
string $hashedPassword,
|
||||
DateTimeImmutable $at,
|
||||
ConsentementParentalPolicy $consentementPolicy,
|
||||
): void {
|
||||
if (!$this->statut->peutActiver()) {
|
||||
throw CompteNonActivableException::carStatutIncompatible($this->id, $this->statut);
|
||||
}
|
||||
|
||||
// Vérifier si le consentement parental est requis
|
||||
if ($consentementPolicy->estRequis($this->dateNaissance)) {
|
||||
if ($this->consentementParental === null) {
|
||||
throw CompteNonActivableException::carConsentementManquant($this->id);
|
||||
}
|
||||
}
|
||||
|
||||
$this->hashedPassword = $hashedPassword;
|
||||
$this->statut = StatutCompte::ACTIF;
|
||||
$this->activatedAt = $at;
|
||||
|
||||
$this->recordEvent(new CompteActive(
|
||||
userId: (string) $this->id,
|
||||
email: (string) $this->email,
|
||||
tenantId: $this->tenantId,
|
||||
role: $this->role->value,
|
||||
occurredOn: $at,
|
||||
aggregateId: $this->id->value,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre le consentement parental donné par le parent.
|
||||
*/
|
||||
public function enregistrerConsentementParental(ConsentementParental $consentement): void
|
||||
{
|
||||
$this->consentementParental = $consentement;
|
||||
|
||||
// Si le compte était en attente de consentement, passer en attente d'activation
|
||||
if ($this->statut === StatutCompte::CONSENTEMENT_REQUIS) {
|
||||
$this->statut = StatutCompte::EN_ATTENTE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si cet utilisateur est mineur et nécessite un consentement parental.
|
||||
*/
|
||||
public function necessiteConsentementParental(ConsentementParentalPolicy $policy): bool
|
||||
{
|
||||
return $policy->estRequis($this->dateNaissance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si le compte est actif et peut se connecter.
|
||||
*/
|
||||
public function peutSeConnecter(): bool
|
||||
{
|
||||
return $this->statut->peutSeConnecter();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstitue un User depuis le stockage.
|
||||
*
|
||||
* @internal Pour usage par l'Infrastructure uniquement
|
||||
*/
|
||||
public static function reconstitute(
|
||||
UserId $id,
|
||||
Email $email,
|
||||
Role $role,
|
||||
TenantId $tenantId,
|
||||
string $schoolName,
|
||||
StatutCompte $statut,
|
||||
?DateTimeImmutable $dateNaissance,
|
||||
DateTimeImmutable $createdAt,
|
||||
?string $hashedPassword,
|
||||
?DateTimeImmutable $activatedAt,
|
||||
?ConsentementParental $consentementParental,
|
||||
): self {
|
||||
$user = new self(
|
||||
id: $id,
|
||||
email: $email,
|
||||
role: $role,
|
||||
tenantId: $tenantId,
|
||||
schoolName: $schoolName,
|
||||
statut: $statut,
|
||||
dateNaissance: $dateNaissance,
|
||||
createdAt: $createdAt,
|
||||
);
|
||||
|
||||
$user->hashedPassword = $hashedPassword;
|
||||
$user->activatedAt = $activatedAt;
|
||||
$user->consentementParental = $consentementParental;
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
11
backend/src/Administration/Domain/Model/User/UserId.php
Normal file
11
backend/src/Administration/Domain/Model/User/UserId.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\User;
|
||||
|
||||
use App\Shared\Domain\EntityId;
|
||||
|
||||
final readonly class UserId extends EntityId
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Policy;
|
||||
|
||||
use App\Shared\Domain\Clock;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Policy déterminant si le consentement parental est requis.
|
||||
*
|
||||
* Conformément au RGPD (NFR-C1), le consentement parental est obligatoire
|
||||
* pour les utilisateurs de moins de 15 ans.
|
||||
*/
|
||||
final readonly class ConsentementParentalPolicy
|
||||
{
|
||||
private const int AGE_MAJORITE_NUMERIQUE = 15;
|
||||
|
||||
public function __construct(
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si le consentement parental est requis pour un utilisateur
|
||||
* né à la date spécifiée.
|
||||
*/
|
||||
public function estRequis(?DateTimeImmutable $dateNaissance): bool
|
||||
{
|
||||
if ($dateNaissance === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->calculerAge($dateNaissance) < self::AGE_MAJORITE_NUMERIQUE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule l'âge en années à partir de la date de naissance.
|
||||
*/
|
||||
private function calculerAge(DateTimeImmutable $dateNaissance): int
|
||||
{
|
||||
$now = $this->clock->now();
|
||||
$interval = $now->diff($dateNaissance);
|
||||
|
||||
return $interval->y;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Repository;
|
||||
|
||||
use App\Administration\Domain\Model\ActivationToken\ActivationToken;
|
||||
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
|
||||
|
||||
interface ActivationTokenRepository
|
||||
{
|
||||
public function save(ActivationToken $token): void;
|
||||
|
||||
/**
|
||||
* Find a token by its unique token value.
|
||||
*/
|
||||
public function findByTokenValue(string $tokenValue): ?ActivationToken;
|
||||
|
||||
/**
|
||||
* Get a token by its ID.
|
||||
*
|
||||
* @throws \App\Administration\Domain\Exception\ActivationTokenNotFoundException if token does not exist
|
||||
*/
|
||||
public function get(ActivationTokenId $id): ActivationToken;
|
||||
|
||||
/**
|
||||
* Delete a token (after use or for cleanup).
|
||||
*/
|
||||
public function delete(ActivationTokenId $id): void;
|
||||
|
||||
/**
|
||||
* Delete a token by its token value.
|
||||
*/
|
||||
public function deleteByTokenValue(string $tokenValue): void;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Repository;
|
||||
|
||||
use App\Administration\Domain\Model\User\Email;
|
||||
use App\Administration\Domain\Model\User\User;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
|
||||
interface UserRepository
|
||||
{
|
||||
public function save(User $user): void;
|
||||
|
||||
/**
|
||||
* @throws \App\Administration\Domain\Exception\UserNotFoundException
|
||||
*/
|
||||
public function get(UserId $id): User;
|
||||
|
||||
public function findByEmail(Email $email): ?User;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Administration\Application\Command\ActivateAccount\ActivateAccountCommand;
|
||||
use App\Administration\Application\Command\ActivateAccount\ActivateAccountHandler;
|
||||
use App\Administration\Domain\Exception\ActivationTokenAlreadyUsedException;
|
||||
use App\Administration\Domain\Exception\ActivationTokenExpiredException;
|
||||
use App\Administration\Domain\Exception\ActivationTokenNotFoundException;
|
||||
use App\Administration\Domain\Exception\CompteNonActivableException;
|
||||
use App\Administration\Domain\Exception\UserNotFoundException;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Domain\Policy\ConsentementParentalPolicy;
|
||||
use App\Administration\Domain\Repository\ActivationTokenRepository;
|
||||
use App\Administration\Domain\Repository\UserRepository;
|
||||
use App\Administration\Infrastructure\Api\Resource\ActivateAccountInput;
|
||||
use App\Administration\Infrastructure\Api\Resource\ActivateAccountOutput;
|
||||
use App\Shared\Domain\Clock;
|
||||
use Override;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
/**
|
||||
* API Platform processor for account activation.
|
||||
*
|
||||
* @implements ProcessorInterface<ActivateAccountInput, ActivateAccountOutput>
|
||||
*/
|
||||
final readonly class ActivateAccountProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private ActivateAccountHandler $handler,
|
||||
private UserRepository $userRepository,
|
||||
private ActivationTokenRepository $tokenRepository,
|
||||
private ConsentementParentalPolicy $consentementPolicy,
|
||||
private Clock $clock,
|
||||
private MessageBusInterface $eventBus,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ActivateAccountInput $data
|
||||
*/
|
||||
#[Override]
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ActivateAccountOutput
|
||||
{
|
||||
$command = new ActivateAccountCommand(
|
||||
tokenValue: $data->tokenValue,
|
||||
password: $data->password,
|
||||
);
|
||||
|
||||
try {
|
||||
$result = ($this->handler)($command);
|
||||
} catch (ActivationTokenNotFoundException) {
|
||||
throw new NotFoundHttpException('Token d\'activation invalide ou introuvable.');
|
||||
} catch (ActivationTokenExpiredException) {
|
||||
throw new BadRequestHttpException('Le token d\'activation a expiré. Veuillez contacter votre établissement pour obtenir un nouveau lien.');
|
||||
} catch (ActivationTokenAlreadyUsedException) {
|
||||
throw new BadRequestHttpException('Ce token d\'activation a déjà été utilisé.');
|
||||
}
|
||||
|
||||
// Activate the User account
|
||||
try {
|
||||
$user = $this->userRepository->get(UserId::fromString($result->userId));
|
||||
$user->activer(
|
||||
hashedPassword: $result->hashedPassword,
|
||||
at: $this->clock->now(),
|
||||
consentementPolicy: $this->consentementPolicy,
|
||||
);
|
||||
$this->userRepository->save($user);
|
||||
|
||||
// Publish domain events recorded on the User aggregate
|
||||
foreach ($user->pullDomainEvents() as $event) {
|
||||
$this->eventBus->dispatch($event);
|
||||
}
|
||||
|
||||
// Delete token only after successful user activation
|
||||
// This ensures failed activations (e.g., missing parental consent) don't burn the token
|
||||
$this->tokenRepository->deleteByTokenValue($data->tokenValue);
|
||||
} catch (UserNotFoundException) {
|
||||
throw new NotFoundHttpException('Utilisateur introuvable.');
|
||||
} catch (CompteNonActivableException $e) {
|
||||
throw new BadRequestHttpException($e->getMessage());
|
||||
}
|
||||
|
||||
return new ActivateAccountOutput(
|
||||
userId: $result->userId,
|
||||
email: $result->email,
|
||||
role: $result->role,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Repository\ActivationTokenRepository;
|
||||
use App\Administration\Infrastructure\Api\Resource\ActivationTokenInfo;
|
||||
use App\Shared\Domain\Clock;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
/**
|
||||
* API Platform provider for activation token information.
|
||||
*
|
||||
* @implements ProviderInterface<ActivationTokenInfo>
|
||||
*/
|
||||
final readonly class ActivationTokenInfoProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private ActivationTokenRepository $tokenRepository,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ActivationTokenInfo
|
||||
{
|
||||
/** @var string $tokenValue */
|
||||
$tokenValue = $uriVariables['tokenValue'] ?? '';
|
||||
|
||||
$token = $this->tokenRepository->findByTokenValue($tokenValue);
|
||||
|
||||
if ($token === null) {
|
||||
throw new NotFoundHttpException('Token d\'activation introuvable.');
|
||||
}
|
||||
|
||||
if ($token->isUsed()) {
|
||||
throw new NotFoundHttpException('Ce token d\'activation a déjà été utilisé.');
|
||||
}
|
||||
|
||||
return new ActivationTokenInfo(
|
||||
tokenValue: $token->tokenValue,
|
||||
email: $token->email,
|
||||
role: $this->translateRole($token->role),
|
||||
schoolName: $token->schoolName,
|
||||
isExpired: $token->isExpired($this->clock->now()),
|
||||
expiresAt: $token->expiresAt->format(DateTimeImmutable::ATOM),
|
||||
);
|
||||
}
|
||||
|
||||
private function translateRole(string $role): string
|
||||
{
|
||||
$roleEnum = Role::tryFrom($role);
|
||||
|
||||
return $roleEnum?->label() ?? $role;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Administration\Infrastructure\Api\Processor\ActivateAccountProcessor;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* API Resource for account activation.
|
||||
*
|
||||
* This endpoint accepts a token value and new password to activate a user account.
|
||||
*/
|
||||
#[ApiResource(
|
||||
shortName: 'AccountActivation',
|
||||
operations: [
|
||||
new Post(
|
||||
uriTemplate: '/activate',
|
||||
processor: ActivateAccountProcessor::class,
|
||||
output: ActivateAccountOutput::class,
|
||||
validationContext: ['groups' => ['Default', 'activate']],
|
||||
name: 'activate_account',
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class ActivateAccountInput
|
||||
{
|
||||
#[Assert\NotBlank(message: 'Le token d\'activation est requis.')]
|
||||
#[Assert\Uuid(message: 'Le token d\'activation doit être un UUID valide.')]
|
||||
public string $tokenValue = '';
|
||||
|
||||
#[Assert\NotBlank(message: 'Le mot de passe est requis.')]
|
||||
#[Assert\Length(
|
||||
min: 8,
|
||||
minMessage: 'Le mot de passe doit contenir au moins {{ limit }} caractères.',
|
||||
)]
|
||||
#[Assert\Regex(
|
||||
pattern: '/[A-Z]/',
|
||||
message: 'Le mot de passe doit contenir au moins une majuscule.',
|
||||
)]
|
||||
#[Assert\Regex(
|
||||
pattern: '/[0-9]/',
|
||||
message: 'Le mot de passe doit contenir au moins un chiffre.',
|
||||
)]
|
||||
public string $password = '';
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Resource;
|
||||
|
||||
/**
|
||||
* API Output for successful account activation.
|
||||
*/
|
||||
final readonly class ActivateAccountOutput
|
||||
{
|
||||
public function __construct(
|
||||
public string $userId,
|
||||
public string $email,
|
||||
public string $role,
|
||||
public string $message = 'Compte activé avec succès.',
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use App\Administration\Infrastructure\Api\Provider\ActivationTokenInfoProvider;
|
||||
|
||||
/**
|
||||
* API Resource for retrieving activation token information.
|
||||
*
|
||||
* Used by the frontend to display the establishment name and role
|
||||
* before the user submits their password.
|
||||
*/
|
||||
#[ApiResource(
|
||||
shortName: 'ActivationTokenInfo',
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/activation-tokens/{tokenValue}',
|
||||
provider: ActivationTokenInfoProvider::class,
|
||||
name: 'get_activation_token_info',
|
||||
),
|
||||
],
|
||||
)]
|
||||
final readonly class ActivationTokenInfo
|
||||
{
|
||||
public function __construct(
|
||||
public string $tokenValue,
|
||||
public string $email,
|
||||
public string $role,
|
||||
public string $schoolName,
|
||||
public bool $isExpired,
|
||||
public string $expiresAt,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Console;
|
||||
|
||||
use App\Administration\Domain\Model\ActivationToken\ActivationToken;
|
||||
use App\Administration\Domain\Model\User\Email;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\User;
|
||||
use App\Administration\Domain\Repository\ActivationTokenRepository;
|
||||
use App\Administration\Domain\Repository\UserRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:dev:create-test-activation-token',
|
||||
description: 'Creates a test user and activation token for development',
|
||||
)]
|
||||
final class CreateTestActivationTokenCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ActivationTokenRepository $activationTokenRepository,
|
||||
private readonly UserRepository $userRepository,
|
||||
private readonly Clock $clock,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addOption('email', null, InputOption::VALUE_OPTIONAL, 'Email address', 'test@example.com')
|
||||
->addOption('role', null, InputOption::VALUE_OPTIONAL, 'User role (PARENT, ELEVE, PROF, ADMIN)', 'PARENT')
|
||||
->addOption('school', null, InputOption::VALUE_OPTIONAL, 'School name', 'École de Test')
|
||||
->addOption('minor', null, InputOption::VALUE_NONE, 'Create a minor user (requires parental consent)')
|
||||
->addOption('base-url', null, InputOption::VALUE_OPTIONAL, 'Frontend base URL', 'http://localhost:5173');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
/** @var string $email */
|
||||
$email = $input->getOption('email');
|
||||
/** @var string $roleOption */
|
||||
$roleOption = $input->getOption('role');
|
||||
$roleInput = strtoupper($roleOption);
|
||||
/** @var string $schoolName */
|
||||
$schoolName = $input->getOption('school');
|
||||
$isMinor = $input->getOption('minor');
|
||||
/** @var string $baseUrlOption */
|
||||
$baseUrlOption = $input->getOption('base-url');
|
||||
$baseUrl = rtrim($baseUrlOption, '/');
|
||||
|
||||
// Convert short role name to full Symfony role format
|
||||
$roleName = str_starts_with($roleInput, 'ROLE_') ? $roleInput : 'ROLE_' . $roleInput;
|
||||
|
||||
$role = Role::tryFrom($roleName);
|
||||
if ($role === null) {
|
||||
$validRoles = array_map(static fn (Role $r) => str_replace('ROLE_', '', $r->value), Role::cases());
|
||||
$io->error(sprintf(
|
||||
'Invalid role "%s". Valid roles: %s',
|
||||
$roleInput,
|
||||
implode(', ', $validRoles)
|
||||
));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$now = $this->clock->now();
|
||||
$tenantId = TenantId::fromString('550e8400-e29b-41d4-a716-446655440001');
|
||||
|
||||
// Create user
|
||||
$dateNaissance = $isMinor
|
||||
? $now->modify('-13 years') // 13 ans = mineur
|
||||
: null;
|
||||
|
||||
$user = User::creer(
|
||||
email: new Email($email),
|
||||
role: $role,
|
||||
tenantId: $tenantId,
|
||||
schoolName: $schoolName,
|
||||
dateNaissance: $dateNaissance,
|
||||
createdAt: $now,
|
||||
);
|
||||
|
||||
$this->userRepository->save($user);
|
||||
|
||||
// Create activation token
|
||||
$token = ActivationToken::generate(
|
||||
userId: (string) $user->id,
|
||||
email: $email,
|
||||
tenantId: $tenantId,
|
||||
role: $role->value,
|
||||
schoolName: $schoolName,
|
||||
createdAt: $now,
|
||||
);
|
||||
|
||||
$this->activationTokenRepository->save($token);
|
||||
|
||||
$activationUrl = sprintf('%s/activate/%s', $baseUrl, $token->tokenValue);
|
||||
|
||||
$io->success('Test activation token created successfully!');
|
||||
|
||||
$io->table(
|
||||
['Property', 'Value'],
|
||||
[
|
||||
['User ID', (string) $user->id],
|
||||
['Email', $email],
|
||||
['Role', $role->value],
|
||||
['School', $schoolName],
|
||||
['Minor', $isMinor ? 'Yes (requires parental consent)' : 'No'],
|
||||
['Token', $token->tokenValue],
|
||||
['Expires', $token->expiresAt->format('Y-m-d H:i:s')],
|
||||
]
|
||||
);
|
||||
|
||||
$io->writeln('');
|
||||
$io->writeln(sprintf('<info>Activation URL:</info> <href=%s>%s</>', $activationUrl, $activationUrl));
|
||||
$io->writeln('');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Messaging;
|
||||
|
||||
use App\Administration\Domain\Event\CompteActive;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use Symfony\Component\Mailer\MailerInterface;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
use Symfony\Component\Mime\Email;
|
||||
use Twig\Environment;
|
||||
|
||||
/**
|
||||
* Sends a confirmation email when an account is activated.
|
||||
*
|
||||
* This handler listens for CompteActive events and sends an email
|
||||
* to the user confirming their account activation.
|
||||
*/
|
||||
#[AsMessageHandler(bus: 'event.bus')]
|
||||
final readonly class SendActivationConfirmationHandler
|
||||
{
|
||||
public function __construct(
|
||||
private MailerInterface $mailer,
|
||||
private Environment $twig,
|
||||
private string $appUrl,
|
||||
private string $fromEmail = 'noreply@classeo.fr',
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(CompteActive $event): void
|
||||
{
|
||||
$roleEnum = Role::tryFrom($event->role);
|
||||
$roleLabel = $roleEnum?->label() ?? $event->role;
|
||||
|
||||
$html = $this->twig->render('emails/activation_confirmation.html.twig', [
|
||||
'email' => $event->email,
|
||||
'role' => $roleLabel,
|
||||
'loginUrl' => rtrim($this->appUrl, '/') . '/login',
|
||||
]);
|
||||
|
||||
$email = (new Email())
|
||||
->from($this->fromEmail)
|
||||
->to($event->email)
|
||||
->subject('Votre compte Classeo est activé')
|
||||
->html($html);
|
||||
|
||||
$this->mailer->send($email);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Persistence\Cache;
|
||||
|
||||
use App\Administration\Domain\Exception\UserNotFoundException;
|
||||
use App\Administration\Domain\Model\ConsentementParental\ConsentementParental;
|
||||
use App\Administration\Domain\Model\User\Email;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\StatutCompte;
|
||||
use App\Administration\Domain\Model\User\User;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Domain\Repository\UserRepository;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
|
||||
/**
|
||||
* Cache-based UserRepository for development and testing.
|
||||
* Uses PSR-6 cache (filesystem in dev, Redis in prod).
|
||||
*
|
||||
* Note: Uses a dedicated users.cache pool with no TTL to ensure
|
||||
* user records don't expire (unlike activation tokens which expire after 7 days).
|
||||
*/
|
||||
final readonly class CacheUserRepository implements UserRepository
|
||||
{
|
||||
private const string KEY_PREFIX = 'user:';
|
||||
private const string EMAIL_INDEX_PREFIX = 'user_email:';
|
||||
|
||||
public function __construct(
|
||||
private CacheItemPoolInterface $usersCache,
|
||||
) {
|
||||
}
|
||||
|
||||
public function save(User $user): void
|
||||
{
|
||||
// Save user data
|
||||
$item = $this->usersCache->getItem(self::KEY_PREFIX . $user->id);
|
||||
$item->set($this->serialize($user));
|
||||
$this->usersCache->save($item);
|
||||
|
||||
// Save email index for lookup
|
||||
$emailItem = $this->usersCache->getItem(self::EMAIL_INDEX_PREFIX . $this->normalizeEmail($user->email));
|
||||
$emailItem->set((string) $user->id);
|
||||
$this->usersCache->save($emailItem);
|
||||
}
|
||||
|
||||
public function findById(UserId $id): ?User
|
||||
{
|
||||
$item = $this->usersCache->getItem(self::KEY_PREFIX . $id);
|
||||
|
||||
if (!$item->isHit()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var array{id: string, email: string, role: string, tenant_id: string, school_name: string, statut: string, hashed_password: string|null, date_naissance: string|null, created_at: string, activated_at: string|null, consentement_parental: array{parent_id: string, eleve_id: string, date_consentement: string, ip_address: string}|null} $data */
|
||||
$data = $item->get();
|
||||
|
||||
return $this->deserialize($data);
|
||||
}
|
||||
|
||||
public function findByEmail(Email $email): ?User
|
||||
{
|
||||
$emailItem = $this->usersCache->getItem(self::EMAIL_INDEX_PREFIX . $this->normalizeEmail($email));
|
||||
|
||||
if (!$emailItem->isHit()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var string $userId */
|
||||
$userId = $emailItem->get();
|
||||
|
||||
return $this->findById(UserId::fromString($userId));
|
||||
}
|
||||
|
||||
public function get(UserId $id): User
|
||||
{
|
||||
$user = $this->findById($id);
|
||||
|
||||
if ($user === null) {
|
||||
throw UserNotFoundException::withId($id);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function serialize(User $user): array
|
||||
{
|
||||
$consentement = $user->consentementParental;
|
||||
|
||||
return [
|
||||
'id' => (string) $user->id,
|
||||
'email' => (string) $user->email,
|
||||
'role' => $user->role->value,
|
||||
'tenant_id' => (string) $user->tenantId,
|
||||
'school_name' => $user->schoolName,
|
||||
'statut' => $user->statut->value,
|
||||
'hashed_password' => $user->hashedPassword,
|
||||
'date_naissance' => $user->dateNaissance?->format('Y-m-d'),
|
||||
'created_at' => $user->createdAt->format('c'),
|
||||
'activated_at' => $user->activatedAt?->format('c'),
|
||||
'consentement_parental' => $consentement !== null ? [
|
||||
'parent_id' => $consentement->parentId,
|
||||
'eleve_id' => $consentement->eleveId,
|
||||
'date_consentement' => $consentement->dateConsentement->format('c'),
|
||||
'ip_address' => $consentement->ipAddress,
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* id: string,
|
||||
* email: string,
|
||||
* role: string,
|
||||
* tenant_id: string,
|
||||
* school_name: string,
|
||||
* statut: string,
|
||||
* hashed_password: string|null,
|
||||
* date_naissance: string|null,
|
||||
* created_at: string,
|
||||
* activated_at: string|null,
|
||||
* consentement_parental: array{parent_id: string, eleve_id: string, date_consentement: string, ip_address: string}|null
|
||||
* } $data
|
||||
*/
|
||||
private function deserialize(array $data): User
|
||||
{
|
||||
$consentement = null;
|
||||
if ($data['consentement_parental'] !== null) {
|
||||
$consentementData = $data['consentement_parental'];
|
||||
$consentement = ConsentementParental::accorder(
|
||||
parentId: $consentementData['parent_id'],
|
||||
eleveId: $consentementData['eleve_id'],
|
||||
at: new DateTimeImmutable($consentementData['date_consentement']),
|
||||
ipAddress: $consentementData['ip_address'],
|
||||
);
|
||||
}
|
||||
|
||||
return User::reconstitute(
|
||||
id: UserId::fromString($data['id']),
|
||||
email: new Email($data['email']),
|
||||
role: Role::from($data['role']),
|
||||
tenantId: TenantId::fromString($data['tenant_id']),
|
||||
schoolName: $data['school_name'],
|
||||
statut: StatutCompte::from($data['statut']),
|
||||
dateNaissance: $data['date_naissance'] !== null ? new DateTimeImmutable($data['date_naissance']) : null,
|
||||
createdAt: new DateTimeImmutable($data['created_at']),
|
||||
hashedPassword: $data['hashed_password'],
|
||||
activatedAt: $data['activated_at'] !== null ? new DateTimeImmutable($data['activated_at']) : null,
|
||||
consentementParental: $consentement,
|
||||
);
|
||||
}
|
||||
|
||||
private function normalizeEmail(Email $email): string
|
||||
{
|
||||
return strtolower(str_replace(['@', '.'], ['_at_', '_dot_'], (string) $email));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Persistence\InMemory;
|
||||
|
||||
use App\Administration\Domain\Exception\ActivationTokenNotFoundException;
|
||||
use App\Administration\Domain\Model\ActivationToken\ActivationToken;
|
||||
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
|
||||
use App\Administration\Domain\Repository\ActivationTokenRepository;
|
||||
use Override;
|
||||
|
||||
final class InMemoryActivationTokenRepository implements ActivationTokenRepository
|
||||
{
|
||||
/** @var array<string, ActivationToken> Indexed by token value */
|
||||
private array $byTokenValue = [];
|
||||
|
||||
/** @var array<string, string> Maps ID to token value */
|
||||
private array $idToTokenValue = [];
|
||||
|
||||
#[Override]
|
||||
public function save(ActivationToken $token): void
|
||||
{
|
||||
$this->byTokenValue[$token->tokenValue] = $token;
|
||||
$this->idToTokenValue[(string) $token->id] = $token->tokenValue;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findByTokenValue(string $tokenValue): ?ActivationToken
|
||||
{
|
||||
return $this->byTokenValue[$tokenValue] ?? null;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function get(ActivationTokenId $id): ActivationToken
|
||||
{
|
||||
$tokenValue = $this->idToTokenValue[(string) $id] ?? null;
|
||||
|
||||
if ($tokenValue === null) {
|
||||
throw ActivationTokenNotFoundException::withId($id);
|
||||
}
|
||||
|
||||
$token = $this->byTokenValue[$tokenValue] ?? null;
|
||||
|
||||
if ($token === null) {
|
||||
throw ActivationTokenNotFoundException::withId($id);
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function delete(ActivationTokenId $id): void
|
||||
{
|
||||
$tokenValue = $this->idToTokenValue[(string) $id] ?? null;
|
||||
|
||||
if ($tokenValue !== null) {
|
||||
unset($this->byTokenValue[$tokenValue]);
|
||||
}
|
||||
|
||||
unset($this->idToTokenValue[(string) $id]);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function deleteByTokenValue(string $tokenValue): void
|
||||
{
|
||||
$token = $this->byTokenValue[$tokenValue] ?? null;
|
||||
|
||||
if ($token !== null) {
|
||||
unset($this->idToTokenValue[(string) $token->id]);
|
||||
}
|
||||
|
||||
unset($this->byTokenValue[$tokenValue]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Persistence\InMemory;
|
||||
|
||||
use App\Administration\Domain\Exception\UserNotFoundException;
|
||||
use App\Administration\Domain\Model\User\Email;
|
||||
use App\Administration\Domain\Model\User\User;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Domain\Repository\UserRepository;
|
||||
use Override;
|
||||
|
||||
final class InMemoryUserRepository implements UserRepository
|
||||
{
|
||||
/** @var array<string, User> Indexed by ID */
|
||||
private array $byId = [];
|
||||
|
||||
/** @var array<string, User> Indexed by email (lowercase) */
|
||||
private array $byEmail = [];
|
||||
|
||||
#[Override]
|
||||
public function save(User $user): void
|
||||
{
|
||||
$this->byId[(string) $user->id] = $user;
|
||||
$this->byEmail[strtolower((string) $user->email)] = $user;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function get(UserId $id): User
|
||||
{
|
||||
$user = $this->byId[(string) $id] ?? null;
|
||||
|
||||
if ($user === null) {
|
||||
throw UserNotFoundException::withId($id);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findByEmail(Email $email): ?User
|
||||
{
|
||||
return $this->byEmail[strtolower((string) $email)] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Persistence\Redis;
|
||||
|
||||
use App\Administration\Domain\Exception\ActivationTokenNotFoundException;
|
||||
use App\Administration\Domain\Model\ActivationToken\ActivationToken;
|
||||
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
|
||||
use App\Administration\Domain\Repository\ActivationTokenRepository;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
|
||||
final readonly class RedisActivationTokenRepository implements ActivationTokenRepository
|
||||
{
|
||||
private const string KEY_PREFIX = 'activation:';
|
||||
private const int TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days
|
||||
|
||||
public function __construct(
|
||||
private CacheItemPoolInterface $activationTokensCache,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function save(ActivationToken $token): void
|
||||
{
|
||||
// Store by token value for lookup during activation
|
||||
$item = $this->activationTokensCache->getItem(self::KEY_PREFIX . $token->tokenValue);
|
||||
$item->set($this->serialize($token));
|
||||
$item->expiresAfter(self::TTL_SECONDS);
|
||||
$this->activationTokensCache->save($item);
|
||||
|
||||
// Also store by ID for direct access
|
||||
$idItem = $this->activationTokensCache->getItem(self::KEY_PREFIX . 'id:' . $token->id);
|
||||
$idItem->set($token->tokenValue);
|
||||
$idItem->expiresAfter(self::TTL_SECONDS);
|
||||
$this->activationTokensCache->save($idItem);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findByTokenValue(string $tokenValue): ?ActivationToken
|
||||
{
|
||||
$item = $this->activationTokensCache->getItem(self::KEY_PREFIX . $tokenValue);
|
||||
|
||||
if (!$item->isHit()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var array{id: string, token_value: string, user_id: string, email: string, tenant_id: string, role: string, school_name: string, created_at: string, expires_at: string, used_at: string|null} $data */
|
||||
$data = $item->get();
|
||||
|
||||
return $this->deserialize($data);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function get(ActivationTokenId $id): ActivationToken
|
||||
{
|
||||
// First get the token value from the ID index
|
||||
$idItem = $this->activationTokensCache->getItem(self::KEY_PREFIX . 'id:' . $id);
|
||||
|
||||
if (!$idItem->isHit()) {
|
||||
throw ActivationTokenNotFoundException::withId($id);
|
||||
}
|
||||
|
||||
/** @var string $tokenValue */
|
||||
$tokenValue = $idItem->get();
|
||||
$token = $this->findByTokenValue($tokenValue);
|
||||
|
||||
if ($token === null) {
|
||||
throw ActivationTokenNotFoundException::withId($id);
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function delete(ActivationTokenId $id): void
|
||||
{
|
||||
// Get token value first
|
||||
$idItem = $this->activationTokensCache->getItem(self::KEY_PREFIX . 'id:' . $id);
|
||||
|
||||
if ($idItem->isHit()) {
|
||||
/** @var string $tokenValue */
|
||||
$tokenValue = $idItem->get();
|
||||
$this->activationTokensCache->deleteItem(self::KEY_PREFIX . $tokenValue);
|
||||
}
|
||||
|
||||
$this->activationTokensCache->deleteItem(self::KEY_PREFIX . 'id:' . $id);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function deleteByTokenValue(string $tokenValue): void
|
||||
{
|
||||
$token = $this->findByTokenValue($tokenValue);
|
||||
|
||||
if ($token !== null) {
|
||||
$this->activationTokensCache->deleteItem(self::KEY_PREFIX . 'id:' . $token->id);
|
||||
}
|
||||
|
||||
$this->activationTokensCache->deleteItem(self::KEY_PREFIX . $tokenValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id: string, token_value: string, user_id: string, email: string, tenant_id: string, role: string, school_name: string, created_at: string, expires_at: string, used_at: string|null}
|
||||
*/
|
||||
private function serialize(ActivationToken $token): array
|
||||
{
|
||||
return [
|
||||
'id' => (string) $token->id,
|
||||
'token_value' => $token->tokenValue,
|
||||
'user_id' => $token->userId,
|
||||
'email' => $token->email,
|
||||
'tenant_id' => (string) $token->tenantId,
|
||||
'role' => $token->role,
|
||||
'school_name' => $token->schoolName,
|
||||
'created_at' => $token->createdAt->format(DateTimeImmutable::ATOM),
|
||||
'expires_at' => $token->expiresAt->format(DateTimeImmutable::ATOM),
|
||||
'used_at' => $token->usedAt?->format(DateTimeImmutable::ATOM),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{id: string, token_value: string, user_id: string, email: string, tenant_id: string, role: string, school_name: string, created_at: string, expires_at: string, used_at: string|null} $data
|
||||
*/
|
||||
private function deserialize(array $data): ActivationToken
|
||||
{
|
||||
return ActivationToken::reconstitute(
|
||||
id: ActivationTokenId::fromString($data['id']),
|
||||
tokenValue: $data['token_value'],
|
||||
userId: $data['user_id'],
|
||||
email: $data['email'],
|
||||
tenantId: TenantId::fromString($data['tenant_id']),
|
||||
role: $data['role'],
|
||||
schoolName: $data['school_name'],
|
||||
createdAt: new DateTimeImmutable($data['created_at']),
|
||||
expiresAt: new DateTimeImmutable($data['expires_at']),
|
||||
usedAt: $data['used_at'] !== null ? new DateTimeImmutable($data['used_at']) : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Security;
|
||||
|
||||
use App\Administration\Application\Port\PasswordHasher;
|
||||
use Override;
|
||||
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface;
|
||||
|
||||
/**
|
||||
* Symfony implementation of the PasswordHasher port.
|
||||
*
|
||||
* Uses Symfony's PasswordHasher component with Argon2id algorithm.
|
||||
*/
|
||||
final readonly class SymfonyPasswordHasher implements PasswordHasher
|
||||
{
|
||||
private const string HASHER_ID = 'common';
|
||||
|
||||
public function __construct(
|
||||
private PasswordHasherFactoryInterface $hasherFactory,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function hash(string $plainPassword): string
|
||||
{
|
||||
return $this->hasherFactory
|
||||
->getPasswordHasher(self::HASHER_ID)
|
||||
->hash($plainPassword);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function verify(string $hashedPassword, string $plainPassword): bool
|
||||
{
|
||||
return $this->hasherFactory
|
||||
->getPasswordHasher(self::HASHER_ID)
|
||||
->verify($hashedPassword, $plainPassword);
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,9 @@ final readonly class TenantMiddleware implements EventSubscriberInterface
|
||||
'/api/docs.json',
|
||||
'/api/docs.jsonld',
|
||||
'/api/contexts',
|
||||
'/api/activation-tokens',
|
||||
'/api/activate',
|
||||
'/api/login',
|
||||
'/_profiler',
|
||||
'/_wdt',
|
||||
'/_error',
|
||||
|
||||
@@ -85,6 +85,18 @@
|
||||
"config/packages/lexik_jwt_authentication.yaml"
|
||||
]
|
||||
},
|
||||
"nelmio/cors-bundle": {
|
||||
"version": "2.6",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "1.5",
|
||||
"ref": "6bea22e6c564fba3a1391615cada1437d0bde39c"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/nelmio_cors.yaml"
|
||||
]
|
||||
},
|
||||
"phpstan/phpstan": {
|
||||
"version": "2.1",
|
||||
"recipe": {
|
||||
@@ -166,6 +178,18 @@
|
||||
".editorconfig"
|
||||
]
|
||||
},
|
||||
"symfony/mailer": {
|
||||
"version": "8.0",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "4.3",
|
||||
"ref": "09051cfde49476e3c12cd3a0e44289ace1c75a4f"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/mailer.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/maker-bundle": {
|
||||
"version": "1.65",
|
||||
"recipe": {
|
||||
|
||||
113
backend/templates/emails/activation_confirmation.html.twig
Normal file
113
backend/templates/emails/activation_confirmation.html.twig
Normal file
@@ -0,0 +1,113 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Compte activé - Classeo</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
border-bottom: 2px solid #4f46e5;
|
||||
}
|
||||
.header h1 {
|
||||
color: #4f46e5;
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
}
|
||||
.content {
|
||||
padding: 30px 0;
|
||||
}
|
||||
.success-icon {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.success-icon span {
|
||||
display: inline-block;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background-color: #10b981;
|
||||
border-radius: 50%;
|
||||
line-height: 60px;
|
||||
color: white;
|
||||
font-size: 30px;
|
||||
}
|
||||
.info-box {
|
||||
background-color: #f3f4f6;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.info-box p {
|
||||
margin: 5px 0;
|
||||
}
|
||||
.button {
|
||||
display: inline-block;
|
||||
background-color: #4f46e5;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.button:hover {
|
||||
background-color: #4338ca;
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Classeo</h1>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="success-icon">
|
||||
<span>✓</span>
|
||||
</div>
|
||||
|
||||
<h2 style="text-align: center;">Votre compte est activé !</h2>
|
||||
|
||||
<p>Bonjour,</p>
|
||||
|
||||
<p>Nous vous confirmons que votre compte Classeo a été activé avec succès.</p>
|
||||
|
||||
<div class="info-box">
|
||||
<p><strong>Email :</strong> {{ email }}</p>
|
||||
<p><strong>Rôle :</strong> {{ role }}</p>
|
||||
</div>
|
||||
|
||||
<p>Vous pouvez maintenant vous connecter à Classeo pour accéder à toutes les fonctionnalités disponibles.</p>
|
||||
|
||||
<p style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{ loginUrl }}" class="button">Se connecter</a>
|
||||
</p>
|
||||
|
||||
<p><strong>Conseils de sécurité :</strong></p>
|
||||
<ul>
|
||||
<li>Ne partagez jamais votre mot de passe</li>
|
||||
<li>Déconnectez-vous après utilisation sur un ordinateur partagé</li>
|
||||
<li>Contactez votre établissement en cas de problème d'accès</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>Cet email a été envoyé automatiquement par Classeo.</p>
|
||||
<p>Si vous n'êtes pas à l'origine de cette action, veuillez contacter votre établissement.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Application\Command\ActivateAccount;
|
||||
|
||||
use App\Administration\Application\Command\ActivateAccount\ActivateAccountCommand;
|
||||
use App\Administration\Application\Command\ActivateAccount\ActivateAccountHandler;
|
||||
use App\Administration\Application\Command\ActivateAccount\ActivateAccountResult;
|
||||
use App\Administration\Application\Port\PasswordHasher;
|
||||
use App\Administration\Domain\Exception\ActivationTokenAlreadyUsedException;
|
||||
use App\Administration\Domain\Exception\ActivationTokenExpiredException;
|
||||
use App\Administration\Domain\Exception\ActivationTokenNotFoundException;
|
||||
use App\Administration\Domain\Model\ActivationToken\ActivationToken;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryActivationTokenRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ActivateAccountHandlerTest extends TestCase
|
||||
{
|
||||
private const string USER_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||
private const string EMAIL = 'user@example.com';
|
||||
private const string ROLE = 'ROLE_PARENT';
|
||||
private const string SCHOOL_NAME = 'École Alpha';
|
||||
private const string PASSWORD = 'SecurePass123';
|
||||
private const string HASHED_PASSWORD = '$argon2id$hashed_password';
|
||||
|
||||
private InMemoryActivationTokenRepository $tokenRepository;
|
||||
private PasswordHasher $passwordHasher;
|
||||
private Clock $clock;
|
||||
private ActivateAccountHandler $handler;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->tokenRepository = new InMemoryActivationTokenRepository();
|
||||
$this->passwordHasher = new class implements PasswordHasher {
|
||||
#[Override]
|
||||
public function hash(string $plainPassword): string
|
||||
{
|
||||
return '$argon2id$hashed_password';
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function verify(string $hashedPassword, string $plainPassword): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
};
|
||||
$this->clock = new class implements Clock {
|
||||
public DateTimeImmutable $now;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->now = new DateTimeImmutable('2026-01-16 10:00:00');
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return $this->now;
|
||||
}
|
||||
};
|
||||
|
||||
$this->handler = new ActivateAccountHandler(
|
||||
$this->tokenRepository,
|
||||
$this->passwordHasher,
|
||||
$this->clock,
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function activateAccountSuccessfully(): void
|
||||
{
|
||||
$token = $this->createAndSaveToken();
|
||||
|
||||
$command = new ActivateAccountCommand(
|
||||
tokenValue: $token->tokenValue,
|
||||
password: self::PASSWORD,
|
||||
);
|
||||
|
||||
$result = ($this->handler)($command);
|
||||
|
||||
self::assertInstanceOf(ActivateAccountResult::class, $result);
|
||||
self::assertSame(self::USER_ID, $result->userId);
|
||||
self::assertSame(self::EMAIL, $result->email);
|
||||
self::assertSame(self::ROLE, $result->role);
|
||||
self::assertSame(self::HASHED_PASSWORD, $result->hashedPassword);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function activateAccountValidatesButDoesNotConsumeToken(): void
|
||||
{
|
||||
// Handler only validates the token - consumption is deferred to the processor
|
||||
// after successful user activation, so failed activations don't burn the token
|
||||
$token = $this->createAndSaveToken();
|
||||
$tokenValue = $token->tokenValue;
|
||||
|
||||
$command = new ActivateAccountCommand(
|
||||
tokenValue: $tokenValue,
|
||||
password: self::PASSWORD,
|
||||
);
|
||||
|
||||
($this->handler)($command);
|
||||
|
||||
// Token should still exist and NOT be marked as used
|
||||
$updatedToken = $this->tokenRepository->findByTokenValue($tokenValue);
|
||||
self::assertNotNull($updatedToken);
|
||||
self::assertFalse($updatedToken->isUsed());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function activateAccountThrowsWhenTokenNotFound(): void
|
||||
{
|
||||
$command = new ActivateAccountCommand(
|
||||
tokenValue: 'non-existent-token',
|
||||
password: self::PASSWORD,
|
||||
);
|
||||
|
||||
$this->expectException(ActivationTokenNotFoundException::class);
|
||||
|
||||
($this->handler)($command);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function activateAccountThrowsWhenTokenExpired(): void
|
||||
{
|
||||
$token = $this->createAndSaveToken(
|
||||
createdAt: new DateTimeImmutable('2026-01-01 10:00:00'),
|
||||
);
|
||||
|
||||
// Clock is set to 2026-01-16, token expires 2026-01-08
|
||||
$command = new ActivateAccountCommand(
|
||||
tokenValue: $token->tokenValue,
|
||||
password: self::PASSWORD,
|
||||
);
|
||||
|
||||
$this->expectException(ActivationTokenExpiredException::class);
|
||||
|
||||
($this->handler)($command);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function activateAccountThrowsWhenTokenAlreadyUsed(): void
|
||||
{
|
||||
$token = $this->createAndSaveToken();
|
||||
|
||||
// Simulate a token that was already used (e.g., by the processor after successful activation)
|
||||
$token->use($this->clock->now());
|
||||
$this->tokenRepository->save($token);
|
||||
|
||||
$command = new ActivateAccountCommand(
|
||||
tokenValue: $token->tokenValue,
|
||||
password: self::PASSWORD,
|
||||
);
|
||||
|
||||
// Should fail because token is already used
|
||||
$this->expectException(ActivationTokenAlreadyUsedException::class);
|
||||
|
||||
($this->handler)($command);
|
||||
}
|
||||
|
||||
private function createAndSaveToken(?DateTimeImmutable $createdAt = null): ActivationToken
|
||||
{
|
||||
$token = ActivationToken::generate(
|
||||
userId: self::USER_ID,
|
||||
email: self::EMAIL,
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
role: self::ROLE,
|
||||
schoolName: self::SCHOOL_NAME,
|
||||
createdAt: $createdAt ?? new DateTimeImmutable('2026-01-15 10:00:00'),
|
||||
);
|
||||
|
||||
$this->tokenRepository->save($token);
|
||||
|
||||
return $token;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Domain\Model\ActivationToken;
|
||||
|
||||
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
|
||||
final class ActivationTokenIdTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function generateCreatesValidUuid(): void
|
||||
{
|
||||
$id = ActivationTokenId::generate();
|
||||
|
||||
self::assertInstanceOf(ActivationTokenId::class, $id);
|
||||
self::assertTrue(Uuid::isValid((string) $id));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function fromStringCreatesIdFromValidUuid(): void
|
||||
{
|
||||
$uuid = '550e8400-e29b-41d4-a716-446655440000';
|
||||
$id = ActivationTokenId::fromString($uuid);
|
||||
|
||||
self::assertSame($uuid, (string) $id);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function equalsReturnsTrueForSameValue(): void
|
||||
{
|
||||
$uuid = '550e8400-e29b-41d4-a716-446655440000';
|
||||
$id1 = ActivationTokenId::fromString($uuid);
|
||||
$id2 = ActivationTokenId::fromString($uuid);
|
||||
|
||||
self::assertTrue($id1->equals($id2));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function equalsReturnsFalseForDifferentValue(): void
|
||||
{
|
||||
$id1 = ActivationTokenId::generate();
|
||||
$id2 = ActivationTokenId::generate();
|
||||
|
||||
self::assertFalse($id1->equals($id2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Domain\Model\ActivationToken;
|
||||
|
||||
use App\Administration\Domain\Event\ActivationTokenGenerated;
|
||||
use App\Administration\Domain\Event\ActivationTokenUsed;
|
||||
use App\Administration\Domain\Exception\ActivationTokenAlreadyUsedException;
|
||||
use App\Administration\Domain\Exception\ActivationTokenExpiredException;
|
||||
use App\Administration\Domain\Model\ActivationToken\ActivationToken;
|
||||
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ActivationTokenTest extends TestCase
|
||||
{
|
||||
private const string USER_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||
private const string EMAIL = 'user@example.com';
|
||||
private const string ROLE = 'ROLE_PARENT';
|
||||
private const string SCHOOL_NAME = 'École Alpha';
|
||||
|
||||
#[Test]
|
||||
public function generateCreatesTokenWithCorrectProperties(): void
|
||||
{
|
||||
$userId = self::USER_ID;
|
||||
$email = self::EMAIL;
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
$role = self::ROLE;
|
||||
$schoolName = self::SCHOOL_NAME;
|
||||
$now = new DateTimeImmutable('2026-01-15 10:00:00');
|
||||
|
||||
$token = ActivationToken::generate(
|
||||
userId: $userId,
|
||||
email: $email,
|
||||
tenantId: $tenantId,
|
||||
role: $role,
|
||||
schoolName: $schoolName,
|
||||
createdAt: $now,
|
||||
);
|
||||
|
||||
self::assertInstanceOf(ActivationTokenId::class, $token->id);
|
||||
self::assertSame($userId, $token->userId);
|
||||
self::assertSame($email, $token->email);
|
||||
self::assertTrue($tenantId->equals($token->tenantId));
|
||||
self::assertSame($role, $token->role);
|
||||
self::assertSame($schoolName, $token->schoolName);
|
||||
self::assertEquals($now, $token->createdAt);
|
||||
self::assertFalse($token->isUsed());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function generateRecordsActivationTokenGeneratedEvent(): void
|
||||
{
|
||||
$token = $this->createToken();
|
||||
|
||||
$events = $token->pullDomainEvents();
|
||||
|
||||
self::assertCount(1, $events);
|
||||
self::assertInstanceOf(ActivationTokenGenerated::class, $events[0]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function tokenValueIsUuidV4Format(): void
|
||||
{
|
||||
$token = $this->createToken();
|
||||
|
||||
self::assertMatchesRegularExpression(
|
||||
'/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i',
|
||||
$token->tokenValue,
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function expiresAtIs7DaysAfterCreation(): void
|
||||
{
|
||||
$createdAt = new DateTimeImmutable('2026-01-15 10:00:00');
|
||||
$expectedExpiration = new DateTimeImmutable('2026-01-22 10:00:00');
|
||||
|
||||
$token = ActivationToken::generate(
|
||||
userId: self::USER_ID,
|
||||
email: self::EMAIL,
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
role: self::ROLE,
|
||||
schoolName: self::SCHOOL_NAME,
|
||||
createdAt: $createdAt,
|
||||
);
|
||||
|
||||
self::assertEquals($expectedExpiration, $token->expiresAt);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function isExpiredReturnsFalseWhenNotExpired(): void
|
||||
{
|
||||
$createdAt = new DateTimeImmutable('2026-01-15 10:00:00');
|
||||
$checkAt = new DateTimeImmutable('2026-01-20 10:00:00');
|
||||
|
||||
$token = ActivationToken::generate(
|
||||
userId: self::USER_ID,
|
||||
email: self::EMAIL,
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
role: self::ROLE,
|
||||
schoolName: self::SCHOOL_NAME,
|
||||
createdAt: $createdAt,
|
||||
);
|
||||
|
||||
self::assertFalse($token->isExpired($checkAt));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function isExpiredReturnsTrueWhenExpired(): void
|
||||
{
|
||||
$createdAt = new DateTimeImmutable('2026-01-15 10:00:00');
|
||||
$checkAt = new DateTimeImmutable('2026-01-25 10:00:00');
|
||||
|
||||
$token = ActivationToken::generate(
|
||||
userId: self::USER_ID,
|
||||
email: self::EMAIL,
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
role: self::ROLE,
|
||||
schoolName: self::SCHOOL_NAME,
|
||||
createdAt: $createdAt,
|
||||
);
|
||||
|
||||
self::assertTrue($token->isExpired($checkAt));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function isExpiredReturnsTrueAtExactExpirationMoment(): void
|
||||
{
|
||||
$createdAt = new DateTimeImmutable('2026-01-15 10:00:00');
|
||||
$checkAt = new DateTimeImmutable('2026-01-22 10:00:00');
|
||||
|
||||
$token = ActivationToken::generate(
|
||||
userId: self::USER_ID,
|
||||
email: self::EMAIL,
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
role: self::ROLE,
|
||||
schoolName: self::SCHOOL_NAME,
|
||||
createdAt: $createdAt,
|
||||
);
|
||||
|
||||
self::assertTrue($token->isExpired($checkAt));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function useMarksTokenAsUsed(): void
|
||||
{
|
||||
$token = $this->createToken();
|
||||
$usedAt = new DateTimeImmutable('2026-01-16 10:00:00');
|
||||
|
||||
$token->use($usedAt);
|
||||
|
||||
self::assertTrue($token->isUsed());
|
||||
self::assertEquals($usedAt, $token->usedAt);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function useRecordsActivationTokenUsedEvent(): void
|
||||
{
|
||||
$token = $this->createToken();
|
||||
$token->pullDomainEvents();
|
||||
|
||||
$usedAt = new DateTimeImmutable('2026-01-16 10:00:00');
|
||||
$token->use($usedAt);
|
||||
|
||||
$events = $token->pullDomainEvents();
|
||||
self::assertCount(1, $events);
|
||||
self::assertInstanceOf(ActivationTokenUsed::class, $events[0]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function useThrowsExceptionWhenTokenAlreadyUsed(): void
|
||||
{
|
||||
$token = $this->createToken();
|
||||
$firstUse = new DateTimeImmutable('2026-01-16 10:00:00');
|
||||
$token->use($firstUse);
|
||||
|
||||
$this->expectException(ActivationTokenAlreadyUsedException::class);
|
||||
|
||||
$secondUse = new DateTimeImmutable('2026-01-17 10:00:00');
|
||||
$token->use($secondUse);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function useThrowsExceptionWhenTokenExpired(): void
|
||||
{
|
||||
$createdAt = new DateTimeImmutable('2026-01-15 10:00:00');
|
||||
$usedAt = new DateTimeImmutable('2026-01-25 10:00:00');
|
||||
|
||||
$token = ActivationToken::generate(
|
||||
userId: self::USER_ID,
|
||||
email: self::EMAIL,
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
role: self::ROLE,
|
||||
schoolName: self::SCHOOL_NAME,
|
||||
createdAt: $createdAt,
|
||||
);
|
||||
|
||||
$this->expectException(ActivationTokenExpiredException::class);
|
||||
|
||||
$token->use($usedAt);
|
||||
}
|
||||
|
||||
private function createToken(): ActivationToken
|
||||
{
|
||||
return ActivationToken::generate(
|
||||
userId: self::USER_ID,
|
||||
email: self::EMAIL,
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
role: self::ROLE,
|
||||
schoolName: self::SCHOOL_NAME,
|
||||
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
|
||||
);
|
||||
}
|
||||
}
|
||||
220
backend/tests/Unit/Administration/Domain/Model/User/UserTest.php
Normal file
220
backend/tests/Unit/Administration/Domain/Model/User/UserTest.php
Normal file
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Domain\Model\User;
|
||||
|
||||
use App\Administration\Domain\Event\CompteActive;
|
||||
use App\Administration\Domain\Event\CompteCreated;
|
||||
use App\Administration\Domain\Exception\CompteNonActivableException;
|
||||
use App\Administration\Domain\Model\ConsentementParental\ConsentementParental;
|
||||
use App\Administration\Domain\Model\User\Email;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\StatutCompte;
|
||||
use App\Administration\Domain\Model\User\User;
|
||||
use App\Administration\Domain\Policy\ConsentementParentalPolicy;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class UserTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||
private const string SCHOOL_NAME = 'École Alpha';
|
||||
|
||||
private Clock $clock;
|
||||
private ConsentementParentalPolicy $consentementPolicy;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-01-31 10:00:00');
|
||||
}
|
||||
};
|
||||
|
||||
$this->consentementPolicy = new ConsentementParentalPolicy($this->clock);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function creerCreatesUserWithPendingStatus(): void
|
||||
{
|
||||
$user = $this->createUser();
|
||||
|
||||
self::assertSame(StatutCompte::EN_ATTENTE, $user->statut);
|
||||
self::assertNull($user->hashedPassword);
|
||||
self::assertNull($user->activatedAt);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function creerRecordsCompteCreatedEvent(): void
|
||||
{
|
||||
$user = $this->createUser();
|
||||
|
||||
$events = $user->pullDomainEvents();
|
||||
|
||||
self::assertCount(1, $events);
|
||||
self::assertInstanceOf(CompteCreated::class, $events[0]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function activerSetsPasswordAndChangesStatusToActive(): void
|
||||
{
|
||||
$user = $this->createUser();
|
||||
$hashedPassword = '$argon2id$hashed';
|
||||
$activatedAt = new DateTimeImmutable('2026-01-31 10:00:00');
|
||||
|
||||
$user->activer($hashedPassword, $activatedAt, $this->consentementPolicy);
|
||||
|
||||
self::assertSame(StatutCompte::ACTIF, $user->statut);
|
||||
self::assertSame($hashedPassword, $user->hashedPassword);
|
||||
self::assertEquals($activatedAt, $user->activatedAt);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function activerRecordsCompteActiveEvent(): void
|
||||
{
|
||||
$user = $this->createUser();
|
||||
$user->pullDomainEvents();
|
||||
|
||||
$user->activer('$argon2id$hashed', new DateTimeImmutable(), $this->consentementPolicy);
|
||||
|
||||
$events = $user->pullDomainEvents();
|
||||
self::assertCount(1, $events);
|
||||
self::assertInstanceOf(CompteActive::class, $events[0]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function activerThrowsWhenStatusIsNotPending(): void
|
||||
{
|
||||
$user = $this->createUser();
|
||||
$user->activer('$argon2id$hashed', new DateTimeImmutable(), $this->consentementPolicy);
|
||||
|
||||
$this->expectException(CompteNonActivableException::class);
|
||||
|
||||
$user->activer('$argon2id$another', new DateTimeImmutable(), $this->consentementPolicy);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function activerThrowsForMinorWithoutConsent(): void
|
||||
{
|
||||
// Créer un utilisateur mineur (14 ans)
|
||||
$user = User::creer(
|
||||
email: new Email('eleve@example.com'),
|
||||
role: Role::ELEVE,
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
schoolName: self::SCHOOL_NAME,
|
||||
dateNaissance: new DateTimeImmutable('2012-06-15'), // 13 ans
|
||||
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
|
||||
);
|
||||
|
||||
$this->expectException(CompteNonActivableException::class);
|
||||
$this->expectExceptionMessage('consentement parental manquant');
|
||||
|
||||
$user->activer('$argon2id$hashed', new DateTimeImmutable(), $this->consentementPolicy);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function activerSucceedsForMinorWithConsent(): void
|
||||
{
|
||||
// Créer un utilisateur mineur (14 ans)
|
||||
$user = User::creer(
|
||||
email: new Email('eleve@example.com'),
|
||||
role: Role::ELEVE,
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
schoolName: self::SCHOOL_NAME,
|
||||
dateNaissance: new DateTimeImmutable('2012-06-15'),
|
||||
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
|
||||
);
|
||||
|
||||
// Enregistrer le consentement parental
|
||||
$consentement = ConsentementParental::accorder(
|
||||
parentId: 'parent-uuid',
|
||||
eleveId: (string) $user->id,
|
||||
at: new DateTimeImmutable('2026-01-20 10:00:00'),
|
||||
ipAddress: '192.168.1.1',
|
||||
);
|
||||
$user->enregistrerConsentementParental($consentement);
|
||||
|
||||
// L'activation devrait maintenant réussir
|
||||
$user->activer('$argon2id$hashed', new DateTimeImmutable(), $this->consentementPolicy);
|
||||
|
||||
self::assertSame(StatutCompte::ACTIF, $user->statut);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function activerSucceedsForAdultWithoutConsent(): void
|
||||
{
|
||||
// Créer un utilisateur adulte (16 ans)
|
||||
$user = User::creer(
|
||||
email: new Email('eleve@example.com'),
|
||||
role: Role::ELEVE,
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
schoolName: self::SCHOOL_NAME,
|
||||
dateNaissance: new DateTimeImmutable('2010-01-01'), // 16 ans
|
||||
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
|
||||
);
|
||||
|
||||
// Pas de consentement nécessaire
|
||||
$user->activer('$argon2id$hashed', new DateTimeImmutable(), $this->consentementPolicy);
|
||||
|
||||
self::assertSame(StatutCompte::ACTIF, $user->statut);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function peutSeConnecterReturnsTrueOnlyWhenActive(): void
|
||||
{
|
||||
$user = $this->createUser();
|
||||
|
||||
self::assertFalse($user->peutSeConnecter());
|
||||
|
||||
$user->activer('$argon2id$hashed', new DateTimeImmutable(), $this->consentementPolicy);
|
||||
|
||||
self::assertTrue($user->peutSeConnecter());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function necessiteConsentementParentalReturnsTrueForMinor(): void
|
||||
{
|
||||
$user = User::creer(
|
||||
email: new Email('eleve@example.com'),
|
||||
role: Role::ELEVE,
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
schoolName: self::SCHOOL_NAME,
|
||||
dateNaissance: new DateTimeImmutable('2012-06-15'), // 13 ans
|
||||
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
|
||||
);
|
||||
|
||||
self::assertTrue($user->necessiteConsentementParental($this->consentementPolicy));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function necessiteConsentementParentalReturnsFalseForAdult(): void
|
||||
{
|
||||
$user = User::creer(
|
||||
email: new Email('parent@example.com'),
|
||||
role: Role::PARENT,
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
schoolName: self::SCHOOL_NAME,
|
||||
dateNaissance: null, // Parents n'ont pas de date de naissance enregistrée
|
||||
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
|
||||
);
|
||||
|
||||
self::assertFalse($user->necessiteConsentementParental($this->consentementPolicy));
|
||||
}
|
||||
|
||||
private function createUser(): User
|
||||
{
|
||||
return User::creer(
|
||||
email: new Email('user@example.com'),
|
||||
role: Role::PARENT,
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
schoolName: self::SCHOOL_NAME,
|
||||
dateNaissance: null,
|
||||
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Domain\Policy;
|
||||
|
||||
use App\Administration\Domain\Policy\ConsentementParentalPolicy;
|
||||
use App\Shared\Domain\Clock;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ConsentementParentalPolicyTest extends TestCase
|
||||
{
|
||||
private Clock $clock;
|
||||
private ConsentementParentalPolicy $policy;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-01-31 10:00:00');
|
||||
}
|
||||
};
|
||||
|
||||
$this->policy = new ConsentementParentalPolicy($this->clock);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function consentementRequisPourUtilisateurDe14Ans(): void
|
||||
{
|
||||
$dateNaissance = new DateTimeImmutable('2012-01-31');
|
||||
|
||||
self::assertTrue($this->policy->estRequis($dateNaissance));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function consentementRequisPourUtilisateurDe10Ans(): void
|
||||
{
|
||||
$dateNaissance = new DateTimeImmutable('2016-01-31');
|
||||
|
||||
self::assertTrue($this->policy->estRequis($dateNaissance));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function consentementNonRequisPourUtilisateurDe15Ans(): void
|
||||
{
|
||||
$dateNaissance = new DateTimeImmutable('2011-01-30');
|
||||
|
||||
self::assertFalse($this->policy->estRequis($dateNaissance));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function consentementNonRequisPourUtilisateurDe16Ans(): void
|
||||
{
|
||||
$dateNaissance = new DateTimeImmutable('2010-01-31');
|
||||
|
||||
self::assertFalse($this->policy->estRequis($dateNaissance));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function consentementNonRequisSiDateNaissanceNulle(): void
|
||||
{
|
||||
self::assertFalse($this->policy->estRequis(null));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('agesBordureProvider')]
|
||||
public function consentementRequisAuxAgesBordure(
|
||||
string $dateNaissance,
|
||||
bool $consentementRequis,
|
||||
string $description,
|
||||
): void {
|
||||
$result = $this->policy->estRequis(new DateTimeImmutable($dateNaissance));
|
||||
|
||||
self::assertSame($consentementRequis, $result, $description);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string, bool, string}>
|
||||
*/
|
||||
public static function agesBordureProvider(): iterable
|
||||
{
|
||||
// Current date is 2026-01-31
|
||||
yield '14 ans et 364 jours' => [
|
||||
'2011-02-01',
|
||||
true,
|
||||
'Un jour avant 15 ans → consentement requis',
|
||||
];
|
||||
|
||||
yield '15 ans exactement' => [
|
||||
'2011-01-31',
|
||||
false,
|
||||
'Le jour des 15 ans → consentement non requis',
|
||||
];
|
||||
|
||||
yield '15 ans et 1 jour' => [
|
||||
'2011-01-30',
|
||||
false,
|
||||
'Un jour après 15 ans → consentement non requis',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Administration\Application\Command\ActivateAccount\ActivateAccountHandler;
|
||||
use App\Administration\Application\Port\PasswordHasher;
|
||||
use App\Administration\Domain\Exception\UserNotFoundException;
|
||||
use App\Administration\Domain\Model\ActivationToken\ActivationToken;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Domain\Policy\ConsentementParentalPolicy;
|
||||
use App\Administration\Domain\Repository\UserRepository;
|
||||
use App\Administration\Infrastructure\Api\Processor\ActivateAccountProcessor;
|
||||
use App\Administration\Infrastructure\Api\Resource\ActivateAccountInput;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryActivationTokenRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
/**
|
||||
* Tests for ActivateAccountProcessor focusing on token consumption behavior.
|
||||
*
|
||||
* Key invariant: Failed activations must NOT consume the token, allowing retries.
|
||||
*/
|
||||
final class ActivateAccountProcessorTest extends TestCase
|
||||
{
|
||||
private const string USER_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||
private const string EMAIL = 'user@example.com';
|
||||
private const string ROLE = 'ROLE_PARENT';
|
||||
private const string SCHOOL_NAME = 'École Test';
|
||||
private const string PASSWORD = 'SecurePass123';
|
||||
private const string HASHED_PASSWORD = '$argon2id$hashed';
|
||||
|
||||
private InMemoryActivationTokenRepository $tokenRepository;
|
||||
private Clock $clock;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->tokenRepository = new InMemoryActivationTokenRepository();
|
||||
$this->clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-01-16 10:00:00');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function tokenRemainsValidWhenUserNotFound(): void
|
||||
{
|
||||
// Arrange: Create a valid token
|
||||
$token = $this->createAndSaveToken();
|
||||
$tokenValue = $token->tokenValue;
|
||||
|
||||
// Create processor with a UserRepository that throws UserNotFoundException
|
||||
$processor = $this->createProcessorWithMissingUser();
|
||||
|
||||
$input = new ActivateAccountInput();
|
||||
$input->tokenValue = $tokenValue;
|
||||
$input->password = self::PASSWORD;
|
||||
|
||||
// Act: Try to activate (should fail because user not found)
|
||||
try {
|
||||
$processor->process($input, new Post());
|
||||
self::fail('Expected NotFoundHttpException to be thrown');
|
||||
} catch (NotFoundHttpException $e) {
|
||||
self::assertSame('Utilisateur introuvable.', $e->getMessage());
|
||||
}
|
||||
|
||||
// Assert: Token should NOT be consumed - retry should be possible
|
||||
$tokenAfterFailure = $this->tokenRepository->findByTokenValue($tokenValue);
|
||||
self::assertNotNull($tokenAfterFailure, 'Token should still exist after failed activation');
|
||||
self::assertFalse($tokenAfterFailure->isUsed(), 'Token should NOT be marked as used after failed activation');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function tokenCanBeReusedAfterFailedActivation(): void
|
||||
{
|
||||
// Arrange: Create a valid token
|
||||
$token = $this->createAndSaveToken();
|
||||
$tokenValue = $token->tokenValue;
|
||||
|
||||
$processorWithMissingUser = $this->createProcessorWithMissingUser();
|
||||
|
||||
$input = new ActivateAccountInput();
|
||||
$input->tokenValue = $tokenValue;
|
||||
$input->password = self::PASSWORD;
|
||||
|
||||
// Act: First activation fails (user not found)
|
||||
try {
|
||||
$processorWithMissingUser->process($input, new Post());
|
||||
} catch (NotFoundHttpException) {
|
||||
// Expected
|
||||
}
|
||||
|
||||
// Assert: Can call handler again with same token (retry scenario)
|
||||
$handler = $this->createHandler();
|
||||
$result = ($handler)(new \App\Administration\Application\Command\ActivateAccount\ActivateAccountCommand(
|
||||
tokenValue: $tokenValue,
|
||||
password: self::PASSWORD,
|
||||
));
|
||||
|
||||
// Should succeed - token was not burned
|
||||
self::assertSame(self::USER_ID, $result->userId);
|
||||
}
|
||||
|
||||
private function createAndSaveToken(): ActivationToken
|
||||
{
|
||||
$token = ActivationToken::generate(
|
||||
userId: self::USER_ID,
|
||||
email: self::EMAIL,
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
role: self::ROLE,
|
||||
schoolName: self::SCHOOL_NAME,
|
||||
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
|
||||
);
|
||||
|
||||
$this->tokenRepository->save($token);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
private function createHandler(): ActivateAccountHandler
|
||||
{
|
||||
$passwordHasher = new class implements PasswordHasher {
|
||||
public function hash(string $plainPassword): string
|
||||
{
|
||||
return '$argon2id$hashed';
|
||||
}
|
||||
|
||||
public function verify(string $hashedPassword, string $plainPassword): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
return new ActivateAccountHandler(
|
||||
$this->tokenRepository,
|
||||
$passwordHasher,
|
||||
$this->clock,
|
||||
);
|
||||
}
|
||||
|
||||
private function createProcessorWithMissingUser(): ActivateAccountProcessor
|
||||
{
|
||||
$handler = $this->createHandler();
|
||||
|
||||
// UserRepository that always throws UserNotFoundException
|
||||
$userRepository = new class implements UserRepository {
|
||||
public function save(\App\Administration\Domain\Model\User\User $user): void
|
||||
{
|
||||
}
|
||||
|
||||
public function findById(UserId $id): ?\App\Administration\Domain\Model\User\User
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function findByEmail(\App\Administration\Domain\Model\User\Email $email): ?\App\Administration\Domain\Model\User\User
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function get(UserId $id): \App\Administration\Domain\Model\User\User
|
||||
{
|
||||
throw UserNotFoundException::withId($id);
|
||||
}
|
||||
};
|
||||
|
||||
$consentementPolicy = new ConsentementParentalPolicy($this->clock);
|
||||
|
||||
$eventBus = $this->createMock(MessageBusInterface::class);
|
||||
|
||||
return new ActivateAccountProcessor(
|
||||
$handler,
|
||||
$userRepository,
|
||||
$this->tokenRepository,
|
||||
$consentementPolicy,
|
||||
$this->clock,
|
||||
$eventBus,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Infrastructure\Persistence\Cache;
|
||||
|
||||
use App\Administration\Domain\Model\User\Email;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\User;
|
||||
use App\Administration\Infrastructure\Persistence\Cache\CacheUserRepository;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Cache\CacheItemInterface;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
|
||||
/**
|
||||
* Tests for CacheUserRepository.
|
||||
*
|
||||
* Key invariant: Users must not expire from cache (unlike activation tokens which have 7-day TTL).
|
||||
* This was a bug where users were stored in the activation_tokens.cache pool with TTL,
|
||||
* causing activated accounts to become inaccessible after 7 days.
|
||||
*/
|
||||
final class CacheUserRepositoryTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function userIsSavedWithoutExpiration(): void
|
||||
{
|
||||
// Arrange: Create a mock cache that tracks expiration settings
|
||||
$expirationSet = null;
|
||||
|
||||
$cacheItem = $this->createMock(CacheItemInterface::class);
|
||||
$cacheItem->method('set')->willReturnSelf();
|
||||
$cacheItem->method('expiresAfter')
|
||||
->willReturnCallback(static function ($ttl) use (&$expirationSet, $cacheItem) {
|
||||
$expirationSet = $ttl;
|
||||
|
||||
return $cacheItem;
|
||||
});
|
||||
|
||||
$cachePool = $this->createMock(CacheItemPoolInterface::class);
|
||||
$cachePool->method('getItem')->willReturn($cacheItem);
|
||||
$cachePool->method('save')->willReturn(true);
|
||||
|
||||
$repository = new CacheUserRepository($cachePool);
|
||||
|
||||
$user = User::creer(
|
||||
email: new Email('test@example.com'),
|
||||
role: Role::PARENT,
|
||||
tenantId: TenantId::fromString('550e8400-e29b-41d4-a716-446655440001'),
|
||||
schoolName: 'École Test',
|
||||
dateNaissance: null,
|
||||
createdAt: new DateTimeImmutable(),
|
||||
);
|
||||
|
||||
// Act
|
||||
$repository->save($user);
|
||||
|
||||
// Assert: No expiration should be set (expiresAfter should not be called with a TTL)
|
||||
// The users.cache pool is configured with default_lifetime: 0 (no expiration)
|
||||
// But CacheUserRepository should NOT explicitly set any TTL
|
||||
self::assertNull(
|
||||
$expirationSet,
|
||||
'User cache entries should not have explicit expiration set by the repository'
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function userCanBeRetrievedById(): void
|
||||
{
|
||||
// Arrange
|
||||
$userId = '550e8400-e29b-41d4-a716-446655440001';
|
||||
$email = 'test@example.com';
|
||||
$tenantId = '550e8400-e29b-41d4-a716-446655440002';
|
||||
|
||||
$userData = [
|
||||
'id' => $userId,
|
||||
'email' => $email,
|
||||
'role' => 'ROLE_PARENT',
|
||||
'tenant_id' => $tenantId,
|
||||
'school_name' => 'École Test',
|
||||
'statut' => 'pending',
|
||||
'hashed_password' => null,
|
||||
'date_naissance' => null,
|
||||
'created_at' => '2026-01-15T10:00:00+00:00',
|
||||
'activated_at' => null,
|
||||
'consentement_parental' => null,
|
||||
];
|
||||
|
||||
$cacheItem = $this->createMock(CacheItemInterface::class);
|
||||
$cacheItem->method('isHit')->willReturn(true);
|
||||
$cacheItem->method('get')->willReturn($userData);
|
||||
|
||||
$cachePool = $this->createMock(CacheItemPoolInterface::class);
|
||||
$cachePool->method('getItem')->willReturn($cacheItem);
|
||||
|
||||
$repository = new CacheUserRepository($cachePool);
|
||||
|
||||
// Act
|
||||
$user = $repository->findById(\App\Administration\Domain\Model\User\UserId::fromString($userId));
|
||||
|
||||
// Assert
|
||||
self::assertNotNull($user);
|
||||
self::assertSame($userId, (string) $user->id);
|
||||
self::assertSame($email, (string) $user->email);
|
||||
self::assertSame(Role::PARENT, $user->role);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function userCanBeRetrievedByEmail(): void
|
||||
{
|
||||
// Arrange
|
||||
$userId = '550e8400-e29b-41d4-a716-446655440001';
|
||||
$email = 'test@example.com';
|
||||
$tenantId = '550e8400-e29b-41d4-a716-446655440002';
|
||||
|
||||
$userData = [
|
||||
'id' => $userId,
|
||||
'email' => $email,
|
||||
'role' => 'ROLE_PARENT',
|
||||
'tenant_id' => $tenantId,
|
||||
'school_name' => 'École Test',
|
||||
'statut' => 'pending',
|
||||
'hashed_password' => null,
|
||||
'date_naissance' => null,
|
||||
'created_at' => '2026-01-15T10:00:00+00:00',
|
||||
'activated_at' => null,
|
||||
'consentement_parental' => null,
|
||||
];
|
||||
|
||||
$emailIndexItem = $this->createMock(CacheItemInterface::class);
|
||||
$emailIndexItem->method('isHit')->willReturn(true);
|
||||
$emailIndexItem->method('get')->willReturn($userId);
|
||||
|
||||
$userItem = $this->createMock(CacheItemInterface::class);
|
||||
$userItem->method('isHit')->willReturn(true);
|
||||
$userItem->method('get')->willReturn($userData);
|
||||
|
||||
$cachePool = $this->createMock(CacheItemPoolInterface::class);
|
||||
$cachePool->method('getItem')
|
||||
->willReturnCallback(static function ($key) use ($emailIndexItem, $userItem) {
|
||||
if (str_starts_with($key, 'user_email:')) {
|
||||
return $emailIndexItem;
|
||||
}
|
||||
|
||||
return $userItem;
|
||||
});
|
||||
|
||||
$repository = new CacheUserRepository($cachePool);
|
||||
|
||||
// Act
|
||||
$user = $repository->findByEmail(new Email($email));
|
||||
|
||||
// Assert
|
||||
self::assertNotNull($user);
|
||||
self::assertSame($userId, (string) $user->id);
|
||||
self::assertSame($email, (string) $user->email);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Infrastructure\Persistence\InMemory;
|
||||
|
||||
use App\Administration\Domain\Exception\ActivationTokenNotFoundException;
|
||||
use App\Administration\Domain\Model\ActivationToken\ActivationToken;
|
||||
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryActivationTokenRepository;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class InMemoryActivationTokenRepositoryTest extends TestCase
|
||||
{
|
||||
private InMemoryActivationTokenRepository $repository;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repository = new InMemoryActivationTokenRepository();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function saveAndFindByTokenValue(): void
|
||||
{
|
||||
$token = $this->createToken();
|
||||
|
||||
$this->repository->save($token);
|
||||
$found = $this->repository->findByTokenValue($token->tokenValue);
|
||||
|
||||
self::assertSame($token, $found);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function saveAndGetById(): void
|
||||
{
|
||||
$token = $this->createToken();
|
||||
|
||||
$this->repository->save($token);
|
||||
$found = $this->repository->get($token->id);
|
||||
|
||||
self::assertSame($token, $found);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function findByTokenValueReturnsNullWhenNotFound(): void
|
||||
{
|
||||
$result = $this->repository->findByTokenValue('non-existent-token');
|
||||
|
||||
self::assertNull($result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getThrowsExceptionWhenNotFound(): void
|
||||
{
|
||||
$this->expectException(ActivationTokenNotFoundException::class);
|
||||
|
||||
$this->repository->get(ActivationTokenId::generate());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function deleteRemovesToken(): void
|
||||
{
|
||||
$token = $this->createToken();
|
||||
$this->repository->save($token);
|
||||
|
||||
$this->repository->delete($token->id);
|
||||
|
||||
self::assertNull($this->repository->findByTokenValue($token->tokenValue));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function deleteRemovesTokenFromIdIndex(): void
|
||||
{
|
||||
$token = $this->createToken();
|
||||
$this->repository->save($token);
|
||||
|
||||
$this->repository->delete($token->id);
|
||||
|
||||
$this->expectException(ActivationTokenNotFoundException::class);
|
||||
$this->repository->get($token->id);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function deleteNonExistentTokenDoesNotThrow(): void
|
||||
{
|
||||
$this->repository->delete(ActivationTokenId::generate());
|
||||
|
||||
$this->addToAssertionCount(1); // No exception thrown
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function saveUpdatesExistingToken(): void
|
||||
{
|
||||
$token = $this->createToken();
|
||||
$this->repository->save($token);
|
||||
|
||||
// Modify the token (mark as used)
|
||||
$usedAt = new DateTimeImmutable('2026-01-16 10:00:00');
|
||||
$token->use($usedAt);
|
||||
$this->repository->save($token);
|
||||
|
||||
$found = $this->repository->findByTokenValue($token->tokenValue);
|
||||
|
||||
self::assertTrue($found?->isUsed());
|
||||
}
|
||||
|
||||
private function createToken(): ActivationToken
|
||||
{
|
||||
return ActivationToken::generate(
|
||||
userId: '550e8400-e29b-41d4-a716-446655440001',
|
||||
email: 'user@example.com',
|
||||
tenantId: TenantId::fromString('550e8400-e29b-41d4-a716-446655440002'),
|
||||
role: 'ROLE_PARENT',
|
||||
schoolName: 'École Alpha',
|
||||
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user