├── LICENSE ├── bin ├── console └── phpunit ├── composer.json ├── composer.lock ├── config ├── bootstrap.php ├── bundles.php ├── packages │ ├── api_platform.yaml │ ├── api_projections.yaml │ ├── cache.yaml │ ├── dev │ │ ├── debug.yaml │ │ ├── lexik_jwt_authentication.yaml │ │ ├── monolog.yaml │ │ ├── routing.yaml │ │ └── web_profiler.yaml │ ├── doctrine.yaml │ ├── doctrine_migrations.yaml │ ├── elasticsearch.yaml │ ├── framework.yaml │ ├── httplug.yaml │ ├── hwi_oauth.yaml │ ├── lexik_jwt_authentication.yaml │ ├── mailer.yaml │ ├── messenger.yaml │ ├── msgphp_eav.php │ ├── msgphp_user.php │ ├── nelmio_cors.yaml │ ├── prod │ │ ├── doctrine.yaml │ │ ├── monolog.yaml │ │ └── routing.yaml │ ├── ramsey_uuid_doctrine.yaml │ ├── routing.yaml │ ├── security.yaml │ ├── sensio_framework_extra.yaml │ ├── test │ │ ├── framework.yaml │ │ ├── monolog.yaml │ │ ├── routing.yaml │ │ ├── twig.yaml │ │ ├── validator.yaml │ │ └── web_profiler.yaml │ ├── translation.yaml │ ├── twig.yaml │ └── validator.yaml ├── routes.yaml ├── routes │ ├── annotations.yaml │ ├── api_platform.yaml │ ├── dev │ │ ├── framework.yaml │ │ └── web_profiler.yaml │ └── hwi_oauth_routing.yaml ├── services.yaml └── services_dev.yaml ├── phpunit.xml.dist ├── psalm.xml ├── public └── index.php ├── src ├── Api │ ├── DocumentIdentity.php │ ├── Endpoint │ │ ├── CreateUserEndpoint.php │ │ └── DeleteUserEndpoint.php │ ├── Projection │ │ └── UserProjection.php │ └── Serializer │ │ └── UserNormalizer.php ├── Console │ ├── ClassContextElementFactory.php │ └── Command │ │ ├── CreateOneTimeLoginTokenCommand.php │ │ └── InviteUserCommand.php ├── Controller │ ├── ConfirmEmailController.php │ ├── ConfirmRegistrationController.php │ ├── ForgotPasswordController.php │ ├── HomeController.php │ ├── LoginController.php │ ├── ProfileController.php │ ├── RegisterController.php │ └── ResetPasswordController.php ├── DataFixtures │ └── AppFixtures.php ├── Entity │ ├── Attribute.php │ ├── AttributeValue.php │ ├── OneTimeLoginToken.php │ ├── PremiumUser.php │ ├── Role.php │ ├── User.php │ ├── UserAttributeValue.php │ ├── UserEmail.php │ ├── UserInvitation.php │ ├── UserRole.php │ └── Username.php ├── EventSubscriber │ ├── EnableConfirmedUser.php │ ├── InvalidateUserInvitation.php │ ├── SendEmailConfirmationUrl.php │ ├── SendPasswordResetUrl.php │ ├── SendRegistrationConfirmationUrl.php │ └── SynchronizeApi.php ├── Form │ ├── AddEmailType.php │ ├── ChangePasswordType.php │ ├── ForgotPasswordType.php │ ├── LoginType.php │ ├── OneTimeLoginType.php │ ├── RegisterType.php │ └── ResetPasswordType.php ├── Http │ ├── Respond.php │ ├── RespondBadRequest.php │ ├── RespondEmpty.php │ ├── RespondFile.php │ ├── RespondJson.php │ ├── RespondNotFound.php │ ├── RespondRaw.php │ ├── RespondRawJson.php │ ├── RespondRedirect.php │ ├── RespondRouteRedirect.php │ ├── RespondStream.php │ ├── RespondTemplate.php │ └── Responder.php ├── Kernel.php └── Security │ ├── JwtTokenSubscriber.php │ ├── OauthUserProvider.php │ ├── OneTimeLoginAuthenticator.php │ ├── PasswordConfirmation.php │ ├── RoleProvider.php │ └── UserChecker.php ├── symfony.lock ├── templates ├── base.html.twig ├── email.html.twig ├── email.txt.twig ├── home.html.twig ├── partials │ ├── flash-messages.html.twig │ └── oauth │ │ └── login_entrypoint.html.twig ├── password_confirmation.html.twig └── user │ ├── email │ ├── confirm_email.html.twig │ ├── confirm_email.txt.twig │ ├── confirm_registration.html.twig │ ├── confirm_registration.txt.twig │ ├── invited.html.twig │ ├── invited.txt.twig │ ├── reset_password.html.twig │ └── reset_password.txt.twig │ ├── forgot_password.html.twig │ ├── login.html.twig │ ├── profile.html.twig │ ├── register.html.twig │ └── reset_password.html.twig ├── tests └── bootstrap.php └── translations └── messages+intl-icu.en.xlf /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Roland Franssen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | getParameterOption(['--env', '-e'], null, true)) { 23 | putenv('APP_ENV='.$_SERVER['APP_ENV'] = $_ENV['APP_ENV'] = $env); 24 | } 25 | 26 | if ($input->hasParameterOption('--no-debug', true)) { 27 | putenv('APP_DEBUG='.$_SERVER['APP_DEBUG'] = $_ENV['APP_DEBUG'] = '0'); 28 | } 29 | 30 | require dirname(__DIR__).'/config/bootstrap.php'; 31 | 32 | if ($_SERVER['APP_DEBUG']) { 33 | umask(0000); 34 | 35 | if (class_exists(Debug::class)) { 36 | Debug::enable(); 37 | } 38 | } 39 | 40 | $kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']); 41 | $application = new Application($kernel); 42 | $application->run($input); 43 | -------------------------------------------------------------------------------- /bin/phpunit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | =1.2) 11 | if (is_array($env = @include dirname(__DIR__).'/.env.local.php') && (!isset($env['APP_ENV']) || ($_SERVER['APP_ENV'] ?? $_ENV['APP_ENV'] ?? $env['APP_ENV']) === $env['APP_ENV'])) { 12 | foreach ($env as $k => $v) { 13 | $_ENV[$k] = $_ENV[$k] ?? (isset($_SERVER[$k]) && 0 !== strpos($k, 'HTTP_') ? $_SERVER[$k] : $v); 14 | } 15 | } elseif (!class_exists(Dotenv::class)) { 16 | throw new RuntimeException('Please run "composer require symfony/dotenv" to load the ".env" files configuring the application.'); 17 | } else { 18 | // load all the .env files 19 | (new Dotenv(false))->loadEnv(dirname(__DIR__).'/.env'); 20 | } 21 | 22 | $_SERVER += $_ENV; 23 | $_SERVER['APP_ENV'] = $_ENV['APP_ENV'] = ($_SERVER['APP_ENV'] ?? $_ENV['APP_ENV'] ?? null) ?: 'dev'; 24 | $_SERVER['APP_DEBUG'] = $_SERVER['APP_DEBUG'] ?? $_ENV['APP_DEBUG'] ?? 'prod' !== $_SERVER['APP_ENV']; 25 | $_SERVER['APP_DEBUG'] = $_ENV['APP_DEBUG'] = (int) $_SERVER['APP_DEBUG'] || filter_var($_SERVER['APP_DEBUG'], FILTER_VALIDATE_BOOLEAN) ? '1' : '0'; 26 | -------------------------------------------------------------------------------- /config/bundles.php: -------------------------------------------------------------------------------- 1 | ['all' => true], 7 | Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], 8 | Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], 9 | Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true], 10 | Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], 11 | ApiPlatform\Core\Bridge\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true], 12 | Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], 13 | HWI\Bundle\OAuthBundle\HWIOAuthBundle::class => ['all' => true], 14 | Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle::class => ['all' => true], 15 | MsgPhp\EavBundle\MsgPhpEavBundle::class => ['all' => true], 16 | MsgPhp\UserBundle\MsgPhpUserBundle::class => ['all' => true], 17 | Http\HttplugBundle\HttplugBundle::class => ['all' => true], 18 | Sensio\Bundle\FrameworkExtraBundle\SensioFrameworkExtraBundle::class => ['all' => true], 19 | Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], 20 | Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], 21 | Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], 22 | Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], 23 | Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true, 'test' => true], 24 | ]; 25 | -------------------------------------------------------------------------------- /config/packages/api_platform.yaml: -------------------------------------------------------------------------------- 1 | api_platform: 2 | title: MsgPHP demo API 3 | version: 1.0.0 4 | name_converter: api_name_converter 5 | path_segment_name_generator: api_platform.path_segment_name_generator.dash 6 | mapping: 7 | paths: ['%kernel.project_dir%/src/Api/Projection'] 8 | elasticsearch: 9 | hosts: ['%env(ELASTICSEARCH_HOST)%'] 10 | mapping: 11 | App\Api\Projection\UserProjection: { index: '%api_projections.index_prefix%user' } 12 | graphql: 13 | enabled: true 14 | graphiql: 15 | enabled: true 16 | http_cache: 17 | vary: [Accept, Authorization] 18 | patch_formats: 19 | json: ['application/merge-patch+json'] 20 | swagger: 21 | versions: [3] 22 | api_keys: 23 | - { name: Authorization, type: header } 24 | 25 | services: 26 | api_name_converter: 27 | class: Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter 28 | -------------------------------------------------------------------------------- /config/packages/api_projections.yaml: -------------------------------------------------------------------------------- 1 | parameters: 2 | api_projections.index_prefix: 'api_%kernel.environment%-' 3 | api_projections.class_lookup: 4 | App\Entity\User: user 5 | api_projections.mappings: 6 | user: 7 | id: keyword 8 | user_id: text 9 | email: text 10 | # https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-create-index.html#create-index-settings 11 | api_projections.settings: 12 | '*': {} 13 | user: {} 14 | 15 | services: 16 | _defaults: 17 | autowire: true 18 | autoconfigure: true 19 | public: false 20 | 21 | # Type resolver 22 | MsgPhp\Domain\Projection\ProjectionTypeResolver: 23 | arguments: 24 | '$classLookup': '%api_projections.class_lookup%' 25 | 26 | # Type registry 27 | MsgPhp\Domain\Infrastructure\Elasticsearch\ProjectionTypeRegistry: 28 | arguments: 29 | '$prefix': '%api_projections.index_prefix%' 30 | '$mappings': '%api_projections.mappings%' 31 | '$settings': '%api_projections.settings%' 32 | MsgPhp\Domain\Projection\ProjectionTypeRegistry: '@MsgPhp\Domain\Infrastructure\Elasticsearch\ProjectionTypeRegistry' 33 | 34 | # Document transformer 35 | MsgPhp\Domain\Infrastructure\Serializer\ProjectionDocumentTransformer: ~ 36 | 37 | # Document provider 38 | MsgPhp\Domain\Projection\ProjectionDocumentProvider: 39 | arguments: 40 | '$dataProviders': 41 | - ['@MsgPhp\User\Repository\UserRepository', findAll] 42 | '$transformer': '@MsgPhp\Domain\Infrastructure\Serializer\ProjectionDocumentTransformer' 43 | '$typeResolver': '@MsgPhp\Domain\Projection\ProjectionTypeResolver' 44 | 45 | # Repository 46 | MsgPhp\Domain\Infrastructure\Elasticsearch\ProjectionRepository: 47 | arguments: 48 | '$prefix': '%api_projections.index_prefix%' 49 | MsgPhp\Domain\Projection\ProjectionRepository: '@MsgPhp\Domain\Infrastructure\Elasticsearch\ProjectionRepository' 50 | 51 | # Synchronization 52 | MsgPhp\Domain\Projection\ProjectionSynchronization: 53 | arguments: 54 | '$documentProvider': '@MsgPhp\Domain\Projection\ProjectionDocumentProvider' 55 | 56 | # Console 57 | MsgPhp\Domain\Infrastructure\Console\Command\SynchronizeProjectionsCommand: ~ 58 | 59 | # Messenger 60 | MsgPhp\Domain\Projection\Command\Handler\DeleteProjectionHandler: 61 | tags: 62 | - { name: messenger.message_handler, bus: command_bus } 63 | MsgPhp\Domain\Projection\Command\Handler\SaveProjectionHandler: 64 | tags: 65 | - { name: messenger.message_handler, bus: command_bus } 66 | -------------------------------------------------------------------------------- /config/packages/cache.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | cache: 3 | # Unique name of your app: used to compute stable namespaces for cache keys. 4 | #prefix_seed: your_vendor_name/app_name 5 | 6 | # The "app" cache stores to the filesystem by default. 7 | # The data in this cache should persist between deploys. 8 | # Other options include: 9 | 10 | # Redis 11 | #app: cache.adapter.redis 12 | #default_redis_provider: redis://localhost 13 | 14 | # APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues) 15 | #app: cache.adapter.apcu 16 | 17 | # Namespaced pools use the above "app" backend by default 18 | #pools: 19 | #my.dedicated.cache: null 20 | -------------------------------------------------------------------------------- /config/packages/dev/debug.yaml: -------------------------------------------------------------------------------- 1 | debug: 2 | # Forwards VarDumper Data clones to a centralized server allowing to inspect dumps on CLI or in your browser. 3 | # See the "server:dump" command to start a new server. 4 | dump_destination: "tcp://%env(VAR_DUMPER_SERVER)%" 5 | -------------------------------------------------------------------------------- /config/packages/dev/lexik_jwt_authentication.yaml: -------------------------------------------------------------------------------- 1 | lexik_jwt_authentication: 2 | token_extractors: 3 | query_parameter: { name: jwt, enabled: true } 4 | -------------------------------------------------------------------------------- /config/packages/dev/monolog.yaml: -------------------------------------------------------------------------------- 1 | monolog: 2 | handlers: 3 | main: 4 | type: stream 5 | path: "%kernel.logs_dir%/%kernel.environment%.log" 6 | level: debug 7 | channels: ["!event"] 8 | # uncomment to get logging in your browser 9 | # you may have to allow bigger header sizes in your Web server configuration 10 | #firephp: 11 | # type: firephp 12 | # level: info 13 | #chromephp: 14 | # type: chromephp 15 | # level: info 16 | console: 17 | type: console 18 | process_psr_3_messages: false 19 | channels: ["!event", "!doctrine", "!console"] 20 | -------------------------------------------------------------------------------- /config/packages/dev/routing.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | router: 3 | strict_requirements: true 4 | -------------------------------------------------------------------------------- /config/packages/dev/web_profiler.yaml: -------------------------------------------------------------------------------- 1 | web_profiler: 2 | toolbar: true 3 | intercept_redirects: false 4 | 5 | framework: 6 | profiler: { only_exceptions: false } 7 | -------------------------------------------------------------------------------- /config/packages/doctrine.yaml: -------------------------------------------------------------------------------- 1 | parameters: 2 | msgphp.doctrine.mapping_config: 3 | key_max_length: 191 4 | 5 | doctrine: 6 | dbal: 7 | url: '%env(resolve:DATABASE_URL)%' 8 | 9 | # IMPORTANT: You MUST configure your server version, 10 | # either here or in the DATABASE_URL env var (see .env file) 11 | #server_version: '5.7' 12 | orm: 13 | auto_generate_proxy_classes: true 14 | naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware 15 | auto_mapping: true 16 | mappings: 17 | App: 18 | is_bundle: false 19 | type: annotation 20 | dir: '%kernel.project_dir%/src/Entity' 21 | prefix: 'App\Entity' 22 | alias: App 23 | -------------------------------------------------------------------------------- /config/packages/doctrine_migrations.yaml: -------------------------------------------------------------------------------- 1 | doctrine_migrations: 2 | dir_name: '%kernel.project_dir%/src/Migrations' 3 | # namespace is arbitrary but should be different from App\Migrations 4 | # as migrations classes should NOT be autoloaded 5 | namespace: DoctrineMigrations 6 | -------------------------------------------------------------------------------- /config/packages/elasticsearch.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | _defaults: 3 | public: false 4 | 5 | # Client 6 | Elasticsearch\ClientBuilder: 7 | factory: Elasticsearch\ClientBuilder::create 8 | calls: 9 | - [setHosts, [['%env(ELASTICSEARCH_HOST)%']]] 10 | 11 | Elasticsearch\Client: 12 | factory: ['@Elasticsearch\ClientBuilder', build] 13 | -------------------------------------------------------------------------------- /config/packages/framework.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | secret: '%env(APP_SECRET)%' 3 | #csrf_protection: true 4 | #http_method_override: true 5 | 6 | # Enables session support. Note that the session will ONLY be started if you read or write from it. 7 | # Remove or comment this section to explicitly disable session support. 8 | session: 9 | handler_id: null 10 | cookie_secure: auto 11 | cookie_samesite: lax 12 | 13 | #esi: true 14 | #fragments: true 15 | php_errors: 16 | log: true 17 | -------------------------------------------------------------------------------- /config/packages/httplug.yaml: -------------------------------------------------------------------------------- 1 | httplug: 2 | plugins: 3 | retry: 4 | retry: 1 5 | 6 | discovery: 7 | client: 'auto' 8 | 9 | clients: 10 | app: 11 | http_methods_client: true 12 | plugins: 13 | - 'httplug.plugin.content_length' 14 | - 'httplug.plugin.redirect' 15 | -------------------------------------------------------------------------------- /config/packages/hwi_oauth.yaml: -------------------------------------------------------------------------------- 1 | hwi_oauth: 2 | # list of names of the firewalls in which this bundle is active, this setting MUST be set 3 | firewall_names: [main] 4 | 5 | # https://github.com/hwi/HWIOAuthBundle/blob/master/Resources/doc/2-configuring_resource_owners.md 6 | resource_owners: 7 | facebook: 8 | type: facebook 9 | client_id: '%env(FB_ID)%' 10 | client_secret: '%env(FB_SECRET)%' 11 | scope: "email" 12 | options: 13 | display: popup 14 | csrf: true 15 | google: 16 | type: google 17 | client_id: '%env(GOOGLE_ID)%' 18 | client_secret: '%env(GOOGLE_SECRET)%' 19 | scope: email 20 | options: 21 | csrf: true 22 | -------------------------------------------------------------------------------- /config/packages/lexik_jwt_authentication.yaml: -------------------------------------------------------------------------------- 1 | lexik_jwt_authentication: 2 | secret_key: '%env(resolve:JWT_SECRET_KEY)%' 3 | public_key: '%env(resolve:JWT_PUBLIC_KEY)%' 4 | pass_phrase: '%env(JWT_PASSPHRASE)%' 5 | -------------------------------------------------------------------------------- /config/packages/mailer.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | mailer: 3 | dsn: '%env(MAILER_DSN)%' 4 | -------------------------------------------------------------------------------- /config/packages/messenger.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | messenger: 3 | # Uncomment this (and the failed transport below) to send failed messages to this transport for later handling. 4 | # failure_transport: failed 5 | 6 | transports: 7 | # https://symfony.com/doc/current/messenger.html#transport-configuration 8 | # async: '%env(MESSENGER_TRANSPORT_DSN)%' 9 | # failed: 'doctrine://default?queue_name=failed' 10 | # sync: 'sync://' 11 | 12 | routing: 13 | # Route your messages to the transports 14 | # 'App\Message\YourMessage': async 15 | 16 | default_bus: command_bus 17 | buses: 18 | command_bus: ~ 19 | event_bus: 20 | default_middleware: allow_no_handlers 21 | 22 | services: 23 | msgphp.messenger.command_bus: '@command_bus' 24 | msgphp.messenger.event_bus: '@event_bus' 25 | -------------------------------------------------------------------------------- /config/packages/msgphp_eav.php: -------------------------------------------------------------------------------- 1 | extension('msgphp_eav', [ 11 | 'class_mapping' => [ 12 | Attribute::class => \App\Entity\Attribute::class, 13 | AttributeValue::class => \App\Entity\AttributeValue::class, 14 | ], 15 | 'default_id_type' => 'uuid', 16 | ]); 17 | }; 18 | -------------------------------------------------------------------------------- /config/packages/msgphp_user.php: -------------------------------------------------------------------------------- 1 | extension('msgphp_user', [ 17 | 'class_mapping' => [ 18 | Role::class => \App\Entity\Role::class, 19 | User::class => \App\Entity\User::class, 20 | UserAttributeValue::class => \App\Entity\UserAttributeValue::class, 21 | UserEmail::class => \App\Entity\UserEmail::class, 22 | Username::class => \App\Entity\Username::class, 23 | UserRole::class => \App\Entity\UserRole::class, 24 | ], 25 | 'default_id_type' => 'uuid', 26 | 'username_lookup' => [ 27 | ['target' => \App\Entity\UserEmail::class, 'field' => 'email', 'mapped_by' => 'user'], 28 | ], 29 | 'role_providers' => [ 30 | 'default' => [RoleProvider::ROLE_USER], 31 | UserRoleProvider::class, 32 | RoleProvider::class, 33 | ], 34 | ]); 35 | }; 36 | -------------------------------------------------------------------------------- /config/packages/nelmio_cors.yaml: -------------------------------------------------------------------------------- 1 | nelmio_cors: 2 | defaults: 3 | origin_regex: true 4 | allow_origin: ['%env(CORS_ALLOW_ORIGIN)%'] 5 | allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE'] 6 | allow_headers: ['Content-Type', 'Authorization'] 7 | expose_headers: ['Link'] 8 | max_age: 3600 9 | paths: 10 | '^/': null 11 | -------------------------------------------------------------------------------- /config/packages/prod/doctrine.yaml: -------------------------------------------------------------------------------- 1 | doctrine: 2 | orm: 3 | auto_generate_proxy_classes: false 4 | metadata_cache_driver: 5 | type: pool 6 | pool: doctrine.system_cache_pool 7 | query_cache_driver: 8 | type: pool 9 | pool: doctrine.system_cache_pool 10 | result_cache_driver: 11 | type: pool 12 | pool: doctrine.result_cache_pool 13 | 14 | framework: 15 | cache: 16 | pools: 17 | doctrine.result_cache_pool: 18 | adapter: cache.app 19 | doctrine.system_cache_pool: 20 | adapter: cache.system 21 | -------------------------------------------------------------------------------- /config/packages/prod/monolog.yaml: -------------------------------------------------------------------------------- 1 | monolog: 2 | handlers: 3 | main: 4 | type: fingers_crossed 5 | action_level: error 6 | handler: nested 7 | excluded_http_codes: [404, 405] 8 | buffer_size: 50 # How many messages should be saved? Prevent memory leaks 9 | nested: 10 | type: stream 11 | path: "%kernel.logs_dir%/%kernel.environment%.log" 12 | level: debug 13 | console: 14 | type: console 15 | process_psr_3_messages: false 16 | channels: ["!event", "!doctrine"] 17 | deprecation: 18 | type: stream 19 | path: "%kernel.logs_dir%/%kernel.environment%.deprecations.log" 20 | deprecation_filter: 21 | type: filter 22 | handler: deprecation 23 | max_level: info 24 | channels: ["php"] 25 | -------------------------------------------------------------------------------- /config/packages/prod/routing.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | router: 3 | strict_requirements: null 4 | -------------------------------------------------------------------------------- /config/packages/ramsey_uuid_doctrine.yaml: -------------------------------------------------------------------------------- 1 | doctrine: 2 | dbal: 3 | types: 4 | uuid: 'Ramsey\Uuid\Doctrine\UuidType' 5 | -------------------------------------------------------------------------------- /config/packages/routing.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | router: 3 | utf8: true 4 | -------------------------------------------------------------------------------- /config/packages/security.yaml: -------------------------------------------------------------------------------- 1 | security: 2 | encoders: 3 | MsgPhp\User\Infrastructure\Security\UserIdentity: auto 4 | 5 | # https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers 6 | providers: 7 | msgphp_user: { id: MsgPhp\User\Infrastructure\Security\UserIdentityProvider } 8 | msgphp_user_jwt: { id: MsgPhp\User\Infrastructure\Security\Jwt\UserIdentityProvider } 9 | firewalls: 10 | api_login: 11 | pattern: ^/api/login 12 | anonymous: true 13 | stateless: true 14 | provider: msgphp_user_jwt 15 | json_login: 16 | check_path: api_login 17 | username_path: email 18 | password_path: password 19 | success_handler: lexik_jwt_authentication.handler.authentication_success 20 | failure_handler: lexik_jwt_authentication.handler.authentication_failure 21 | 22 | api: 23 | pattern: ^/api 24 | anonymous: true 25 | stateless: true 26 | provider: msgphp_user_jwt 27 | guard: 28 | authenticators: 29 | - lexik_jwt_authentication.jwt_token_authenticator 30 | 31 | dev: 32 | pattern: ^/(_(profiler|wdt)|css|images|js)/ 33 | security: false 34 | main: 35 | anonymous: lazy 36 | provider: msgphp_user 37 | user_checker: App\Security\UserChecker 38 | form_login: 39 | login_path: login 40 | check_path: login 41 | default_target_path: home 42 | username_parameter: email 43 | password_parameter: password 44 | remember_me: true 45 | remember_me: 46 | secret: '%kernel.secret%' 47 | remember_me_parameter: remember_me 48 | logout: 49 | path: logout 50 | switch_user: 51 | role: ROLE_ADMIN 52 | oauth: 53 | login_path: /login 54 | failure_path: /login 55 | resource_owners: 56 | google: /oauth/login-check/google 57 | facebook: /oauth/login-check/facebook 58 | oauth_user_provider: 59 | service: App\Security\OauthUserProvider 60 | guard: 61 | authenticators: 62 | - App\Security\OneTimeLoginAuthenticator 63 | 64 | # Easy way to control access for large sections of your site 65 | # Note: Only the *first* access control that matches will be used 66 | access_control: 67 | - { path: ^/profile, roles: ROLE_USER } 68 | -------------------------------------------------------------------------------- /config/packages/sensio_framework_extra.yaml: -------------------------------------------------------------------------------- 1 | sensio_framework_extra: 2 | router: 3 | annotations: false 4 | -------------------------------------------------------------------------------- /config/packages/test/framework.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | test: true 3 | session: 4 | storage_id: session.storage.mock_file 5 | -------------------------------------------------------------------------------- /config/packages/test/monolog.yaml: -------------------------------------------------------------------------------- 1 | monolog: 2 | handlers: 3 | main: 4 | type: fingers_crossed 5 | action_level: error 6 | handler: nested 7 | excluded_http_codes: [404, 405] 8 | channels: ["!event"] 9 | nested: 10 | type: stream 11 | path: "%kernel.logs_dir%/%kernel.environment%.log" 12 | level: debug 13 | -------------------------------------------------------------------------------- /config/packages/test/routing.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | router: 3 | strict_requirements: true 4 | -------------------------------------------------------------------------------- /config/packages/test/twig.yaml: -------------------------------------------------------------------------------- 1 | twig: 2 | strict_variables: true 3 | -------------------------------------------------------------------------------- /config/packages/test/validator.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | validation: 3 | not_compromised_password: false 4 | -------------------------------------------------------------------------------- /config/packages/test/web_profiler.yaml: -------------------------------------------------------------------------------- 1 | web_profiler: 2 | toolbar: false 3 | intercept_redirects: false 4 | 5 | framework: 6 | profiler: { collect: false } 7 | -------------------------------------------------------------------------------- /config/packages/translation.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | default_locale: en 3 | translator: 4 | default_path: '%kernel.project_dir%/translations' 5 | fallbacks: 6 | - en 7 | -------------------------------------------------------------------------------- /config/packages/twig.yaml: -------------------------------------------------------------------------------- 1 | twig: 2 | default_path: '%kernel.project_dir%/templates' 3 | -------------------------------------------------------------------------------- /config/packages/validator.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | validation: 3 | email_validation_mode: html5 4 | 5 | # Enables validator auto-mapping support. 6 | # For instance, basic validation constraints will be inferred from Doctrine's metadata. 7 | #auto_mapping: 8 | # App\Entity\: [] 9 | -------------------------------------------------------------------------------- /config/routes.yaml: -------------------------------------------------------------------------------- 1 | # virtual routes 2 | logout: 3 | path: /logout 4 | methods: GET 5 | 6 | api_login: 7 | path: /api/login 8 | methods: POST 9 | 10 | oauth_login_check: 11 | path: /oauth/login-check/{resource} 12 | -------------------------------------------------------------------------------- /config/routes/annotations.yaml: -------------------------------------------------------------------------------- 1 | controllers: 2 | resource: ../../src/Controller/ 3 | type: annotation 4 | 5 | kernel: 6 | resource: ../../src/Kernel.php 7 | type: annotation 8 | -------------------------------------------------------------------------------- /config/routes/api_platform.yaml: -------------------------------------------------------------------------------- 1 | api_platform: 2 | resource: . 3 | type: api_platform 4 | prefix: /api 5 | -------------------------------------------------------------------------------- /config/routes/dev/framework.yaml: -------------------------------------------------------------------------------- 1 | _errors: 2 | resource: '@FrameworkBundle/Resources/config/routing/errors.xml' 3 | prefix: /_error 4 | -------------------------------------------------------------------------------- /config/routes/dev/web_profiler.yaml: -------------------------------------------------------------------------------- 1 | web_profiler_wdt: 2 | resource: '@WebProfilerBundle/Resources/config/routing/wdt.xml' 3 | prefix: /_wdt 4 | 5 | web_profiler_profiler: 6 | resource: '@WebProfilerBundle/Resources/config/routing/profiler.xml' 7 | prefix: /_profiler 8 | -------------------------------------------------------------------------------- /config/routes/hwi_oauth_routing.yaml: -------------------------------------------------------------------------------- 1 | hwi_oauth_redirect: 2 | resource: "@HWIOAuthBundle/Resources/config/routing/redirect.xml" 3 | prefix: /oauth/connect 4 | 5 | #hwi_oauth_connect: 6 | # resource: "@HWIOAuthBundle/Resources/config/routing/connect.xml" 7 | # prefix: /oauth/connect 8 | 9 | #hwi_oauth_login: 10 | # resource: "@HWIOAuthBundle/Resources/config/routing/login.xml" 11 | # prefix: /oauth/login 12 | -------------------------------------------------------------------------------- /config/services.yaml: -------------------------------------------------------------------------------- 1 | # This file is the entry point to configure your own services. 2 | # Files in the packages/ subdirectory configure your dependencies. 3 | 4 | # Put parameters here that don't need to change on each machine where the app is deployed 5 | # https://symfony.com/doc/current/best_practices/configuration.html#application-related-configuration 6 | parameters: 7 | 8 | services: 9 | # default configuration for services in *this* file 10 | _defaults: 11 | autowire: true # Automatically injects dependencies in your services. 12 | autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. 13 | bind: 14 | 'string $secret': '%kernel.secret%' 15 | 'callable $documentTransformer': '@MsgPhp\Domain\Infrastructure\Serializer\ProjectionDocumentTransformer' 16 | 'callable $documentTypeResolver': '@MsgPhp\Domain\Projection\ProjectionTypeResolver' 17 | 18 | # makes classes in src/ available to be used as services 19 | # this creates a service per class whose id is the fully-qualified class name 20 | App\: 21 | resource: '../src/*' 22 | exclude: '../src/{DependencyInjection,Entity,Migrations,Tests,Kernel.php}' 23 | 24 | # controllers are imported separately to make sure services can be injected 25 | # as action arguments even if you don't extend any base controller class 26 | App\Controller\: 27 | resource: '../src/Controller' 28 | tags: ['controller.service_arguments'] 29 | 30 | # API 31 | App\Api\Endpoint\: 32 | resource: '../src/Api/Endpoint' 33 | tags: [controller.service_arguments] 34 | 35 | # Console 36 | App\Console\Command\: 37 | resource: '../src/Console/Command' 38 | MsgPhp\Domain\Infrastructure\Console\Context\ClassContextElementFactory: '@App\Console\ClassContextElementFactory' 39 | 40 | # Messenger 41 | App\EventSubscriber\: 42 | resource: '../src/EventSubscriber' 43 | 44 | # add more service definitions when explicit configuration is needed 45 | # please note that last definitions always *replace* previous ones 46 | -------------------------------------------------------------------------------- /config/services_dev.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | _defaults: 3 | autowire: true 4 | autoconfigure: true 5 | public: false 6 | 7 | App\DataFixtures\: 8 | resource: '../src/DataFixtures' 9 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | tests 21 | 22 | 23 | 24 | 25 | 26 | src 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | handle($request); 28 | $response->send(); 29 | $kernel->terminate($request, $response); 30 | -------------------------------------------------------------------------------- /src/Api/DocumentIdentity.php: -------------------------------------------------------------------------------- 1 | toString(); 21 | } 22 | if ('' === $value) { 23 | throw new \LogicException('A document identifier cannot be obtained from an empty identifier.'); 24 | } 25 | 26 | return ShortUuid::uuid5(self::ID_NS, sha1($value)); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Api/Endpoint/CreateUserEndpoint.php: -------------------------------------------------------------------------------- 1 | userId); 23 | $docId = DocumentIdentity::get($userId); 24 | $locationUrl = $urlGenerator->generate('api_users_get_item', ['id' => $docId], UrlGeneratorInterface::ABSOLUTE_URL); 25 | 26 | if (null === $data->password) { 27 | throw new BadRequestHttpException('Missing password field.'); 28 | } 29 | 30 | $bus->dispatch(new CreateUser([ 31 | 'id' => $userId, 32 | 'email' => $data->email, 33 | 'password' => $passwordHashing->encodePassword($data->password, null), 34 | ])); 35 | 36 | return new JsonResponse(['id' => $docId], JsonResponse::HTTP_CREATED, ['Location' => $locationUrl]); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Api/Endpoint/DeleteUserEndpoint.php: -------------------------------------------------------------------------------- 1 | dispatch(new DeleteUser(UserUuid::fromValue($data->userId))); 18 | 19 | return null; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Api/Projection/UserProjection.php: -------------------------------------------------------------------------------- 1 | getId(); 23 | 24 | return [ 25 | 'id' => DocumentIdentity::get($userId), 26 | 'user_id' => $userId->toString(), 27 | 'email' => $object->getEmail(), 28 | ]; 29 | } 30 | 31 | public function supportsNormalization($data, $format = null): bool 32 | { 33 | return $data instanceof User; 34 | } 35 | 36 | public function hasCacheableSupportsMethod(): bool 37 | { 38 | return true; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Console/ClassContextElementFactory.php: -------------------------------------------------------------------------------- 1 | passwordHashing = $passwordHashing; 20 | } 21 | 22 | public function getElement(string $class, string $method, string $argument): ContextElement 23 | { 24 | $element = new ContextElement(ucfirst((string) preg_replace(['/([A-Z]+)([A-Z][a-z])/', '/([a-z\d])([A-Z])/'], ['\\1 \\2', '\\1 \\2'], $argument))); 25 | 26 | switch ($argument) { 27 | case 'email': 28 | $element->label = 'E-mail'; 29 | 30 | break; 31 | case 'password': 32 | if (User::class === $class || EmailPassword::class === $class) { 33 | $element 34 | ->hide() 35 | ->generator(static function (): string { 36 | return bin2hex(random_bytes(8)); 37 | }) 38 | ->normalizer(function (string $value): string { 39 | return $this->passwordHashing->encodePassword($value, null); 40 | }) 41 | ; 42 | } 43 | 44 | break; 45 | } 46 | 47 | return $element; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Console/Command/CreateOneTimeLoginTokenCommand.php: -------------------------------------------------------------------------------- 1 | em = $em; 29 | $this->userRepository = $userRepository; 30 | } 31 | 32 | protected function configure(): void 33 | { 34 | $this 35 | ->setDescription('Create a one-time login token') 36 | ->addArgument('username', InputArgument::REQUIRED, 'A username to provide the token for') 37 | ->addOption('token', null, InputOption::VALUE_REQUIRED, 'A pre-defined token (will be generated otherwise)') 38 | ; 39 | } 40 | 41 | protected function execute(InputInterface $input, OutputInterface $output): int 42 | { 43 | $io = new SymfonyStyle($input, $output); 44 | $token = $input->getOption('token'); 45 | 46 | if (null !== $token) { 47 | if (!is_scalar($token)) { 48 | throw new \UnexpectedValueException('Unexpected token'); 49 | } 50 | 51 | $token = (string) $token; 52 | 53 | if (null !== $this->em->find(OneTimeLoginToken::class, $token)) { 54 | throw new \LogicException(sprintf('The token "%s" already exists.', $token)); 55 | } 56 | } 57 | 58 | $username = $input->getArgument('username'); 59 | $user = $this->userRepository->findByUsername($username); 60 | $oneTimeLoginToken = new OneTimeLoginToken($user, $token); 61 | 62 | $this->em->persist($oneTimeLoginToken); 63 | $this->em->flush(); 64 | 65 | /** @psalm-suppress UndefinedInterfaceMethod */ 66 | $io->success(sprintf('Created login token "%s" for user "%s".', $oneTimeLoginToken->getToken(), $oneTimeLoginToken->getUser()->getCredential()->getUsername())); 67 | 68 | return 0; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Console/Command/InviteUserCommand.php: -------------------------------------------------------------------------------- 1 | em = $em; 32 | $this->userRepository = $userRepository; 33 | $this->mailer = $mailer; 34 | } 35 | 36 | protected function configure(): void 37 | { 38 | $this 39 | ->setDescription('Invite a user for registration') 40 | ->addArgument('email', InputArgument::REQUIRED, 'An e-mail to provide the token for') 41 | ->addOption('token', null, InputOption::VALUE_REQUIRED, 'A pre-defined token (will be generated otherwise)') 42 | ->addOption('notify', null, InputOption::VALUE_NONE, 'Send notification to the invited e-mail') 43 | ; 44 | } 45 | 46 | protected function execute(InputInterface $input, OutputInterface $output): int 47 | { 48 | $io = new SymfonyStyle($input, $output); 49 | $token = $input->getOption('token'); 50 | 51 | if (null !== $token) { 52 | if (!is_scalar($token)) { 53 | throw new \UnexpectedValueException('Unexpected token'); 54 | } 55 | 56 | $token = (string) $token; 57 | 58 | if (null !== $this->em->find(UserInvitation::class, $token)) { 59 | throw new \LogicException(sprintf('The token "%s" already exists.', $token)); 60 | } 61 | } 62 | 63 | /** @psalm-suppress PossiblyInvalidCast */ 64 | $email = (string) $input->getArgument('email'); 65 | 66 | if ($this->userRepository->usernameExists($email) || null !== $this->em->getRepository(UserInvitation::class)->findOneBy(['email' => $email])) { 67 | throw new \LogicException(sprintf('The username "%s" already exists.', $email)); 68 | } 69 | 70 | $invitation = new UserInvitation($email, $token); 71 | 72 | $this->em->persist($invitation); 73 | $this->em->flush(); 74 | 75 | $io->success(sprintf('Created registration token "%s" for user "%s".', $invitation->getToken(), $invitation->getEmail())); 76 | 77 | if ($input->getOption('notify')) { 78 | $params = compact('invitation'); 79 | $message = (new TemplatedEmail()) 80 | ->from('webmaster@localhost') 81 | ->to($email) 82 | ->subject('You are invited to register at The App') 83 | ->textTemplate('user/email/invited.txt.twig') 84 | ->htmlTemplate('user/email/invited.html.twig') 85 | ->context($params) 86 | ; 87 | 88 | $this->mailer->send($message); 89 | $io->note('Notification sent'); 90 | } else { 91 | $io->note('No notification was sent'); 92 | } 93 | 94 | return 0; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Controller/ConfirmEmailController.php: -------------------------------------------------------------------------------- 1 | dispatch(new ConfirmUserEmail($userEmail->getEmail())); 33 | 34 | return $responder->respond((new RespondRouteRedirect('profile'))->withFlashes([ 35 | 'success' => sprintf('Hi %s, your e-mail is confirmed.', $user->getEmail()), 36 | ])); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Controller/ConfirmRegistrationController.php: -------------------------------------------------------------------------------- 1 | dispatch(new ConfirmUser($user->getId())); 30 | 31 | return $responder->respond((new RespondRouteRedirect('login'))->withFlashes([ 32 | 'success' => sprintf('Hi %s, your registration is confirmed. You can now login.', $user->getEmail()), 33 | ])); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Controller/ForgotPasswordController.php: -------------------------------------------------------------------------------- 1 | createNamed('', ForgotPasswordType::class); 31 | $form->handleRequest($request); 32 | 33 | if ($form->isSubmitted() && $form->isValid()) { 34 | $data = $form->getData(); 35 | 36 | if (isset($data['user'])) { 37 | /** @var User $user */ 38 | $user = $data['user']; 39 | $bus->dispatch(new RequestUserPassword($user->getId())); 40 | } 41 | 42 | return $responder->respond((new RespondRouteRedirect('home'))->withFlashes([ 43 | 'success' => sprintf('Hi %s, we\'ve send you a password reset link.', $data['email']), 44 | ])); 45 | } 46 | 47 | return $responder->respond(new RespondTemplate('user/forgot_password.html.twig', [ 48 | 'form' => $form->createView(), 49 | ])); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Controller/HomeController.php: -------------------------------------------------------------------------------- 1 | respond(new RespondTemplate('home.html.twig')); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Controller/LoginController.php: -------------------------------------------------------------------------------- 1 | createNamed('', LoginType::class, [ 30 | 'email' => $authenticationUtils->getLastUsername(), 31 | ]); 32 | 33 | // one-time login 34 | $oneTimeLoginForm = $formFactory->createNamed('', OneTimeLoginType::class); 35 | 36 | return $responder->respond(new RespondTemplate('user/login.html.twig', [ 37 | 'error' => $authenticationUtils->getLastAuthenticationError(), 38 | 'form' => $form->createView(), 39 | 'oneTimeLoginForm' => $oneTimeLoginForm->createView(), 40 | ])); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Controller/ProfileController.php: -------------------------------------------------------------------------------- 1 | query->getBoolean('generate-jwt')) { 51 | return $responder->respond((new RespondRouteRedirect('profile'))->withFlashes([ 52 | 'success' => sprintf('Generated JWT token: %s', $jwtTokenManager->create($securityUser)), 53 | ])); 54 | } 55 | 56 | // add email 57 | $emailForm = $formFactory->create(AddEmailType::class); 58 | $emailForm->handleRequest($request); 59 | 60 | if ($emailForm->isSubmitted() && $emailForm->isValid()) { 61 | $bus->dispatch(new AddUserEmail($user->getId(), $email = $emailForm->getData()['email'])); 62 | 63 | return $responder->respond((new RespondRouteRedirect('profile'))->withFlashes([ 64 | 'success' => sprintf('E-mail %s added. We\'ve send you a confirmation link.', $email), 65 | ])); 66 | } 67 | 68 | // mark primary email 69 | if ($primaryEmail = $request->query->get('primary-email')) { 70 | try { 71 | /** @var UserEmail $userEmail */ 72 | $userEmail = $user->getEmails()->get($primaryEmail); 73 | if (!$userEmail->isConfirmed()) { 74 | $userEmail = null; 75 | } 76 | } catch (UnknownCollectionElement $e) { 77 | $userEmail = null; 78 | } 79 | if (null === $userEmail) { 80 | return $responder->respond(new RespondNotFound()); 81 | } 82 | 83 | $confirmResponse = $passwordConfirmation->confirm($request); 84 | 85 | if (null !== $confirmResponse) { 86 | return $confirmResponse; 87 | } 88 | 89 | $currentEmail = $user->getEmail(); 90 | $bus->dispatch(new DeleteUserEmail($primaryEmail)); 91 | $bus->dispatch(new ChangeUserCredential($user->getId(), ['email' => $primaryEmail])); 92 | $bus->dispatch(new AddUserEmail($user->getId(), $currentEmail, ['confirm' => true])); 93 | 94 | return $responder->respond((new RespondRouteRedirect('profile'))->withFlashes([ 95 | 'success' => sprintf('E-mail %s marked primary.', $primaryEmail), 96 | ])); 97 | } 98 | 99 | // send email confirmation link 100 | if ($confirmEmail = $request->query->get('confirm-email')) { 101 | try { 102 | /** @var UserEmail $userEmail */ 103 | $userEmail = $user->getEmails()->get($confirmEmail); 104 | if ($userEmail->isConfirmed()) { 105 | $userEmail = null; 106 | } 107 | } catch (UnknownCollectionElement $e) { 108 | $userEmail = null; 109 | } 110 | if (null === $userEmail) { 111 | return $responder->respond(new RespondNotFound()); 112 | } 113 | 114 | $sendEmailConfirmationUrl->notify($userEmail); 115 | 116 | return $responder->respond((new RespondRouteRedirect('profile'))->withFlashes([ 117 | 'success' => 'We\'ve send you a e-mail confirmation link.', 118 | ])); 119 | } 120 | 121 | // delete email 122 | if ($deleteEmail = $request->query->get('delete-email')) { 123 | if (!$user->getEmails()->containsKey($deleteEmail)) { 124 | return $responder->respond(new RespondNotFound()); 125 | } 126 | 127 | $confirmResponse = $passwordConfirmation->confirm($request); 128 | 129 | if (null !== $confirmResponse) { 130 | return $confirmResponse; 131 | } 132 | 133 | $bus->dispatch(new DeleteUserEmail($deleteEmail)); 134 | 135 | return $responder->respond((new RespondRouteRedirect('profile'))->withFlashes([ 136 | 'success' => sprintf('E-mail %s deleted.', $deleteEmail), 137 | ])); 138 | } 139 | 140 | // change password 141 | $passwordForm = $formFactory->create(ChangePasswordType::class); 142 | $passwordForm->handleRequest($request); 143 | 144 | if ($passwordForm->isSubmitted() && $passwordForm->isValid()) { 145 | $bus->dispatch(new ChangeUserCredential($user->getId(), $passwordForm->getData())); 146 | 147 | return $responder->respond((new RespondRouteRedirect('profile'))->withFlashes([ 148 | 'success' => 'Your password is now changed.', 149 | ])); 150 | } 151 | 152 | // render view 153 | return $responder->respond(new RespondTemplate('user/profile.html.twig', [ 154 | 'email_form' => $emailForm->createView(), 155 | 'password_form' => $passwordForm->createView(), 156 | ])); 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/Controller/RegisterController.php: -------------------------------------------------------------------------------- 1 | query->get('token')) && null !== $invitation = $em->find(UserInvitation::class, $token)) { 34 | $data['email'] = $invitation->getEmail(); 35 | } 36 | 37 | $form = $formFactory->createNamed('', RegisterType::class, $data); 38 | $form->handleRequest($request); 39 | 40 | if ($form->isSubmitted() && $form->isValid()) { 41 | $data = $form->getData(); 42 | $data['invitation_token'] = $token; 43 | $bus->dispatch(new CreateUser($data)); 44 | 45 | return $responder->respond((new RespondRouteRedirect('home'))->withFlashes([ 46 | 'success' => sprintf('Hi %s, you\'re successfully registered. We\'ve send you a confirmation link.', $data['email']), 47 | ])); 48 | } 49 | 50 | return $responder->respond(new RespondTemplate('user/register.html.twig', [ 51 | 'form' => $form->createView(), 52 | ])); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Controller/ResetPasswordController.php: -------------------------------------------------------------------------------- 1 | createNamed('', ResetPasswordType::class); 36 | $form->handleRequest($request); 37 | 38 | if ($form->isSubmitted() && $form->isValid()) { 39 | $bus->dispatch(new ResetUserPassword($user->getId(), $form->getData()['password'])); 40 | 41 | return $responder->respond((new RespondRouteRedirect('home'))->withFlashes([ 42 | 'success' => sprintf('Hi %s, we\'ve reset your password.', $user->getEmail()), 43 | ])); 44 | } 45 | 46 | return $responder->respond(new RespondTemplate('user/reset_password.html.twig', [ 47 | 'form' => $form->createView(), 48 | ])); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/DataFixtures/AppFixtures.php: -------------------------------------------------------------------------------- 1 | passwordEncoder = $passwordEncoder; 32 | } 33 | 34 | public function load(ObjectManager $manager): void 35 | { 36 | // roles 37 | $manager->persist($adminRole = new Role(RoleProvider::ROLE_ADMIN)); 38 | 39 | // attributes 40 | $manager->persist($this->createAttribute(Attribute::GOOGLE_OAUTH_ID)); 41 | $manager->persist($this->createAttribute(Attribute::FACEBOOK_OAUTH_ID)); 42 | $manager->persist($boolAttr = $this->createAttribute()); 43 | $manager->persist($intAttr = $this->createAttribute()); 44 | $manager->persist($floatAttr = $this->createAttribute()); 45 | $manager->persist($stringAttr = $this->createAttribute()); 46 | $manager->persist($dateTimeAttr = $this->createAttribute()); 47 | 48 | // users 49 | $user = $this->createUser('user@domain.dev'); 50 | $user->enable(); 51 | $user->confirm(); 52 | $manager->persist($user); 53 | $manager->persist(new UserEmail($user, 'other@domain.dev')); 54 | $manager->persist(new UserEmail($user, 'secondary@domain.dev', true)); 55 | $manager->persist($this->createUserAttributeValue($user, $boolAttr, true)); 56 | $manager->persist($this->createUserAttributeValue($user, $boolAttr, false)); 57 | $manager->persist($this->createUserAttributeValue($user, $boolAttr, null)); 58 | $manager->persist($this->createUserAttributeValue($user, $intAttr, 123)); 59 | $manager->persist($this->createUserAttributeValue($user, $intAttr, -456)); 60 | $manager->persist($this->createUserAttributeValue($user, $floatAttr, 123.0123456789)); 61 | $manager->persist($this->createUserAttributeValue($user, $floatAttr, -0.123)); 62 | $manager->persist($this->createUserAttributeValue($user, $stringAttr, 'text')); 63 | $manager->persist($this->createUserAttributeValue($user, $dateTimeAttr, new \DateTimeImmutable())); 64 | 65 | $user = $this->createUser('user+disabled@domain.dev'); 66 | $manager->persist($user); 67 | 68 | $user = $this->createUser('user+admin@domain.dev'); 69 | $user->enable(); 70 | $user->confirm(); 71 | $manager->persist($user); 72 | $manager->persist(new UserRole($user, $adminRole)); 73 | 74 | $user = $this->createUser('user+admin+disabled@domain.dev'); 75 | $manager->persist($user); 76 | $manager->persist(new UserRole($user, $adminRole)); 77 | 78 | $premiumUser = $this->createUser('user+premium@domain.dev', true); 79 | $premiumUser->enable(); 80 | $premiumUser->confirm(); 81 | $manager->persist($premiumUser); 82 | 83 | $manager->flush(); 84 | } 85 | 86 | private function createAttribute($id = null): Attribute 87 | { 88 | return new Attribute(AttributeUuid::fromValue($id)); 89 | } 90 | 91 | private function createUser(string $email, bool $premium = false, string $password = self::PASSWORD): User 92 | { 93 | $password = $this->passwordEncoder->encodePassword($password, null); 94 | 95 | if ($premium) { 96 | return new PremiumUser(new UserUuid(), $email, $password); 97 | } 98 | 99 | return new User(new UserUuid(), $email, $password); 100 | } 101 | 102 | private function createUserAttributeValue(User $user, Attribute $attribute, $value): UserAttributeValue 103 | { 104 | return new UserAttributeValue($user, new AttributeValue(new AttributeValueUuid(), $attribute, $value)); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/Entity/Attribute.php: -------------------------------------------------------------------------------- 1 | id = $id; 27 | } 28 | 29 | public function getId(): AttributeId 30 | { 31 | return $this->id; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Entity/AttributeValue.php: -------------------------------------------------------------------------------- 1 | id = $id; 26 | } 27 | 28 | public function getId(): AttributeValueId 29 | { 30 | return $this->id; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Entity/OneTimeLoginToken.php: -------------------------------------------------------------------------------- 1 | user = $user; 28 | $this->token = $token ?? bin2hex(random_bytes(32)); 29 | $this->redirectUrl = $redirectUrl; 30 | } 31 | 32 | public function getToken(): string 33 | { 34 | return $this->token; 35 | } 36 | 37 | public function getRedirectUrl(): ?string 38 | { 39 | return $this->redirectUrl; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Entity/PremiumUser.php: -------------------------------------------------------------------------------- 1 | name = $name; 23 | } 24 | 25 | public function getName(): string 26 | { 27 | return $this->name; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Entity/User.php: -------------------------------------------------------------------------------- 1 | id = $id; 46 | $this->createdAt = new \DateTimeImmutable(); 47 | $this->credential = new EmailPassword($email, $password); 48 | $this->confirmationToken = bin2hex(random_bytes(32)); 49 | } 50 | 51 | public function getId(): UserId 52 | { 53 | return $this->id; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Entity/UserAttributeValue.php: -------------------------------------------------------------------------------- 1 | confirm(); 33 | } else { 34 | $this->confirmationToken = bin2hex(random_bytes(32)); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Entity/UserInvitation.php: -------------------------------------------------------------------------------- 1 | token = $token ?? bin2hex(random_bytes(32)); 25 | $this->email = $email; 26 | } 27 | 28 | public function getToken(): string 29 | { 30 | return $this->token; 31 | } 32 | 33 | public function getEmail(): string 34 | { 35 | return $this->email; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Entity/UserRole.php: -------------------------------------------------------------------------------- 1 | bus = $bus; 20 | } 21 | 22 | public function __invoke(UserConfirmed $event): void 23 | { 24 | /** @psalm-suppress ArgumentTypeCoercion */ 25 | $this->notify($event->user); 26 | } 27 | 28 | public function notify(User $user): void 29 | { 30 | if ($user->isEnabled()) { 31 | return; 32 | } 33 | 34 | $this->bus->dispatch(new EnableUser($user->getId())); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/EventSubscriber/InvalidateUserInvitation.php: -------------------------------------------------------------------------------- 1 | em = $em; 19 | } 20 | 21 | public function __invoke(UserCreated $event): void 22 | { 23 | if (!isset($event->context['invitation_token']) || null === $invitation = $this->em->find(UserInvitation::class, $event->context['invitation_token'])) { 24 | return; 25 | } 26 | 27 | $this->em->remove($invitation); 28 | $this->em->flush(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/EventSubscriber/SendEmailConfirmationUrl.php: -------------------------------------------------------------------------------- 1 | mailer = $mailer; 20 | } 21 | 22 | public function __invoke(UserEmailAdded $event): void 23 | { 24 | /** @psalm-suppress ArgumentTypeCoercion */ 25 | $this->notify($event->userEmail); 26 | } 27 | 28 | public function notify(UserEmail $userEmail): void 29 | { 30 | if ($userEmail->isConfirmed()) { 31 | return; 32 | } 33 | 34 | $params = ['userEmail' => $userEmail]; 35 | $message = (new TemplatedEmail()) 36 | ->from('webmaster@localhost') 37 | ->to($userEmail->getEmail()) 38 | ->subject('Confirm your e-mail at The App') 39 | ->textTemplate('user/email/confirm_email.txt.twig') 40 | ->htmlTemplate('user/email/confirm_email.html.twig') 41 | ->context($params) 42 | ; 43 | 44 | $this->mailer->send($message); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/EventSubscriber/SendPasswordResetUrl.php: -------------------------------------------------------------------------------- 1 | mailer = $mailer; 20 | } 21 | 22 | public function __invoke(UserPasswordRequested $event): void 23 | { 24 | /** @psalm-suppress ArgumentTypeCoercion */ 25 | $this->notify($event->user); 26 | } 27 | 28 | public function notify(User $user): void 29 | { 30 | if (null === $user->getPasswordResetToken()) { 31 | return; 32 | } 33 | 34 | $params = ['user' => $user]; 35 | $message = (new TemplatedEmail()) 36 | ->from('webmaster@localhost') 37 | ->to($user->getEmail()) 38 | ->subject('Reset your password at The App') 39 | ->textTemplate('user/email/reset_password.txt.twig') 40 | ->htmlTemplate('user/email/reset_password.html.twig') 41 | ->context($params) 42 | ; 43 | 44 | $this->mailer->send($message); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/EventSubscriber/SendRegistrationConfirmationUrl.php: -------------------------------------------------------------------------------- 1 | mailer = $mailer; 20 | } 21 | 22 | public function __invoke(UserCreated $event): void 23 | { 24 | /** @psalm-suppress ArgumentTypeCoercion */ 25 | $this->notify($event->user); 26 | } 27 | 28 | public function notify(User $user): void 29 | { 30 | if ($user->isConfirmed()) { 31 | return; 32 | } 33 | 34 | $params = ['user' => $user]; 35 | $message = (new TemplatedEmail()) 36 | ->from('webmaster@localhost') 37 | ->to($user->getEmail()) 38 | ->subject('Confirm your account at The App') 39 | ->textTemplate('user/email/confirm_registration.txt.twig') 40 | ->htmlTemplate('user/email/confirm_registration.html.twig') 41 | ->context($params) 42 | ; 43 | 44 | $this->mailer->send($message); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/EventSubscriber/SynchronizeApi.php: -------------------------------------------------------------------------------- 1 | bus = $bus; 26 | $this->documentTransformer = $documentTransformer; 27 | $this->documentTypeResolver = $documentTypeResolver; 28 | } 29 | 30 | public function __invoke($event): void 31 | { 32 | if ($event instanceof UserCreated || $event instanceof UserCredentialChanged) { 33 | $this->notifySave($event->user); 34 | 35 | return; 36 | } 37 | 38 | if ($event instanceof UserDeleted) { 39 | $this->notifyDelete($event->user, $event->user->getId()); 40 | 41 | return; 42 | } 43 | } 44 | 45 | public static function getHandledMessages(): iterable 46 | { 47 | return [ 48 | UserCreated::class, 49 | UserCredentialChanged::class, 50 | UserDeleted::class, 51 | ]; 52 | } 53 | 54 | private function notifySave(object $object): void 55 | { 56 | $this->bus->dispatch(new SaveProjection(($this->documentTypeResolver)($object), ($this->documentTransformer)($object))); 57 | } 58 | 59 | private function notifyDelete(object $object, DomainId $id): void 60 | { 61 | $this->bus->dispatch(new DeleteProjection(($this->documentTypeResolver)($object), DocumentIdentity::get($id))); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Form/AddEmailType.php: -------------------------------------------------------------------------------- 1 | add('email', EmailType::class, [ 19 | 'constraints' => [new NotBlank(), new Email(), new UniqueUsername()], 20 | ]); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Form/ChangePasswordType.php: -------------------------------------------------------------------------------- 1 | add('current', HashedPasswordType::class, [ 18 | 'password_options' => ['constraints' => new UserPassword()], 19 | 'mapped' => false, 20 | ]); 21 | $builder->add('password', HashedPasswordType::class, [ 22 | 'password_confirm' => true, 23 | 'password_options' => ['constraints' => new NotBlank()], 24 | ]); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Form/ForgotPasswordType.php: -------------------------------------------------------------------------------- 1 | add('email', EmailType::class, [ 18 | 'constraints' => [new NotBlank()], 19 | ]); 20 | } 21 | 22 | public function configureOptions(OptionsResolver $resolver): void 23 | { 24 | $resolver->setDefault('user_mapping', ['email' => 'user']); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Form/LoginType.php: -------------------------------------------------------------------------------- 1 | add('email', EmailType::class); 18 | $builder->add('password', PasswordType::class); 19 | $builder->add('remember_me', CheckboxType::class); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Form/OneTimeLoginType.php: -------------------------------------------------------------------------------- 1 | add('token', TextType::class, [ 19 | 'constraints' => new NotBlank(), 20 | ]); 21 | } 22 | 23 | public function configureOptions(OptionsResolver $resolver): void 24 | { 25 | $resolver->setDefaults([ 26 | 'method' => Request::METHOD_GET, 27 | 'csrf_protection' => false, 28 | ]); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Form/RegisterType.php: -------------------------------------------------------------------------------- 1 | add('email', EmailType::class, [ 20 | 'constraints' => [new NotBlank(), new Email(), new UniqueUsername()], 21 | ]); 22 | $builder->add('password', HashedPasswordType::class, [ 23 | 'password_confirm' => true, 24 | 'password_options' => ['constraints' => new NotBlank()], 25 | ]); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Form/ResetPasswordType.php: -------------------------------------------------------------------------------- 1 | add('password', HashedPasswordType::class, [ 17 | 'password_confirm' => true, 18 | 'password_options' => ['constraints' => new NotBlank()], 19 | ]); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Http/Respond.php: -------------------------------------------------------------------------------- 1 | status = $status; 17 | 18 | return $respond; 19 | } 20 | 21 | public function withHeaders(array $headers): self 22 | { 23 | $respond = clone $this; 24 | $respond->headers = $headers; 25 | 26 | return $respond; 27 | } 28 | 29 | public function withFlashes(array $flashes): self 30 | { 31 | $respond = clone $this; 32 | $respond->flashes = $flashes; 33 | 34 | return $respond; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Http/RespondBadRequest.php: -------------------------------------------------------------------------------- 1 | status = Response::HTTP_BAD_REQUEST; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Http/RespondEmpty.php: -------------------------------------------------------------------------------- 1 | status = Response::HTTP_NO_CONTENT; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Http/RespondFile.php: -------------------------------------------------------------------------------- 1 | file = $file; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Http/RespondJson.php: -------------------------------------------------------------------------------- 1 | data = $data; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Http/RespondNotFound.php: -------------------------------------------------------------------------------- 1 | status = Response::HTTP_NOT_FOUND; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Http/RespondRaw.php: -------------------------------------------------------------------------------- 1 | contents = $contents; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Http/RespondRawJson.php: -------------------------------------------------------------------------------- 1 | status = Response::HTTP_FOUND; 16 | $this->url = $url; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Http/RespondRouteRedirect.php: -------------------------------------------------------------------------------- 1 | status = Response::HTTP_FOUND; 19 | $this->name = $name; 20 | $this->parameters = $parameters; 21 | $this->referenceType = $referenceType; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Http/RespondStream.php: -------------------------------------------------------------------------------- 1 | callback = $callback; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Http/RespondTemplate.php: -------------------------------------------------------------------------------- 1 | name = $name; 15 | $this->context = $context; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Http/Responder.php: -------------------------------------------------------------------------------- 1 | urlGenerator = $urlGenerator; 27 | $this->twig = $twig; 28 | $this->flashBag = $flashBag; 29 | } 30 | 31 | public function respond(Respond $respond): Response 32 | { 33 | foreach ($respond->flashes as $type => $messages) { 34 | foreach ((array) $messages as $message) { 35 | $this->flashBag->add($type, $message); 36 | } 37 | } 38 | 39 | if ($respond instanceof RespondBadRequest) { 40 | throw new BadRequestHttpException(null, null, 0, $respond->headers); 41 | } 42 | 43 | if ($respond instanceof RespondNotFound) { 44 | throw new NotFoundHttpException(null, null, 0, $respond->headers); 45 | } 46 | 47 | if ($respond instanceof RespondTemplate) { 48 | return new Response($this->twig->render($respond->name, $respond->context), $respond->status, $respond->headers); 49 | } 50 | 51 | if ($respond instanceof RespondRouteRedirect) { 52 | return new RedirectResponse($this->urlGenerator->generate($respond->name, $respond->parameters, $respond->referenceType), $respond->status, $respond->headers); 53 | } 54 | 55 | if ($respond instanceof RespondRedirect) { 56 | return new RedirectResponse($respond->url, $respond->status, $respond->headers); 57 | } 58 | 59 | if ($respond instanceof RespondJson) { 60 | return new JsonResponse($respond->data, $respond->status, $respond->headers); 61 | } 62 | 63 | if ($respond instanceof RespondRawJson) { 64 | return JsonResponse::fromJsonString($respond->contents, $respond->status, $respond->headers); 65 | } 66 | 67 | if ($respond instanceof RespondFile) { 68 | return new BinaryFileResponse($respond->file, $respond->status, $respond->headers); 69 | } 70 | 71 | if ($respond instanceof RespondStream) { 72 | return new StreamedResponse($respond->callback, $respond->status, $respond->headers); 73 | } 74 | 75 | if ($respond instanceof RespondRaw) { 76 | return new Response($respond->contents, $respond->status, $respond->headers); 77 | } 78 | 79 | return new Response('', $respond->status, $respond->headers); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Kernel.php: -------------------------------------------------------------------------------- 1 | getProjectDir().'/config/bundles.php'; 23 | foreach ($contents as $class => $envs) { 24 | if ($envs[$this->environment] ?? $envs['all'] ?? false) { 25 | yield new $class(); 26 | } 27 | } 28 | } 29 | 30 | public function getProjectDir(): string 31 | { 32 | return \dirname(__DIR__); 33 | } 34 | 35 | protected function configureContainer(ContainerBuilder $container, LoaderInterface $loader): void 36 | { 37 | $container->addResource(new FileResource($this->getProjectDir().'/config/bundles.php')); 38 | $container->setParameter('container.dumper.inline_class_loader', \PHP_VERSION_ID < 70400 || $this->debug); 39 | $container->setParameter('container.dumper.inline_factories', true); 40 | $confDir = $this->getProjectDir().'/config'; 41 | 42 | $loader->load($confDir.'/{packages}/*'.self::CONFIG_EXTS, 'glob'); 43 | $loader->load($confDir.'/{packages}/'.$this->environment.'/*'.self::CONFIG_EXTS, 'glob'); 44 | $loader->load($confDir.'/{services}'.self::CONFIG_EXTS, 'glob'); 45 | $loader->load($confDir.'/{services}_'.$this->environment.self::CONFIG_EXTS, 'glob'); 46 | } 47 | 48 | protected function configureRoutes(RouteCollectionBuilder $routes): void 49 | { 50 | $confDir = $this->getProjectDir().'/config'; 51 | 52 | $routes->import($confDir.'/{routes}/'.$this->environment.'/*'.self::CONFIG_EXTS, '/', 'glob'); 53 | $routes->import($confDir.'/{routes}/*'.self::CONFIG_EXTS, '/', 'glob'); 54 | $routes->import($confDir.'/{routes}'.self::CONFIG_EXTS, '/', 'glob'); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Security/JwtTokenSubscriber.php: -------------------------------------------------------------------------------- 1 | urlGenerator = $urlGenerator; 22 | } 23 | 24 | public function handleSuccess(AuthenticationSuccessEvent $event): void 25 | { 26 | $docId = DocumentIdentity::get($event->getUser()->getUsername()); 27 | $locationUrl = $this->urlGenerator->generate('api_users_get_item', ['id' => $docId], UrlGeneratorInterface::ABSOLUTE_URL); 28 | 29 | $event->getResponse()->headers->set('Location', $locationUrl); 30 | } 31 | 32 | public function handleFailure(JWTFailureEventInterface $event): void 33 | { 34 | $exception = $event->getException(); 35 | 36 | throw new UnauthorizedHttpException('Bearer', $exception->getMessage(), $exception); 37 | } 38 | 39 | public static function getSubscribedEvents(): array 40 | { 41 | return [ 42 | Events::AUTHENTICATION_SUCCESS => 'handleSuccess', 43 | Events::JWT_EXPIRED => 'handleFailure', 44 | Events::JWT_INVALID => 'handleFailure', 45 | Events::JWT_NOT_FOUND => 'handleFailure', 46 | ]; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Security/OauthUserProvider.php: -------------------------------------------------------------------------------- 1 | userRepository = $userRepository; 34 | $this->userAttributeValueRepository = $userAttributeValueRepository; 35 | $this->userIdentityProvider = $userIdentityProvider; 36 | $this->bus = $bus; 37 | } 38 | 39 | public function loadUserByOAuthUserResponse(UserResponseInterface $response): UserInterface 40 | { 41 | $owner = $response->getResourceOwner()->getName(); 42 | $username = $response->getUsername(); 43 | 44 | if (!\defined($const = Attribute::class.'::'.strtoupper($owner).'_OAUTH_ID')) { 45 | throw new \LogicException(sprintf('Missing constant "%s" for OAuth resoure owner "%s"', $const, $owner)); 46 | } 47 | 48 | $attributeId = AttributeUuid::fromValue(\constant($const)); 49 | $userAttributeValues = $this->userAttributeValueRepository->findAllByAttributeIdAndValue($attributeId, $username); 50 | 51 | if ($userAttributeValues->isEmpty()) { 52 | if (null === $email = $response->getEmail()) { 53 | throw new CustomUserMessageAuthenticationException(sprintf('Oauth resource owner "%s" requires e-mail availability and appropriate read-privilege.', $owner)); 54 | } 55 | 56 | try { 57 | $user = $this->userRepository->findByUsername($email); 58 | $userId = $user->getId(); 59 | } catch (EntityNotFound $e) { 60 | $userId = new UserUuid(); 61 | // @todo validate username/email availability 62 | $this->bus->dispatch(new CreateUser([ 63 | 'id' => $userId, 64 | 'email' => $email, 65 | 'password' => bin2hex(random_bytes(32)), 66 | ])); 67 | $this->bus->dispatch(new ConfirmUser($userId)); 68 | 69 | $user = $this->userRepository->find($userId); 70 | } 71 | 72 | $this->bus->dispatch(new AddUserAttributeValue($userId, $attributeId, $username)); 73 | } else { 74 | /** @var UserAttributeValue $userAttributeValue */ 75 | $userAttributeValue = $userAttributeValues->first(); 76 | $user = $userAttributeValue->getUser(); 77 | } 78 | 79 | return $this->userIdentityProvider->fromUser($user); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Security/OneTimeLoginAuthenticator.php: -------------------------------------------------------------------------------- 1 | em = $em; 30 | $this->userIdentityProvider = $userIdentityProvider; 31 | $this->urlGenerator = $urlGenerator; 32 | } 33 | 34 | public function start(Request $request, ?AuthenticationException $authException = null): Response 35 | { 36 | return new RedirectResponse($this->urlGenerator->generate('login')); 37 | } 38 | 39 | public function supports(Request $request): bool 40 | { 41 | return 'login' === $request->attributes->get('_route') 42 | && $request->isMethod(Request::METHOD_GET) 43 | && $request->query->has('token'); 44 | } 45 | 46 | public function getCredentials(Request $request) 47 | { 48 | return $request->query->get('token'); 49 | } 50 | 51 | public function getUser($credentials, UserProviderInterface $userProvider): ?UserInterface 52 | { 53 | if (null === $oneTimeLoginToken = $this->getOneTimeLoginToken($credentials)) { 54 | return null; 55 | } 56 | 57 | return $this->userIdentityProvider->fromUser($oneTimeLoginToken->getUser()); 58 | } 59 | 60 | public function checkCredentials($credentials, UserInterface $identity): bool 61 | { 62 | if (!$identity instanceof UserIdentity) { 63 | return false; 64 | } 65 | 66 | if (null === $oneTimeLoginToken = $this->getOneTimeLoginToken($credentials)) { 67 | return false; 68 | } 69 | 70 | return $oneTimeLoginToken->getUserId()->equals($identity->getUserId()); 71 | } 72 | 73 | public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response 74 | { 75 | return new RedirectResponse($this->urlGenerator->generate('login')); 76 | } 77 | 78 | public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey): ?Response 79 | { 80 | if (null === $oneTimeLoginToken = $this->getOneTimeLoginTokenOnce($this->getCredentials($request))) { 81 | return null; 82 | } 83 | 84 | return new RedirectResponse($oneTimeLoginToken->getRedirectUrl() ?? $this->urlGenerator->generate('profile')); 85 | } 86 | 87 | public function supportsRememberMe(): bool 88 | { 89 | return false; 90 | } 91 | 92 | private function getOneTimeLoginToken(string $token): ?OneTimeLoginToken 93 | { 94 | /** @var null|OneTimeLoginToken */ 95 | return $this->em->find(OneTimeLoginToken::class, $token); 96 | } 97 | 98 | private function getOneTimeLoginTokenOnce(string $token): ?OneTimeLoginToken 99 | { 100 | if (null !== $token = $this->getOneTimeLoginToken($token)) { 101 | $this->em->remove($token); 102 | $this->em->flush(); 103 | } 104 | 105 | return $token; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/Security/PasswordConfirmation.php: -------------------------------------------------------------------------------- 1 | secret = $secret; 28 | $this->twig = $twig; 29 | $this->formFactory = $formFactory; 30 | $this->urlGenerator = $urlGenerator; 31 | } 32 | 33 | public function confirm(Request $request): ?Response 34 | { 35 | $session = $request->getSession(); 36 | 37 | /** @psalm-suppress DocblockTypeContradiction */ 38 | if (null === $session) { 39 | throw new \LogicException('Session not available.'); 40 | } 41 | 42 | $hash = md5(implode("\0", [ 43 | $this->secret, 44 | $request->getClientIp(), 45 | $request->getUriForPath($request->getRequestUri()), 46 | ])); 47 | 48 | if ($session->has($hash)) { 49 | if ($session->remove($hash) !== md5($hash.$this->secret)) { 50 | throw new BadRequestHttpException('Unable to confirm current request.'); 51 | } 52 | 53 | return null; 54 | } 55 | 56 | $referer = (null !== $route = $request->attributes->get('_route')) 57 | ? $this->urlGenerator->generate($route, $request->attributes->get('_route_params', []), UrlGeneratorInterface::ABSOLUTE_URL) 58 | : $request->headers->get('referer'); 59 | 60 | $form = $this->formFactory->createNamedBuilder($hash) 61 | ->add('password', HashedPasswordType::class, [ 62 | 'password_options' => ['constraints' => new UserPassword()], 63 | ]) 64 | ->add('referer', HiddenType::class, [ 65 | 'data' => $referer, 66 | ]) 67 | ->getForm() 68 | ; 69 | 70 | $form->handleRequest($request); 71 | 72 | if ($form->isSubmitted()) { 73 | if ($form->isValid()) { 74 | $session->set($hash, md5($hash.$this->secret)); 75 | 76 | return new RedirectResponse($request->getRequestUri()); 77 | } 78 | 79 | $referer = $form->get('referer')->getData(); 80 | } 81 | 82 | if (null === $referer) { 83 | throw new BadRequestHttpException('Unable to confirm current request.'); 84 | } 85 | 86 | return new Response($this->twig->render('password_confirmation.html.twig', [ 87 | 'form' => $form->createView(), 88 | 'cancelUrl' => $referer, 89 | ])); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Security/RoleProvider.php: -------------------------------------------------------------------------------- 1 | isEnabled() ? [self::ROLE_ENABLED_USER] : [self::ROLE_DISABLED_USER]; 28 | 29 | if ($user instanceof PremiumUser) { 30 | $roles[] = self::ROLE_PREMIUM_USER; 31 | } 32 | 33 | return $roles; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Security/UserChecker.php: -------------------------------------------------------------------------------- 1 | em = $em; 25 | $this->logger = $logger ?? new NullLogger(); 26 | } 27 | 28 | public function checkPreAuth(UserInterface $identity): void 29 | { 30 | if (!$identity instanceof UserIdentity) { 31 | return; 32 | } 33 | 34 | if (null === $user = $this->em->find(User::class, $userId = $identity->getUserId())) { 35 | throw new AuthenticationCredentialsNotFoundException('Bad credentials.'); 36 | } 37 | 38 | /** @var User $user */ 39 | if (!$user->isEnabled()) { 40 | $this->logger->info('Disabled user login attempt.', ['id' => $userId->toString(), 'email' => $user->getEmail()]); 41 | 42 | throw new DisabledException('Bad credentials.'); 43 | } 44 | } 45 | 46 | public function checkPostAuth(UserInterface $user): void 47 | { 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /symfony.lock: -------------------------------------------------------------------------------- 1 | { 2 | "api-platform/api-pack": { 3 | "version": "v1.2.1" 4 | }, 5 | "api-platform/core": { 6 | "version": "2.5", 7 | "recipe": { 8 | "repo": "github.com/symfony/recipes", 9 | "branch": "master", 10 | "version": "2.5", 11 | "ref": "a93061567140e386f107be75340ac2aee3f86cbf" 12 | }, 13 | "files": [ 14 | "config/packages/api_platform.yaml", 15 | "config/routes/api_platform.yaml", 16 | "src/Entity/.gitignore" 17 | ] 18 | }, 19 | "clue/stream-filter": { 20 | "version": "v1.4.1" 21 | }, 22 | "doctrine/annotations": { 23 | "version": "1.0", 24 | "recipe": { 25 | "repo": "github.com/symfony/recipes", 26 | "branch": "master", 27 | "version": "1.0", 28 | "ref": "a2759dd6123694c8d901d0ec80006e044c2e6457" 29 | }, 30 | "files": [ 31 | "config/routes/annotations.yaml" 32 | ] 33 | }, 34 | "doctrine/cache": { 35 | "version": "1.10.0" 36 | }, 37 | "doctrine/collections": { 38 | "version": "1.6.4" 39 | }, 40 | "doctrine/common": { 41 | "version": "2.12.0" 42 | }, 43 | "doctrine/data-fixtures": { 44 | "version": "1.4.2" 45 | }, 46 | "doctrine/dbal": { 47 | "version": "v2.10.1" 48 | }, 49 | "doctrine/doctrine-bundle": { 50 | "version": "2.0", 51 | "recipe": { 52 | "repo": "github.com/symfony/recipes", 53 | "branch": "master", 54 | "version": "2.0", 55 | "ref": "a9f2463b9f73efe74482f831f03a204a41328555" 56 | }, 57 | "files": [ 58 | "config/packages/doctrine.yaml", 59 | "config/packages/prod/doctrine.yaml", 60 | "src/Entity/.gitignore", 61 | "src/Repository/.gitignore" 62 | ] 63 | }, 64 | "doctrine/doctrine-fixtures-bundle": { 65 | "version": "3.0", 66 | "recipe": { 67 | "repo": "github.com/symfony/recipes", 68 | "branch": "master", 69 | "version": "3.0", 70 | "ref": "fc52d86631a6dfd9fdf3381d0b7e3df2069e51b3" 71 | }, 72 | "files": [ 73 | "src/DataFixtures/AppFixtures.php" 74 | ] 75 | }, 76 | "doctrine/doctrine-migrations-bundle": { 77 | "version": "1.2", 78 | "recipe": { 79 | "repo": "github.com/symfony/recipes", 80 | "branch": "master", 81 | "version": "1.2", 82 | "ref": "c1431086fec31f17fbcfe6d6d7e92059458facc1" 83 | }, 84 | "files": [ 85 | "config/packages/doctrine_migrations.yaml", 86 | "src/Migrations/.gitignore" 87 | ] 88 | }, 89 | "doctrine/event-manager": { 90 | "version": "1.1.0" 91 | }, 92 | "doctrine/inflector": { 93 | "version": "1.3.1" 94 | }, 95 | "doctrine/instantiator": { 96 | "version": "1.3.0" 97 | }, 98 | "doctrine/lexer": { 99 | "version": "1.2.0" 100 | }, 101 | "doctrine/migrations": { 102 | "version": "2.2.1" 103 | }, 104 | "doctrine/orm": { 105 | "version": "v2.7.1" 106 | }, 107 | "doctrine/persistence": { 108 | "version": "1.3.6" 109 | }, 110 | "doctrine/reflection": { 111 | "version": "v1.1.0" 112 | }, 113 | "easycorp/easy-log-handler": { 114 | "version": "1.0", 115 | "recipe": { 116 | "repo": "github.com/symfony/recipes", 117 | "branch": "master", 118 | "version": "1.0", 119 | "ref": "70062abc2cd58794d2a90274502f81b55cd9951b" 120 | }, 121 | "files": [ 122 | "config/packages/dev/easy_log_handler.yaml" 123 | ] 124 | }, 125 | "egulias/email-validator": { 126 | "version": "2.1.17" 127 | }, 128 | "elasticsearch/elasticsearch": { 129 | "version": "v7.6.1" 130 | }, 131 | "ezimuel/guzzlestreams": { 132 | "version": "3.0.1" 133 | }, 134 | "ezimuel/ringphp": { 135 | "version": "1.1.2" 136 | }, 137 | "fig/link-util": { 138 | "version": "1.1.0" 139 | }, 140 | "guzzlehttp/guzzle": { 141 | "version": "6.5.2" 142 | }, 143 | "guzzlehttp/promises": { 144 | "version": "v1.3.1" 145 | }, 146 | "guzzlehttp/psr7": { 147 | "version": "1.6.1" 148 | }, 149 | "hwi/oauth-bundle": { 150 | "version": "0.6", 151 | "recipe": { 152 | "repo": "github.com/symfony/recipes-contrib", 153 | "branch": "master", 154 | "version": "0.6", 155 | "ref": "20cacc9b2da49d96ea55c8a8dd31324c5be88bc9" 156 | }, 157 | "files": [ 158 | "config/packages/hwi_oauth.yaml", 159 | "config/routes/hwi_oauth_routing.yaml" 160 | ] 161 | }, 162 | "jdorn/sql-formatter": { 163 | "version": "v1.2.17" 164 | }, 165 | "laminas/laminas-code": { 166 | "version": "3.4.1" 167 | }, 168 | "laminas/laminas-eventmanager": { 169 | "version": "3.2.1" 170 | }, 171 | "laminas/laminas-zendframework-bridge": { 172 | "version": "1.0.1" 173 | }, 174 | "lcobucci/jwt": { 175 | "version": "3.3.1" 176 | }, 177 | "lexik/jwt-authentication-bundle": { 178 | "version": "2.5", 179 | "recipe": { 180 | "repo": "github.com/symfony/recipes", 181 | "branch": "master", 182 | "version": "2.5", 183 | "ref": "5b2157bcd5778166a5696e42f552ad36529a07a6" 184 | }, 185 | "files": [ 186 | "config/packages/lexik_jwt_authentication.yaml" 187 | ] 188 | }, 189 | "monolog/monolog": { 190 | "version": "2.0.2" 191 | }, 192 | "moontoast/math": { 193 | "version": "1.2.1" 194 | }, 195 | "msgphp/domain": { 196 | "version": "v0.15.0" 197 | }, 198 | "msgphp/eav": { 199 | "version": "v0.15.0" 200 | }, 201 | "msgphp/eav-bundle": { 202 | "version": "0.10", 203 | "recipe": { 204 | "repo": "github.com/symfony/recipes-contrib", 205 | "branch": "master", 206 | "version": "0.10", 207 | "ref": "9b2774120df769609554a5883df6c3a2224a9761" 208 | }, 209 | "files": [ 210 | "config/packages/msgphp_eav.php", 211 | "src/Entity/Attribute.php", 212 | "src/Entity/AttributeValue.php" 213 | ] 214 | }, 215 | "msgphp/user": { 216 | "version": "v0.15.0" 217 | }, 218 | "msgphp/user-bundle": { 219 | "version": "0.10", 220 | "recipe": { 221 | "repo": "github.com/symfony/recipes-contrib", 222 | "branch": "master", 223 | "version": "0.10", 224 | "ref": "4067ee796789a8828ec72f82c76508a76875bcf8" 225 | }, 226 | "files": [ 227 | "config/packages/msgphp_user.php", 228 | "src/Entity/User.php" 229 | ] 230 | }, 231 | "msgphp/user-eav": { 232 | "version": "v0.15.0" 233 | }, 234 | "namshi/jose": { 235 | "version": "7.2.3" 236 | }, 237 | "nelmio/cors-bundle": { 238 | "version": "1.5", 239 | "recipe": { 240 | "repo": "github.com/symfony/recipes", 241 | "branch": "master", 242 | "version": "1.5", 243 | "ref": "6388de23860284db9acce0a7a5d9d13153bcb571" 244 | }, 245 | "files": [ 246 | "config/packages/nelmio_cors.yaml" 247 | ] 248 | }, 249 | "nikic/php-parser": { 250 | "version": "v4.3.0" 251 | }, 252 | "ocramius/package-versions": { 253 | "version": "1.5.1" 254 | }, 255 | "ocramius/proxy-manager": { 256 | "version": "2.2.3" 257 | }, 258 | "pascaldevink/shortuuid": { 259 | "version": "2.2.0" 260 | }, 261 | "php": { 262 | "version": "7.3" 263 | }, 264 | "php-http/client-common": { 265 | "version": "2.1.0" 266 | }, 267 | "php-http/discovery": { 268 | "version": "1.7.4" 269 | }, 270 | "php-http/guzzle6-adapter": { 271 | "version": "v2.0.1" 272 | }, 273 | "php-http/httplug": { 274 | "version": "2.1.0" 275 | }, 276 | "php-http/httplug-bundle": { 277 | "version": "1.6", 278 | "recipe": { 279 | "repo": "github.com/symfony/recipes-contrib", 280 | "branch": "master", 281 | "version": "1.6", 282 | "ref": "87b3d491d593d2c6e3b90bf0689ac69a188bfa5f" 283 | }, 284 | "files": [ 285 | "config/packages/httplug.yaml" 286 | ] 287 | }, 288 | "php-http/logger-plugin": { 289 | "version": "1.1.0" 290 | }, 291 | "php-http/message": { 292 | "version": "1.8.0" 293 | }, 294 | "php-http/message-factory": { 295 | "version": "v1.0.2" 296 | }, 297 | "php-http/promise": { 298 | "version": "v1.0.0" 299 | }, 300 | "php-http/stopwatch-plugin": { 301 | "version": "1.3.0" 302 | }, 303 | "phpdocumentor/reflection-common": { 304 | "version": "2.0.0" 305 | }, 306 | "phpdocumentor/reflection-docblock": { 307 | "version": "4.3.4" 308 | }, 309 | "phpdocumentor/type-resolver": { 310 | "version": "1.1.0" 311 | }, 312 | "phpseclib/bcmath_compat": { 313 | "version": "1.0.4" 314 | }, 315 | "phpseclib/phpseclib": { 316 | "version": "2.0.25" 317 | }, 318 | "psr/cache": { 319 | "version": "1.0.1" 320 | }, 321 | "psr/container": { 322 | "version": "1.0.0" 323 | }, 324 | "psr/event-dispatcher": { 325 | "version": "1.0.0" 326 | }, 327 | "psr/http-client": { 328 | "version": "1.0.0" 329 | }, 330 | "psr/http-message": { 331 | "version": "1.0.1" 332 | }, 333 | "psr/link": { 334 | "version": "1.0.0" 335 | }, 336 | "psr/log": { 337 | "version": "1.1.2" 338 | }, 339 | "ralouphie/getallheaders": { 340 | "version": "3.0.3" 341 | }, 342 | "ramsey/uuid": { 343 | "version": "3.9.3" 344 | }, 345 | "ramsey/uuid-doctrine": { 346 | "version": "1.3", 347 | "recipe": { 348 | "repo": "github.com/symfony/recipes-contrib", 349 | "branch": "master", 350 | "version": "1.3", 351 | "ref": "471aed0fbf5620b8d7f92b7a5ebbbf6c0945c27a" 352 | }, 353 | "files": [ 354 | "config/packages/ramsey_uuid_doctrine.yaml" 355 | ] 356 | }, 357 | "react/promise": { 358 | "version": "v2.7.1" 359 | }, 360 | "sensio/framework-extra-bundle": { 361 | "version": "5.2", 362 | "recipe": { 363 | "repo": "github.com/symfony/recipes", 364 | "branch": "master", 365 | "version": "5.2", 366 | "ref": "fb7e19da7f013d0d422fa9bce16f5c510e27609b" 367 | }, 368 | "files": [ 369 | "config/packages/sensio_framework_extra.yaml" 370 | ] 371 | }, 372 | "symfony/asset": { 373 | "version": "v5.0.5" 374 | }, 375 | "symfony/browser-kit": { 376 | "version": "v5.0.5" 377 | }, 378 | "symfony/cache": { 379 | "version": "v5.0.5" 380 | }, 381 | "symfony/cache-contracts": { 382 | "version": "v2.0.1" 383 | }, 384 | "symfony/config": { 385 | "version": "v5.0.5" 386 | }, 387 | "symfony/console": { 388 | "version": "4.4", 389 | "recipe": { 390 | "repo": "github.com/symfony/recipes", 391 | "branch": "master", 392 | "version": "4.4", 393 | "ref": "ea8c0eda34fda57e7d5cd8cbd889e2a387e3472c" 394 | }, 395 | "files": [ 396 | "bin/console", 397 | "config/bootstrap.php" 398 | ] 399 | }, 400 | "symfony/css-selector": { 401 | "version": "v5.0.5" 402 | }, 403 | "symfony/debug-bundle": { 404 | "version": "4.1", 405 | "recipe": { 406 | "repo": "github.com/symfony/recipes", 407 | "branch": "master", 408 | "version": "4.1", 409 | "ref": "f8863cbad2f2e58c4b65fa1eac892ab189971bea" 410 | }, 411 | "files": [ 412 | "config/packages/dev/debug.yaml" 413 | ] 414 | }, 415 | "symfony/debug-pack": { 416 | "version": "v1.0.7" 417 | }, 418 | "symfony/dependency-injection": { 419 | "version": "v5.0.5" 420 | }, 421 | "symfony/doctrine-bridge": { 422 | "version": "v5.0.5" 423 | }, 424 | "symfony/dom-crawler": { 425 | "version": "v5.0.5" 426 | }, 427 | "symfony/dotenv": { 428 | "version": "v5.0.5" 429 | }, 430 | "symfony/error-handler": { 431 | "version": "v5.0.5" 432 | }, 433 | "symfony/event-dispatcher": { 434 | "version": "v5.0.5" 435 | }, 436 | "symfony/event-dispatcher-contracts": { 437 | "version": "v2.0.1" 438 | }, 439 | "symfony/expression-language": { 440 | "version": "v5.0.5" 441 | }, 442 | "symfony/filesystem": { 443 | "version": "v5.0.5" 444 | }, 445 | "symfony/finder": { 446 | "version": "v5.0.5" 447 | }, 448 | "symfony/flex": { 449 | "version": "1.0", 450 | "recipe": { 451 | "repo": "github.com/symfony/recipes", 452 | "branch": "master", 453 | "version": "1.0", 454 | "ref": "c0eeb50665f0f77226616b6038a9b06c03752d8e" 455 | }, 456 | "files": [ 457 | ".env" 458 | ] 459 | }, 460 | "symfony/form": { 461 | "version": "v5.0.5" 462 | }, 463 | "symfony/framework-bundle": { 464 | "version": "4.4", 465 | "recipe": { 466 | "repo": "github.com/symfony/recipes", 467 | "branch": "master", 468 | "version": "4.4", 469 | "ref": "23ecaccc551fe2f74baf613811ae529eb07762fa" 470 | }, 471 | "files": [ 472 | "config/bootstrap.php", 473 | "config/packages/cache.yaml", 474 | "config/packages/framework.yaml", 475 | "config/packages/test/framework.yaml", 476 | "config/routes/dev/framework.yaml", 477 | "config/services.yaml", 478 | "public/index.php", 479 | "src/Controller/.gitignore", 480 | "src/Kernel.php" 481 | ] 482 | }, 483 | "symfony/http-foundation": { 484 | "version": "v5.0.5" 485 | }, 486 | "symfony/http-kernel": { 487 | "version": "v5.0.5" 488 | }, 489 | "symfony/inflector": { 490 | "version": "v5.0.5" 491 | }, 492 | "symfony/intl": { 493 | "version": "v5.0.5" 494 | }, 495 | "symfony/mailer": { 496 | "version": "4.3", 497 | "recipe": { 498 | "repo": "github.com/symfony/recipes", 499 | "branch": "master", 500 | "version": "4.3", 501 | "ref": "15658c2a0176cda2e7dba66276a2030b52bd81b2" 502 | }, 503 | "files": [ 504 | "config/packages/mailer.yaml" 505 | ] 506 | }, 507 | "symfony/maker-bundle": { 508 | "version": "1.0", 509 | "recipe": { 510 | "repo": "github.com/symfony/recipes", 511 | "branch": "master", 512 | "version": "1.0", 513 | "ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f" 514 | } 515 | }, 516 | "symfony/messenger": { 517 | "version": "4.3", 518 | "recipe": { 519 | "repo": "github.com/symfony/recipes", 520 | "branch": "master", 521 | "version": "4.3", 522 | "ref": "8a2675c061737658bed85102e9241c752620e575" 523 | }, 524 | "files": [ 525 | "config/packages/messenger.yaml" 526 | ] 527 | }, 528 | "symfony/mime": { 529 | "version": "v5.0.5" 530 | }, 531 | "symfony/monolog-bridge": { 532 | "version": "v5.0.5" 533 | }, 534 | "symfony/monolog-bundle": { 535 | "version": "3.3", 536 | "recipe": { 537 | "repo": "github.com/symfony/recipes", 538 | "branch": "master", 539 | "version": "3.3", 540 | "ref": "877bdb4223245783d00ed1f7429aa7ebc606d914" 541 | }, 542 | "files": [ 543 | "config/packages/dev/monolog.yaml", 544 | "config/packages/prod/monolog.yaml", 545 | "config/packages/test/monolog.yaml" 546 | ] 547 | }, 548 | "symfony/options-resolver": { 549 | "version": "v5.0.5" 550 | }, 551 | "symfony/orm-pack": { 552 | "version": "v1.0.8" 553 | }, 554 | "symfony/phpunit-bridge": { 555 | "version": "4.3", 556 | "recipe": { 557 | "repo": "github.com/symfony/recipes", 558 | "branch": "master", 559 | "version": "4.3", 560 | "ref": "6d0e35f749d5f4bfe1f011762875275cd3f9874f" 561 | }, 562 | "files": [ 563 | ".env.test", 564 | "bin/phpunit", 565 | "phpunit.xml.dist", 566 | "tests/bootstrap.php" 567 | ] 568 | }, 569 | "symfony/polyfill-intl-icu": { 570 | "version": "v1.14.0" 571 | }, 572 | "symfony/polyfill-intl-idn": { 573 | "version": "v1.14.0" 574 | }, 575 | "symfony/polyfill-mbstring": { 576 | "version": "v1.14.0" 577 | }, 578 | "symfony/profiler-pack": { 579 | "version": "v1.0.4" 580 | }, 581 | "symfony/property-access": { 582 | "version": "v5.0.5" 583 | }, 584 | "symfony/property-info": { 585 | "version": "v5.0.5" 586 | }, 587 | "symfony/routing": { 588 | "version": "4.2", 589 | "recipe": { 590 | "repo": "github.com/symfony/recipes", 591 | "branch": "master", 592 | "version": "4.2", 593 | "ref": "683dcb08707ba8d41b7e34adb0344bfd68d248a7" 594 | }, 595 | "files": [ 596 | "config/packages/prod/routing.yaml", 597 | "config/packages/routing.yaml", 598 | "config/routes.yaml" 599 | ] 600 | }, 601 | "symfony/security-bundle": { 602 | "version": "4.4", 603 | "recipe": { 604 | "repo": "github.com/symfony/recipes", 605 | "branch": "master", 606 | "version": "4.4", 607 | "ref": "7b4408dc203049666fe23fabed23cbadc6d8440f" 608 | }, 609 | "files": [ 610 | "config/packages/security.yaml" 611 | ] 612 | }, 613 | "symfony/security-core": { 614 | "version": "v5.0.5" 615 | }, 616 | "symfony/security-csrf": { 617 | "version": "v5.0.5" 618 | }, 619 | "symfony/security-guard": { 620 | "version": "v5.0.5" 621 | }, 622 | "symfony/security-http": { 623 | "version": "v5.0.5" 624 | }, 625 | "symfony/serializer": { 626 | "version": "v5.0.5" 627 | }, 628 | "symfony/service-contracts": { 629 | "version": "v2.0.1" 630 | }, 631 | "symfony/stopwatch": { 632 | "version": "v5.0.5" 633 | }, 634 | "symfony/templating": { 635 | "version": "v5.0.5" 636 | }, 637 | "symfony/test-pack": { 638 | "version": "v1.0.6" 639 | }, 640 | "symfony/translation": { 641 | "version": "3.3", 642 | "recipe": { 643 | "repo": "github.com/symfony/recipes", 644 | "branch": "master", 645 | "version": "3.3", 646 | "ref": "2ad9d2545bce8ca1a863e50e92141f0b9d87ffcd" 647 | }, 648 | "files": [ 649 | "config/packages/translation.yaml", 650 | "translations/.gitignore" 651 | ] 652 | }, 653 | "symfony/translation-contracts": { 654 | "version": "v2.0.1" 655 | }, 656 | "symfony/twig-bridge": { 657 | "version": "v5.0.5" 658 | }, 659 | "symfony/twig-bundle": { 660 | "version": "5.0", 661 | "recipe": { 662 | "repo": "github.com/symfony/recipes", 663 | "branch": "master", 664 | "version": "5.0", 665 | "ref": "fab9149bbaa4d5eca054ed93f9e1b66cc500895d" 666 | }, 667 | "files": [ 668 | "config/packages/test/twig.yaml", 669 | "config/packages/twig.yaml", 670 | "templates/base.html.twig" 671 | ] 672 | }, 673 | "symfony/validator": { 674 | "version": "4.3", 675 | "recipe": { 676 | "repo": "github.com/symfony/recipes", 677 | "branch": "master", 678 | "version": "4.3", 679 | "ref": "d902da3e4952f18d3bf05aab29512eb61cabd869" 680 | }, 681 | "files": [ 682 | "config/packages/test/validator.yaml", 683 | "config/packages/validator.yaml" 684 | ] 685 | }, 686 | "symfony/var-dumper": { 687 | "version": "v5.0.5" 688 | }, 689 | "symfony/var-exporter": { 690 | "version": "v5.0.5" 691 | }, 692 | "symfony/web-link": { 693 | "version": "v5.0.5" 694 | }, 695 | "symfony/web-profiler-bundle": { 696 | "version": "3.3", 697 | "recipe": { 698 | "repo": "github.com/symfony/recipes", 699 | "branch": "master", 700 | "version": "3.3", 701 | "ref": "6bdfa1a95f6b2e677ab985cd1af2eae35d62e0f6" 702 | }, 703 | "files": [ 704 | "config/packages/dev/web_profiler.yaml", 705 | "config/packages/test/web_profiler.yaml", 706 | "config/routes/dev/web_profiler.yaml" 707 | ] 708 | }, 709 | "symfony/yaml": { 710 | "version": "v5.0.5" 711 | }, 712 | "twig/twig": { 713 | "version": "v3.0.3" 714 | }, 715 | "webmozart/assert": { 716 | "version": "1.7.0" 717 | }, 718 | "webonyx/graphql-php": { 719 | "version": "v0.13.8" 720 | }, 721 | "willdurand/negotiation": { 722 | "version": "v2.3.1" 723 | } 724 | } 725 | -------------------------------------------------------------------------------- /templates/base.html.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% block title %}Welcome!{% endblock %} 6 | {% block stylesheets %}{% endblock %} 7 | 8 | 9 | {% block header %} 10 |
11 | The App 12 | 33 |
34 | {% endblock %} 35 | {% block body %} 36 | {{ include('partials/flash-messages.html.twig') }} 37 | {% block main %}{% endblock %} 38 | {% endblock %} 39 | {% block javascripts %}{% endblock %} 40 | 41 | 42 | -------------------------------------------------------------------------------- /templates/email.html.twig: -------------------------------------------------------------------------------- 1 |

★ The App

2 | 3 | {% block body %}{% endblock %} 4 | 5 |

Cheers! The App,

6 | -------------------------------------------------------------------------------- /templates/email.txt.twig: -------------------------------------------------------------------------------- 1 | ★ The App 2 | ========= 3 | 4 | {% block body %}{% endblock %} 5 | 6 | -- 7 | 8 | Cheers! The App, 9 | -------------------------------------------------------------------------------- /templates/home.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block main %} 4 |

Hello 🌍

5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /templates/partials/flash-messages.html.twig: -------------------------------------------------------------------------------- 1 | {% if app.request.hasPreviousSession %} 2 | 9 | {% endif %} 10 | -------------------------------------------------------------------------------- /templates/partials/oauth/login_entrypoint.html.twig: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /templates/password_confirmation.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block main %} 4 |

Confirm your password to proceed.

5 | 6 | {{ form_start(form) }} 7 | {{ form_errors(form) }} 8 | {{ form_row(form.password.plain, {label: 'Password', translation_domain: false}) }} 9 | 10 |
11 | or Cancel 12 |
13 | {{ form_end(form) }} 14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /templates/user/email/confirm_email.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'email.html.twig' %} 2 | 3 | {% block body %} 4 | {% set url = url('confirm_email', {token: userEmail.confirmationToken}) %} 5 |

Confirm your e-mail at {{ url }}

6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /templates/user/email/confirm_email.txt.twig: -------------------------------------------------------------------------------- 1 | {% extends 'email.txt.twig' %} 2 | 3 | {% block body %} 4 | Confirm your e-mail at {{ url('confirm_email', {token: userEmail.confirmationToken}) }} 5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /templates/user/email/confirm_registration.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'email.html.twig' %} 2 | 3 | {% block body %} 4 | {% set url = url('confirm_registration', {token: user.confirmationToken}) %} 5 |

Confirm your account at {{ url }}

6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /templates/user/email/confirm_registration.txt.twig: -------------------------------------------------------------------------------- 1 | {% extends 'email.txt.twig' %} 2 | 3 | {% block body %} 4 | Confirm your account at {{ url('confirm_registration', {token: user.confirmationToken}) }} 5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /templates/user/email/invited.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'email.html.twig' %} 2 | 3 | {% block body %} 4 | {% set url = url('register', {token: invitation.token}) %} 5 |

Complete your registration at {{ url }}

6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /templates/user/email/invited.txt.twig: -------------------------------------------------------------------------------- 1 | {% extends 'email.txt.twig' %} 2 | 3 | {% block body %} 4 | Complete your registration at {{ url('register', {token: invitation.token}) }} 5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /templates/user/email/reset_password.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'email.html.twig' %} 2 | 3 | {% block body %} 4 | {% set url = url('reset_password', {token: user.passwordResetToken}) %} 5 |

Reset your password at {{ url }}

6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /templates/user/email/reset_password.txt.twig: -------------------------------------------------------------------------------- 1 | {% extends 'email.txt.twig' %} 2 | 3 | {% block body %} 4 | Reset your password at {{ url('reset_password', {token: user.passwordResetToken}) }} 5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /templates/user/forgot_password.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block main %} 4 |

Forgot Your Password?

5 | 6 | {{ form_start(form) }} 7 | {{ form_errors(form) }} 8 | {{ form_row(form.email, {label: 'E-mail', translation_domain: false}) }} 9 | 10 |
11 | 12 |
13 | {{ form_end(form) }} 14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /templates/user/login.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block main %} 4 |

Login

5 | 6 | {% if error %} 7 |

{{ error.messageKey|trans(error.messageData, 'security') }}

8 | {% endif %} 9 | 10 | {{ form_start(form) }} 11 | {{ form_errors(form) }} 12 | {{ form_row(form.email, {label: 'E-mail', translation_domain: false}) }} 13 | {{ form_row(form.password, {label: 'Password', translation_domain: false}) }} 14 | {{ form_row(form.remember_me, {label: 'Remember me', required: false, translation_domain: false}) }} 15 | 16 |
17 | 18 |

Forgot your password?

19 |
20 | {{ form_end(form) }} 21 | 22 |

Or Login Using:

23 | 24 | {% include 'partials/oauth/login_entrypoint.html.twig' %} 25 | 26 |

Got a One-Time-Login Token?

27 | 28 | {{ form_start(oneTimeLoginForm) }} 29 | {{ form_errors(oneTimeLoginForm) }} 30 | {{ form_row(oneTimeLoginForm.token, {label: 'Your token', translation_domain: false}) }} 31 | 32 |
33 | 34 |
35 | {{ form_end(oneTimeLoginForm) }} 36 | {% endblock %} 37 | -------------------------------------------------------------------------------- /templates/user/profile.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% set userId, user = msgphp_user.currentId, msgphp_user.current %} 4 | {% set oauth_resources = { 5 | (constant('App\\Entity\\Attribute::GOOGLE_OAUTH_ID')): 'Google', 6 | (constant('App\\Entity\\Attribute::FACEBOOK_OAUTH_ID')): 'Facebook' 7 | } %} 8 | 9 | {% block main %} 10 |

My Profile

11 | 12 |
13 | Information 14 | 38 |
39 | 40 |
41 | API 42 | 45 |
46 | 47 |
48 | E-mails 49 | 65 | 66 | {{ form_start(email_form) }} 67 | {{ form_errors(email_form) }} 68 | {{ form_row(email_form.email, {label: 'E-mail', translation_domain: false}) }} 69 | 70 |
71 | 72 |
73 | {{ form_end(email_form) }} 74 |
75 | 76 |
77 | Change Password 78 | {{ form_start(password_form) }} 79 | {{ form_errors(password_form) }} 80 | {{ form_row(password_form.current.plain, {label: 'Current password', translation_domain: false}) }} 81 | {{ form_row(password_form.password.plain, {label: 'New password', translation_domain: false}) }} 82 | {{ form_row(password_form.password.confirmation, {label: 'Confirm new password', translation_domain: false}) }} 83 | 84 |
85 | 86 |
87 | {{ form_end(password_form) }} 88 |
89 | {% endblock %} 90 | -------------------------------------------------------------------------------- /templates/user/register.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block main %} 4 |

Register

5 | 6 | {{ form_start(form) }} 7 | {{ form_errors(form) }} 8 | {{ form_row(form.email, {label: 'E-mail', translation_domain: false}) }} 9 | {{ form_row(form.password.plain, {label: 'Password', translation_domain: false}) }} 10 | {{ form_row(form.password.confirmation, {label: 'Confirm password', translation_domain: false}) }} 11 | 12 |
13 | 14 |
15 | {{ form_end(form) }} 16 | {% endblock %} 17 | -------------------------------------------------------------------------------- /templates/user/reset_password.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block main %} 4 |

Reset Your Password

5 | 6 | {{ form_start(form) }} 7 | {{ form_errors(form) }} 8 | {{ form_row(form.password.plain, {label: 'New password', translation_domain: false}) }} 9 | {{ form_row(form.password.confirmation, {label: 'Confirm new password', translation_domain: false}) }} 10 | 11 |
12 | 13 |
14 | {{ form_end(form) }} 15 | {% endblock %} 16 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | bootEnv(dirname(__DIR__).'/.env'); 13 | } 14 | -------------------------------------------------------------------------------- /translations/messages+intl-icu.en.xlf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | role.ROLE_USER 8 | Regular User 9 | 10 | 11 | role.ROLE_DISABLED_USER 12 | Inactive User 13 | 14 | 15 | role.ROLE_ENABLED_USER 16 | Active User 17 | 18 | 19 | role.ROLE_PREMIUM_USER 20 | Premium User 21 | 22 | 23 | role.ROLE_ADMIN 24 | Administrator 25 | 26 | 27 | 28 | 29 | oauth.resource_owner.facebook 30 | Facebook 31 | 32 | 33 | oauth.resource_owner.google 34 | Google 35 | 36 | 37 | 38 | 39 | --------------------------------------------------------------------------------