From a3f6800b0231f4d4f75166fce3d330a7c78074ef Mon Sep 17 00:00:00 2001 From: Chloe Casali Date: Wed, 12 Nov 2025 16:23:58 +0100 Subject: [PATCH 1/9] setup lexik jwt --- .env | 6 + .gitignore | 4 + composer.json | 1 + composer.lock | 186 +++++++++++++++++- config/bundles.php | 1 + config/packages/api_platform.yaml | 5 + config/packages/lexik_jwt_authentication.yaml | 4 + config/packages/security.yaml | 68 ++++--- config/routes.yaml | 4 + src/Entity/User.php | 8 +- symfony.lock | 12 ++ 11 files changed, 260 insertions(+), 39 deletions(-) create mode 100644 config/packages/lexik_jwt_authentication.yaml diff --git a/.env b/.env index 5aec523..7d6b6e9 100644 --- a/.env +++ b/.env @@ -46,3 +46,9 @@ MERCURE_PUBLIC_URL=https://localhost/.well-known/mercure # The secret used to sign the JWTs MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!" ###< symfony/mercure-bundle ### + +###> lexik/jwt-authentication-bundle ### +JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem +JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem +JWT_PASSPHRASE=48709c91aeb82f8ffc07caf4f46369ec8530ec435b380c661e66697ec3939507 +###< lexik/jwt-authentication-bundle ### diff --git a/.gitignore b/.gitignore index 700d10f..e15a338 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,7 @@ /phpunit.xml .phpunit.result.cache ###< phpunit/phpunit ### + +###> lexik/jwt-authentication-bundle ### +/config/jwt/*.pem +###< lexik/jwt-authentication-bundle ### diff --git a/composer.json b/composer.json index 1ae6368..d88914e 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,7 @@ "doctrine/doctrine-migrations-bundle": "^3.2", "doctrine/orm": "^3.0", "fakerphp/faker": "^1.24", + "lexik/jwt-authentication-bundle": "^3.1", "nelmio/cors-bundle": "^2.2", "phpstan/phpdoc-parser": "^2.0", "runtime/frankenphp-symfony": "^0.2", diff --git a/composer.lock b/composer.lock index fdf2d0b..87f7e9a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d075ba164fa3f9d9cdcfe349538a6c81", + "content-hash": "b2dd98f982584777008604e13bd8f314", "packages": [ { "name": "api-platform/doctrine-common", @@ -2474,6 +2474,70 @@ }, "time": "2024-11-21T13:46:39+00:00" }, + { + "name": "lcobucci/clock", + "version": "3.5.0", + "source": { + "type": "git", + "url": "https://github.com/lcobucci/clock.git", + "reference": "a3139d9e97d47826f27e6a17bb63f13621f86058" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lcobucci/clock/zipball/a3139d9e97d47826f27e6a17bb63f13621f86058", + "reference": "a3139d9e97d47826f27e6a17bb63f13621f86058", + "shasum": "" + }, + "require": { + "php": "~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/clock": "^1.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "infection/infection": "^0.31", + "lcobucci/coding-standard": "^11.2.0", + "phpstan/extension-installer": "^1.3.1", + "phpstan/phpstan": "^2.0.0", + "phpstan/phpstan-deprecation-rules": "^2.0.0", + "phpstan/phpstan-phpunit": "^2.0.0", + "phpstan/phpstan-strict-rules": "^2.0.0", + "phpunit/phpunit": "^12.0.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Lcobucci\\Clock\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Luís Cobucci", + "email": "lcobucci@gmail.com" + } + ], + "description": "Yet another clock abstraction", + "support": { + "issues": "https://github.com/lcobucci/clock/issues", + "source": "https://github.com/lcobucci/clock/tree/3.5.0" + }, + "funding": [ + { + "url": "https://github.com/lcobucci", + "type": "github" + }, + { + "url": "https://www.patreon.com/lcobucci", + "type": "patreon" + } + ], + "time": "2025-10-27T09:03:17+00:00" + }, { "name": "lcobucci/jwt", "version": "5.5.0", @@ -2547,6 +2611,122 @@ ], "time": "2025-01-26T21:29:45+00:00" }, + { + "name": "lexik/jwt-authentication-bundle", + "version": "v3.1.1", + "source": { + "type": "git", + "url": "https://github.com/lexik/LexikJWTAuthenticationBundle.git", + "reference": "ebe0e2c6a0ae17b4702feffc89e32e3aaba6cb61" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lexik/LexikJWTAuthenticationBundle/zipball/ebe0e2c6a0ae17b4702feffc89e32e3aaba6cb61", + "reference": "ebe0e2c6a0ae17b4702feffc89e32e3aaba6cb61", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "lcobucci/clock": "^3.0", + "lcobucci/jwt": "^5.0", + "php": ">=8.2", + "symfony/config": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/deprecation-contracts": "^2.4|^3.0", + "symfony/event-dispatcher": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/property-access": "^6.4|^7.0", + "symfony/security-bundle": "^6.4|^7.0", + "symfony/translation-contracts": "^1.0|^2.0|^3.0" + }, + "require-dev": { + "api-platform/core": "^3.0|^4.0", + "rector/rector": "^1.2", + "symfony/browser-kit": "^6.4|^7.0", + "symfony/console": "^6.4|^7.0", + "symfony/dom-crawler": "^6.4|^7.0", + "symfony/filesystem": "^6.4|^7.0", + "symfony/framework-bundle": "^6.4|^7.0", + "symfony/phpunit-bridge": "^6.4|^7.0", + "symfony/var-dumper": "^6.4|^7.0", + "symfony/yaml": "^6.4|^7.0" + }, + "suggest": { + "gesdinet/jwt-refresh-token-bundle": "Implements a refresh token system over Json Web Tokens in Symfony", + "spomky-labs/lexik-jose-bridge": "Provides a JWT Token encoder with encryption support" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Lexik\\Bundle\\JWTAuthenticationBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jeremy Barthe", + "email": "j.barthe@lexik.fr", + "homepage": "https://github.com/jeremyb" + }, + { + "name": "Nicolas Cabot", + "email": "n.cabot@lexik.fr", + "homepage": "https://github.com/slashfan" + }, + { + "name": "Cedric Girard", + "email": "c.girard@lexik.fr", + "homepage": "https://github.com/cedric-g" + }, + { + "name": "Dev Lexik", + "email": "dev@lexik.fr", + "homepage": "https://github.com/lexik" + }, + { + "name": "Robin Chalas", + "email": "robin.chalas@gmail.com", + "homepage": "https://github.com/chalasr" + }, + { + "name": "Lexik Community", + "homepage": "https://github.com/lexik/LexikJWTAuthenticationBundle/graphs/contributors" + } + ], + "description": "This bundle provides JWT authentication for your Symfony REST API", + "homepage": "https://github.com/lexik/LexikJWTAuthenticationBundle", + "keywords": [ + "Authentication", + "JWS", + "api", + "bundle", + "jwt", + "rest", + "symfony" + ], + "support": { + "issues": "https://github.com/lexik/LexikJWTAuthenticationBundle/issues", + "source": "https://github.com/lexik/LexikJWTAuthenticationBundle/tree/v3.1.1" + }, + "funding": [ + { + "url": "https://github.com/chalasr", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/lexik/jwt-authentication-bundle", + "type": "tidelift" + } + ], + "time": "2025-01-06T16:34:57+00:00" + }, { "name": "monolog/monolog", "version": "3.8.1", @@ -11922,7 +12102,7 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, "platform": { @@ -11930,6 +12110,6 @@ "ext-ctype": "*", "ext-iconv": "*" }, - "platform-dev": [], + "platform-dev": {}, "plugin-api-version": "2.6.0" } diff --git a/config/bundles.php b/config/bundles.php index 7c3f102..a8491d3 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -14,4 +14,5 @@ Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true, 'test' => true], Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], + Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle::class => ['all' => true], ]; diff --git a/config/packages/api_platform.yaml b/config/packages/api_platform.yaml index 0a25824..848df75 100644 --- a/config/packages/api_platform.yaml +++ b/config/packages/api_platform.yaml @@ -9,3 +9,8 @@ api_platform: stateless: true cache_headers: vary: ['Content-Type', 'Authorization', 'Origin'] + swagger: + api_keys: + JWT: + name: Authorization + type: header \ No newline at end of file diff --git a/config/packages/lexik_jwt_authentication.yaml b/config/packages/lexik_jwt_authentication.yaml new file mode 100644 index 0000000..edfb69d --- /dev/null +++ b/config/packages/lexik_jwt_authentication.yaml @@ -0,0 +1,4 @@ +lexik_jwt_authentication: + secret_key: '%env(resolve:JWT_SECRET_KEY)%' + public_key: '%env(resolve:JWT_PUBLIC_KEY)%' + pass_phrase: '%env(JWT_PASSPHRASE)%' diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 367af25..6b94497 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -1,39 +1,37 @@ security: - # https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords - password_hashers: - Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' - # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider - providers: - users_in_memory: { memory: null } - firewalls: - dev: - pattern: ^/(_(profiler|wdt)|css|images|js)/ - security: false - main: - lazy: true - provider: users_in_memory + # https://symfony.com/doc/current/security.html#c-hashing-passwords + password_hashers: + App\Entity\User: 'auto' - # activate different ways to authenticate - # https://symfony.com/doc/current/security.html#the-firewall + # https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers + providers: + # used to reload user from session & other features (e.g. switch_user) + users: + entity: + class: App\Entity\User + property: email + # mongodb: + # class: App\Document\User + # property: email - # https://symfony.com/doc/current/security/impersonating_user.html - # switch_user: true + firewalls: + dev: + pattern: ^/_(profiler|wdt) + security: false + main: + stateless: true + provider: users + json_login: + check_path: /auth # The name in routes.yaml is enough for mapping + username_path: email + password_path: password + success_handler: lexik_jwt_authentication.handler.authentication_success + failure_handler: lexik_jwt_authentication.handler.authentication_failure + jwt: ~ - # Easy way to control access for large sections of your site - # Note: Only the *first* access control that matches will be used - access_control: - # - { path: ^/admin, roles: ROLE_ADMIN } - # - { path: ^/profile, roles: ROLE_USER } - -when@test: - security: - password_hashers: - # By default, password hashers are resource intensive and take time. This is - # important to generate secure password hashes. In tests however, secure hashes - # are not important, waste resources and increase test times. The following - # reduces the work factor to the lowest possible values. - Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: - algorithm: auto - cost: 4 # Lowest possible value for bcrypt - time_cost: 3 # Lowest possible value for argon - memory_cost: 10 # Lowest possible value for argon + access_control: + - { path: ^/$, roles: PUBLIC_ACCESS } # Allows accessing the Swagger UI + - { path: ^/docs, roles: PUBLIC_ACCESS } # Allows accessing the Swagger UI docs + - { path: ^/contexts, roles: PUBLIC_ACCESS } # Allows accessing the Swagger UI contexts + - { path: ^/auth, roles: PUBLIC_ACCESS } + - { path: ^/, roles: IS_AUTHENTICATED_FULLY } \ No newline at end of file diff --git a/config/routes.yaml b/config/routes.yaml index 41ef814..226a110 100644 --- a/config/routes.yaml +++ b/config/routes.yaml @@ -3,3 +3,7 @@ controllers: path: ../src/Controller/ namespace: App\Controller type: attribute + +auth: + path: /auth + methods: ['POST'] \ No newline at end of file diff --git a/src/Entity/User.php b/src/Entity/User.php index cbca958..2d11dd7 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -8,13 +8,14 @@ use Symfony\Bridge\Doctrine\IdGenerator\UuidGenerator; use Symfony\Bridge\Doctrine\Types\UuidType; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; +use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Uid\Uuid; #[ApiResource] #[ORM\Entity(repositoryClass: UserRepository::class)] #[ORM\Table(name: '`user`')] #[ORM\UniqueConstraint(name: 'UNIQ_IDENTIFIER_EMAIL', fields: ['email'])] -class User implements PasswordAuthenticatedUserInterface +class User implements PasswordAuthenticatedUserInterface, UserInterface { #[ORM\Id] #[ORM\Column(type: UuidType::NAME, unique: true)] @@ -94,4 +95,9 @@ public function setFirstname(string $firstname): static return $this; } + + public function eraseCredentials(): void + { + // TODO: Implement eraseCredentials() method. + } } diff --git a/symfony.lock b/symfony.lock index 1200157..bc919ab 100644 --- a/symfony.lock +++ b/symfony.lock @@ -87,6 +87,18 @@ ".php-cs-fixer.dist.php" ] }, + "lexik/jwt-authentication-bundle": { + "version": "3.1", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "2.5", + "ref": "e9481b233a11ef7e15fe055a2b21fd3ac1aa2bb7" + }, + "files": [ + "config/packages/lexik_jwt_authentication.yaml" + ] + }, "nelmio/cors-bundle": { "version": "2.5", "recipe": { From a6a3ec9fd033346a71a247da236a19e834926dd5 Mon Sep 17 00:00:00 2001 From: Chloe Casali Date: Wed, 12 Nov 2025 17:04:23 +0100 Subject: [PATCH 2/9] implement refresh token + fix lenght on email and pwd attribute + migration --- composer.json | 5 +- composer.lock | 265 +++++++++--------- config/bundles.php | 2 + .../packages/gesdinet_jwt_refresh_token.yaml | 2 + config/packages/security.yaml | 23 +- config/routes.yaml | 7 +- ...12142306.php => Version20251112160004.php} | 7 +- src/Entity/RefreshToken.php | 12 + src/Entity/User.php | 26 +- symfony.lock | 9 + 10 files changed, 198 insertions(+), 160 deletions(-) create mode 100644 config/packages/gesdinet_jwt_refresh_token.yaml rename migrations/{Version20251112142306.php => Version20251112160004.php} (61%) create mode 100644 src/Entity/RefreshToken.php diff --git a/composer.json b/composer.json index d88914e..0d583e6 100644 --- a/composer.json +++ b/composer.json @@ -7,10 +7,11 @@ "ext-iconv": "*", "api-platform/doctrine-orm": "^4.1.0", "api-platform/symfony": "^4.1.0", - "doctrine/doctrine-bundle": "^2.7", + "doctrine/doctrine-bundle": "^2.18", "doctrine/doctrine-migrations-bundle": "^3.2", - "doctrine/orm": "^3.0", + "doctrine/orm": "^3.5", "fakerphp/faker": "^1.24", + "gesdinet/jwt-refresh-token-bundle": "^1.5", "lexik/jwt-authentication-bundle": "^3.1", "nelmio/cors-bundle": "^2.2", "phpstan/phpdoc-parser": "^2.0", diff --git a/composer.lock b/composer.lock index 87f7e9a..fdb50c8 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b2dd98f982584777008604e13bd8f314", + "content-hash": "5ddcdf83bb41d8f5e81fb767482c2eac", "packages": [ { "name": "api-platform/doctrine-common", @@ -1111,99 +1111,6 @@ }, "time": "2025-02-28T10:08:08+00:00" }, - { - "name": "doctrine/cache", - "version": "2.2.0", - "source": { - "type": "git", - "url": "https://github.com/doctrine/cache.git", - "reference": "1ca8f21980e770095a31456042471a57bc4c68fb" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/doctrine/cache/zipball/1ca8f21980e770095a31456042471a57bc4c68fb", - "reference": "1ca8f21980e770095a31456042471a57bc4c68fb", - "shasum": "" - }, - "require": { - "php": "~7.1 || ^8.0" - }, - "conflict": { - "doctrine/common": ">2.2,<2.4" - }, - "require-dev": { - "cache/integration-tests": "dev-master", - "doctrine/coding-standard": "^9", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", - "psr/cache": "^1.0 || ^2.0 || ^3.0", - "symfony/cache": "^4.4 || ^5.4 || ^6", - "symfony/var-exporter": "^4.4 || ^5.4 || ^6" - }, - "type": "library", - "autoload": { - "psr-4": { - "Doctrine\\Common\\Cache\\": "lib/Doctrine/Common/Cache" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Guilherme Blanco", - "email": "guilhermeblanco@gmail.com" - }, - { - "name": "Roman Borschel", - "email": "roman@code-factory.org" - }, - { - "name": "Benjamin Eberlei", - "email": "kontakt@beberlei.de" - }, - { - "name": "Jonathan Wage", - "email": "jonwage@gmail.com" - }, - { - "name": "Johannes Schmitt", - "email": "schmittjoh@gmail.com" - } - ], - "description": "PHP Doctrine Cache library is a popular cache implementation that supports many different drivers such as redis, memcache, apc, mongodb and others.", - "homepage": "https://www.doctrine-project.org/projects/cache.html", - "keywords": [ - "abstraction", - "apcu", - "cache", - "caching", - "couchdb", - "memcached", - "php", - "redis", - "xcache" - ], - "support": { - "issues": "https://github.com/doctrine/cache/issues", - "source": "https://github.com/doctrine/cache/tree/2.2.0" - }, - "funding": [ - { - "url": "https://www.doctrine-project.org/sponsorship.html", - "type": "custom" - }, - { - "url": "https://www.patreon.com/phpdoctrine", - "type": "patreon" - }, - { - "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fcache", - "type": "tidelift" - } - ], - "time": "2022-05-20T20:07:39+00:00" - }, { "name": "doctrine/collections", "version": "2.2.2", @@ -1534,62 +1441,63 @@ }, { "name": "doctrine/doctrine-bundle", - "version": "2.13.2", + "version": "2.18.1", "source": { "type": "git", "url": "https://github.com/doctrine/DoctrineBundle.git", - "reference": "2363c43d9815a11657e452625cd64172d5587486" + "reference": "b769877014de053da0e5cbbb63d0ea2f3b2fea76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/2363c43d9815a11657e452625cd64172d5587486", - "reference": "2363c43d9815a11657e452625cd64172d5587486", + "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/b769877014de053da0e5cbbb63d0ea2f3b2fea76", + "reference": "b769877014de053da0e5cbbb63d0ea2f3b2fea76", "shasum": "" }, "require": { - "doctrine/cache": "^1.11 || ^2.0", "doctrine/dbal": "^3.7.0 || ^4.0", - "doctrine/persistence": "^2.2 || ^3", + "doctrine/deprecations": "^1.0", + "doctrine/persistence": "^3.1 || ^4", "doctrine/sql-formatter": "^1.0.1", - "php": "^7.4 || ^8.0", - "symfony/cache": "^5.4 || ^6.0 || ^7.0", - "symfony/config": "^5.4 || ^6.0 || ^7.0", - "symfony/console": "^5.4 || ^6.0 || ^7.0", - "symfony/dependency-injection": "^5.4 || ^6.0 || ^7.0", - "symfony/deprecation-contracts": "^2.1 || ^3", - "symfony/doctrine-bridge": "^5.4.46 || ~6.3.12 || ^6.4.3 || ^7.0.3", - "symfony/framework-bundle": "^5.4 || ^6.0 || ^7.0", - "symfony/polyfill-php80": "^1.15", - "symfony/service-contracts": "^1.1.1 || ^2.0 || ^3" + "php": "^8.1", + "symfony/cache": "^6.4 || ^7.0", + "symfony/config": "^6.4 || ^7.0", + "symfony/console": "^6.4 || ^7.0", + "symfony/dependency-injection": "^6.4 || ^7.0", + "symfony/doctrine-bridge": "^6.4.3 || ^7.0.3", + "symfony/framework-bundle": "^6.4 || ^7.0", + "symfony/service-contracts": "^2.5 || ^3" }, "conflict": { "doctrine/annotations": ">=3.0", + "doctrine/cache": "< 1.11", "doctrine/orm": "<2.17 || >=4.0", - "twig/twig": "<1.34 || >=2.0 <2.4" + "symfony/var-exporter": "< 6.4.1 || 7.0.0", + "twig/twig": "<2.13 || >=3.0 <3.0.4" }, "require-dev": { "doctrine/annotations": "^1 || ^2", - "doctrine/coding-standard": "^12", - "doctrine/deprecations": "^1.0", - "doctrine/orm": "^2.17 || ^3.0", + "doctrine/cache": "^1.11 || ^2.0", + "doctrine/coding-standard": "^14", + "doctrine/orm": "^2.17 || ^3.1", "friendsofphp/proxy-manager-lts": "^1.0", "phpstan/phpstan": "2.1.1", "phpstan/phpstan-phpunit": "2.0.3", "phpstan/phpstan-strict-rules": "^2", - "phpunit/phpunit": "^9.5.26", + "phpunit/phpunit": "^10.5.53 || ^12.3.10", "psr/log": "^1.1.4 || ^2.0 || ^3.0", - "symfony/phpunit-bridge": "^6.1 || ^7.0", - "symfony/property-info": "^5.4 || ^6.0 || ^7.0", - "symfony/proxy-manager-bridge": "^5.4 || ^6.0", - "symfony/security-bundle": "^5.4 || ^6.0 || ^7.0", - "symfony/stopwatch": "^5.4 || ^6.0 || ^7.0", - "symfony/string": "^5.4 || ^6.0 || ^7.0", - "symfony/twig-bridge": "^5.4 || ^6.0 || ^7.0", - "symfony/validator": "^5.4 || ^6.0 || ^7.0", - "symfony/var-exporter": "^5.4 || ^6.2 || ^7.0", - "symfony/web-profiler-bundle": "^5.4 || ^6.0 || ^7.0", - "symfony/yaml": "^5.4 || ^6.0 || ^7.0", - "twig/twig": "^1.34 || ^2.12 || ^3.0" + "symfony/doctrine-messenger": "^6.4 || ^7.0", + "symfony/expression-language": "^6.4 || ^7.0", + "symfony/messenger": "^6.4 || ^7.0", + "symfony/property-info": "^6.4 || ^7.0", + "symfony/security-bundle": "^6.4 || ^7.0", + "symfony/stopwatch": "^6.4 || ^7.0", + "symfony/string": "^6.4 || ^7.0", + "symfony/twig-bridge": "^6.4 || ^7.0", + "symfony/validator": "^6.4 || ^7.0", + "symfony/var-exporter": "^6.4.1 || ^7.0.1", + "symfony/web-profiler-bundle": "^6.4 || ^7.0", + "symfony/yaml": "^6.4 || ^7.0", + "twig/twig": "^2.14.7 || ^3.0.4" }, "suggest": { "doctrine/orm": "The Doctrine ORM integration is optional in the bundle.", @@ -1634,7 +1542,7 @@ ], "support": { "issues": "https://github.com/doctrine/DoctrineBundle/issues", - "source": "https://github.com/doctrine/DoctrineBundle/tree/2.13.2" + "source": "https://github.com/doctrine/DoctrineBundle/tree/2.18.1" }, "funding": [ { @@ -1650,7 +1558,7 @@ "type": "tidelift" } ], - "time": "2025-01-15T11:12:38+00:00" + "time": "2025-11-05T14:42:10+00:00" }, { "name": "doctrine/doctrine-migrations-bundle", @@ -2172,16 +2080,16 @@ }, { "name": "doctrine/orm", - "version": "3.3.2", + "version": "3.5.7", "source": { "type": "git", "url": "https://github.com/doctrine/orm.git", - "reference": "c9557c588b3a70ed93caff069d0aa75737f25609" + "reference": "f18de9d569f00ed6eb9dac4b33c7844d705d17da" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/orm/zipball/c9557c588b3a70ed93caff069d0aa75737f25609", - "reference": "c9557c588b3a70ed93caff069d0aa75737f25609", + "url": "https://api.github.com/repos/doctrine/orm/zipball/f18de9d569f00ed6eb9dac4b33c7844d705d17da", + "reference": "f18de9d569f00ed6eb9dac4b33c7844d705d17da", "shasum": "" }, "require": { @@ -2201,15 +2109,14 @@ "symfony/var-exporter": "^6.3.9 || ^7.0" }, "require-dev": { - "doctrine/coding-standard": "^12.0", + "doctrine/coding-standard": "^14.0", "phpbench/phpbench": "^1.0", "phpdocumentor/guides-cli": "^1.4", "phpstan/extension-installer": "^1.4", - "phpstan/phpstan": "2.0.3", + "phpstan/phpstan": "2.1.23", "phpstan/phpstan-deprecation-rules": "^2", - "phpunit/phpunit": "^10.4.0", + "phpunit/phpunit": "^10.5.0 || ^11.5", "psr/log": "^1 || ^2 || ^3", - "squizlabs/php_codesniffer": "3.7.2", "symfony/cache": "^5.4 || ^6.2 || ^7.0" }, "suggest": { @@ -2256,9 +2163,9 @@ ], "support": { "issues": "https://github.com/doctrine/orm/issues", - "source": "https://github.com/doctrine/orm/tree/3.3.2" + "source": "https://github.com/doctrine/orm/tree/3.5.7" }, - "time": "2025-02-04T19:43:15+00:00" + "time": "2025-11-11T18:27:40+00:00" }, { "name": "doctrine/persistence", @@ -2474,6 +2381,86 @@ }, "time": "2024-11-21T13:46:39+00:00" }, + { + "name": "gesdinet/jwt-refresh-token-bundle", + "version": "v1.5.0", + "source": { + "type": "git", + "url": "https://github.com/markitosgv/JWTRefreshTokenBundle.git", + "reference": "8706b0d8dcb26610358ba3328ec412315b55c3cd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/markitosgv/JWTRefreshTokenBundle/zipball/8706b0d8dcb26610358ba3328ec412315b55c3cd", + "reference": "8706b0d8dcb26610358ba3328ec412315b55c3cd", + "shasum": "" + }, + "require": { + "doctrine/persistence": "^1.3.3|^2.0|^3.0|^4.0", + "lexik/jwt-authentication-bundle": "^2.0|^3.0", + "php": ">=7.4", + "symfony/config": "^5.4|^6.0|^7.0", + "symfony/console": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/deprecation-contracts": "^2.1|^3.0", + "symfony/event-dispatcher": "^5.4|^6.0|^7.0", + "symfony/http-foundation": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^5.4|^6.0|^7.0", + "symfony/polyfill-php80": "^1.15", + "symfony/property-access": "^5.4|^6.0|^7.0", + "symfony/security-bundle": "^5.4|^6.0|^7.0", + "symfony/security-core": "^5.4|^6.0|^7.0", + "symfony/security-http": "^5.4|^6.0|^7.0" + }, + "conflict": { + "doctrine/mongodb-odm": "<2.2", + "doctrine/orm": "<2.7" + }, + "require-dev": { + "doctrine/annotations": "^1.13|^2.0", + "doctrine/cache": "^1.11|^2.0", + "doctrine/mongodb-odm": "^2.2", + "doctrine/orm": "^2.7|^3.0", + "matthiasnoback/symfony-config-test": "^4.2|^5.0", + "matthiasnoback/symfony-dependency-injection-test": "^4.2|^5.0", + "phpunit/phpunit": "^9.5", + "symfony/cache": "^5.4|^6.0|^7.0", + "symfony/security-guard": "^5.4" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Gesdinet\\JWTRefreshTokenBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marcos Gómez Vilches", + "email": "marcos@gesdinet.com" + } + ], + "description": "Implements a refresh token system over Json Web Tokens in Symfony", + "keywords": [ + "jwt refresh token bundle symfony json web" + ], + "support": { + "issues": "https://github.com/markitosgv/JWTRefreshTokenBundle/issues", + "source": "https://github.com/markitosgv/JWTRefreshTokenBundle/tree/v1.5.0" + }, + "time": "2025-06-24T13:08:37+00:00" + }, { "name": "lcobucci/clock", "version": "3.5.0", diff --git a/config/bundles.php b/config/bundles.php index a8491d3..f185d35 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -15,4 +15,6 @@ Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true, 'test' => true], Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle::class => ['all' => true], + Gesdinet\JWTRefreshTokenBundle\GesdinetJWTRefreshTokenBundle::class => ['all' => true], ]; + diff --git a/config/packages/gesdinet_jwt_refresh_token.yaml b/config/packages/gesdinet_jwt_refresh_token.yaml new file mode 100644 index 0000000..29e3537 --- /dev/null +++ b/config/packages/gesdinet_jwt_refresh_token.yaml @@ -0,0 +1,2 @@ +gesdinet_jwt_refresh_token: + refresh_token_class: App\Entity\RefreshToken # This is the class name of the refresh token, you will need to adjust this to match the class your application will use \ No newline at end of file diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 6b94497..7d3ac57 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -1,37 +1,36 @@ security: - # https://symfony.com/doc/current/security.html#c-hashing-passwords password_hashers: App\Entity\User: 'auto' - # https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers providers: - # used to reload user from session & other features (e.g. switch_user) users: entity: class: App\Entity\User property: email - # mongodb: - # class: App\Document\User - # property: email firewalls: dev: - pattern: ^/_(profiler|wdt) + pattern: ^/(_(profiler|wdt)) security: false + main: stateless: true provider: users + entry_point: jwt json_login: - check_path: /auth # The name in routes.yaml is enough for mapping + check_path: /auth/login username_path: email password_path: password success_handler: lexik_jwt_authentication.handler.authentication_success failure_handler: lexik_jwt_authentication.handler.authentication_failure jwt: ~ + refresh_jwt: + check_path: /auth/token/refresh access_control: - - { path: ^/$, roles: PUBLIC_ACCESS } # Allows accessing the Swagger UI - - { path: ^/docs, roles: PUBLIC_ACCESS } # Allows accessing the Swagger UI docs - - { path: ^/contexts, roles: PUBLIC_ACCESS } # Allows accessing the Swagger UI contexts + - { path: ^/$, roles: PUBLIC_ACCESS } + - { path: ^/docs, roles: PUBLIC_ACCESS } + - { path: ^/contexts, roles: PUBLIC_ACCESS } - { path: ^/auth, roles: PUBLIC_ACCESS } - - { path: ^/, roles: IS_AUTHENTICATED_FULLY } \ No newline at end of file + - { path: ^/auth/(login|token/refresh), roles: PUBLIC_ACCESS } + - { path: ^/, roles: IS_AUTHENTICATED_FULLY } diff --git a/config/routes.yaml b/config/routes.yaml index 226a110..91dfed5 100644 --- a/config/routes.yaml +++ b/config/routes.yaml @@ -5,5 +5,8 @@ controllers: type: attribute auth: - path: /auth - methods: ['POST'] \ No newline at end of file + path: /auth/login + methods: ['POST'] + +auth_refresh_token: + path: /auth/token/refresh \ No newline at end of file diff --git a/migrations/Version20251112142306.php b/migrations/Version20251112160004.php similarity index 61% rename from migrations/Version20251112142306.php rename to migrations/Version20251112160004.php index 7375377..77066ad 100644 --- a/migrations/Version20251112142306.php +++ b/migrations/Version20251112160004.php @@ -10,7 +10,7 @@ /** * Auto-generated Migration: Please modify to your needs! */ -final class Version20251112142306 extends AbstractMigration +final class Version20251112160004 extends AbstractMigration { public function getDescription(): string { @@ -20,13 +20,16 @@ public function getDescription(): string public function up(Schema $schema): void { // this up() migration is auto-generated, please modify it to your needs - $this->addSql('CREATE TABLE "user" (id UUID NOT NULL, email VARCHAR(180) NOT NULL, roles JSON NOT NULL, password VARCHAR(255) NOT NULL, firstname VARCHAR(255) NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE TABLE refresh_tokens (refresh_token VARCHAR(128) NOT NULL, username VARCHAR(255) NOT NULL, valid TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_9BACE7E1C74F2195 ON refresh_tokens (refresh_token)'); + $this->addSql('CREATE TABLE "user" (id UUID NOT NULL, email VARCHAR(180) NOT NULL, roles JSON NOT NULL, password VARCHAR(180) NOT NULL, firstname VARCHAR(180) NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE UNIQUE INDEX UNIQ_IDENTIFIER_EMAIL ON "user" (email)'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP TABLE refresh_tokens'); $this->addSql('DROP TABLE "user"'); } } diff --git a/src/Entity/RefreshToken.php b/src/Entity/RefreshToken.php new file mode 100644 index 0000000..ba80c1c --- /dev/null +++ b/src/Entity/RefreshToken.php @@ -0,0 +1,12 @@ + Date: Wed, 12 Nov 2025 17:08:15 +0100 Subject: [PATCH 3/9] add get /users --- src/Entity/User.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Entity/User.php b/src/Entity/User.php index b6abb3a..593983e 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -5,6 +5,7 @@ use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use App\Controller\RegistrationController; @@ -29,7 +30,8 @@ ), new Patch(), new Delete(), - new Get() + new Get(), + new GetCollection(), ] )] #[ORM\Entity(repositoryClass: UserRepository::class)] From fee16d6fcc4cd9c87844e79e018893f5eb8f2ee9 Mon Sep 17 00:00:00 2001 From: Chloe Casali Date: Wed, 12 Nov 2025 17:09:36 +0100 Subject: [PATCH 4/9] fix prettier --- src/Entity/RefreshToken.php | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/Entity/RefreshToken.php b/src/Entity/RefreshToken.php index ba80c1c..d607a63 100644 --- a/src/Entity/RefreshToken.php +++ b/src/Entity/RefreshToken.php @@ -1,12 +1,12 @@ - Date: Fri, 14 Nov 2025 17:44:13 +0100 Subject: [PATCH 5/9] User can create an account and log in (#16) * user can create an account * improve tests + wip token into request's header * delete redundant role * improve fixures by adding a admin user * add role hierarchy + only authorized routes for admin user + dont serialize password in json response * update functional tests + fix role logic in controller + wip refactor * refactor tests * fix prettier * delete comments + fix test in ci with jwt keys * Update config/packages/security.yaml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/Controller/RegistrationController.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update config/packages/security.yaml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/Controller/RegistrationController.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Chloe Casali Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 21 ++--- config/packages/security.yaml | 4 +- src/Controller/RegistrationController.php | 54 ++++++++++++ src/DataFixtures/AppFixtures.php | 15 +++- src/Entity/User.php | 21 +++-- src/Repository/UserRepository.php | 5 ++ tests/AuthDefaultTestCase.php | 94 +++++++++++++-------- tests/Functional/UserAuthenticationTest.php | 48 +++++++++++ tests/Unit/UserTest.php | 71 ++++++---------- 9 files changed, 230 insertions(+), 103 deletions(-) create mode 100644 src/Controller/RegistrationController.php create mode 100644 tests/Functional/UserAuthenticationTest.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 727b13b..ef9666a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,16 +10,13 @@ jobs: runs-on: ubuntu-22.04 steps: - # 1️⃣ Checkout code - name: Checkout repository uses: actions/checkout@v4 - # 2️⃣ Run PHP-CS-Fixer on src - name: PHP-CS-Fixer src run: | docker run --rm -v "${{ github.workspace }}/src":/src ghcr.io/php-cs-fixer/php-cs-fixer:3.72-php8.3 check -- /src - # 3️⃣ Run PHP-CS-Fixer on tests - name: PHP-CS-Fixer tests run: | docker run --rm -v "${{ github.workspace }}/tests":/tests ghcr.io/php-cs-fixer/php-cs-fixer:3.72-php8.3 check -- /tests @@ -29,11 +26,9 @@ jobs: runs-on: ubuntu-22.04 steps: - # 1️⃣ Checkout du repo - name: Checkout repository uses: actions/checkout@v4 - # 3️⃣ Build Docker image - name: Build Docker image run: docker build --file Dockerfile --tag auth:ci . @@ -71,18 +66,17 @@ jobs: - name: Start required services run: docker compose up -d database auth - # # 🕓 Attente que la DB soit prête avant d’agir - # - name: Wait for PostgreSQL - # run: | - # until docker compose exec -T database pg_isready -U auth > /dev/null 2>&1; do - # echo "Waiting for database to be ready..." - # sleep 2 - # done + - name: Recreate JWT keys inside container + run: | + docker compose exec -T auth mkdir -p config/jwt + echo "${{ secrets.JWT_PRIVATE_KEY }}" | base64 --decode | docker compose exec -T auth sh -c "cat > config/jwt/private.pem" + echo "${{ secrets.JWT_PUBLIC_KEY }}" | base64 --decode | docker compose exec -T auth sh -c "cat > config/jwt/public.pem" + docker compose exec -T auth chmod 600 config/jwt/private.pem + docker compose exec -T auth chmod 600 config/jwt/public.pem - name: Install dependencies run: docker compose exec -T auth composer install --no-interaction --prefer-dist --optimize-autoloader - # 💾 Préparation complète de la base de test (équivalent à make test) - name: Prepare test database run: | docker compose run --rm auth php bin/console d:d:d --force --if-exists --env=test || true @@ -90,7 +84,6 @@ jobs: docker compose run --rm auth php bin/console d:mi:mi -n --env=test docker compose run --rm auth php bin/console d:fixture:load -n --env=test - # 🧪 Lancement des tests PHPUnit - name: Run PHPUnit tests env: APP_ENV: test diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 7d3ac57..3b0d29c 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -32,5 +32,5 @@ security: - { path: ^/docs, roles: PUBLIC_ACCESS } - { path: ^/contexts, roles: PUBLIC_ACCESS } - { path: ^/auth, roles: PUBLIC_ACCESS } - - { path: ^/auth/(login|token/refresh), roles: PUBLIC_ACCESS } - - { path: ^/, roles: IS_AUTHENTICATED_FULLY } + - { path: ^/auth/(login|token/refresh|register), roles: PUBLIC_ACCESS } + - { path: ^/, roles: ROLE_ADMIN } \ No newline at end of file diff --git a/src/Controller/RegistrationController.php b/src/Controller/RegistrationController.php new file mode 100644 index 0000000..15ebae0 --- /dev/null +++ b/src/Controller/RegistrationController.php @@ -0,0 +1,54 @@ +getContent(), true); + + $email = $data['email']; + $password = $data['password']; + $firstname = $data['firstname']; + $roles = ['ROLE_USER']; + + if (empty(trim($email)) || empty(trim($password)) || empty(trim($firstname))) { + throw new ConflictHttpException('Missing required fields'); + } + + if ($this->userRepository->findOneBy(['email' => $email])) { + throw new ConflictHttpException('Email already exists'); + } + + $user = new User(); + $user->setEmail($email); + $user->setFirstname($firstname); + $user->setRoles($roles); + $user->setPassword($this->passwordHasher->hashPassword($user, $password)); + + $this->userRepository->save($user); + + return new JsonResponse([ + 'id' => $user->getId(), + 'email' => $user->getEmail(), + 'firstname' => $user->getFirstname(), + 'roles' => $user->getRoles(), + ], Response::HTTP_CREATED); + } +} diff --git a/src/DataFixtures/AppFixtures.php b/src/DataFixtures/AppFixtures.php index e0e608d..9141654 100644 --- a/src/DataFixtures/AppFixtures.php +++ b/src/DataFixtures/AppFixtures.php @@ -10,6 +10,8 @@ class AppFixtures extends Fixture { + public const string PASSWORD = 'password'; + public function __construct(private readonly UserPasswordHasherInterface $hasher) { } @@ -17,14 +19,23 @@ public function __construct(private readonly UserPasswordHasherInterface $hasher public function load(ObjectManager $manager): void { $faker = Faker\Factory::create(); - for ($nbUser = 0; $nbUser < 3; ++$nbUser) { + + $admin = new User(); + $admin->setEmail('chlooe@skintrack.com'); + $admin->setFirstname('Chloé'); + $admin->setPassword($this->hasher->hashPassword($admin, self::PASSWORD)); + $admin->setRoles(['ROLE_ADMIN']); + $manager->persist($admin); + + for ($i = 0; $i < 2; ++$i) { $user = new User(); - $user->setPassword($this->hasher->hashPassword($user, 'password')); $user->setEmail($faker->email()); $user->setFirstname($faker->firstName()); + $user->setPassword($this->hasher->hashPassword($user, self::PASSWORD)); $user->setRoles(); $manager->persist($user); } + $manager->flush(); } } diff --git a/src/Entity/User.php b/src/Entity/User.php index 593983e..8d8c53a 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -15,6 +15,7 @@ use Symfony\Bridge\Doctrine\Types\UuidType; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Uid\Uuid; #[ApiResource( @@ -24,7 +25,7 @@ name: 'log in' ), new Post( - uriTemplate: 'auth/register', + uriTemplate: '/auth/register', controller: RegistrationController::class, name: 'registration', ), @@ -32,8 +33,8 @@ new Delete(), new Get(), new GetCollection(), - ] -)] + ], + normalizationContext: ['groups' => 'user:read'])] #[ORM\Entity(repositoryClass: UserRepository::class)] #[ORM\Table(name: '`user`')] #[ORM\UniqueConstraint(name: 'UNIQ_IDENTIFIER_EMAIL', fields: ['email'])] @@ -44,18 +45,21 @@ class User implements PasswordAuthenticatedUserInterface, UserInterface #[ORM\GeneratedValue(strategy: 'CUSTOM')] #[ORM\CustomIdGenerator(class: UuidGenerator::class)] private Uuid $id; - #[ORM\Column(length: 180, unique: true)] + #[Groups(groups: ['user:read'])] private string $email; - #[ORM\Column] - private array $roles = []; + private const array ROLE_USER = ['ROLE_USER']; + #[ORM\Column] + #[Groups(groups: ['user:read'])] + private array $roles = [self::ROLE_USER]; #[ORM\Column(length: 180)] private string $password; #[ORM\Column(length: 180)] - private ?string $firstname = null; + #[Groups(groups: ['user:read'])] + private string $firstname; public function getId(): Uuid { @@ -76,7 +80,7 @@ public function setEmail(string $email): static public function getUserIdentifier(): string { - return (string) $this->email; + return $this->email; } public function getRoles(): array @@ -85,7 +89,6 @@ public function getRoles(): array return array_unique($roles); } - private const array ROLE_USER = ['ROLE_USER']; public function setRoles(array|string $roles = self::ROLE_USER): static { diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index 4f2804e..71470b6 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -57,4 +57,9 @@ public function upgradePassword(PasswordAuthenticatedUserInterface $user, string // ->getOneOrNullResult() // ; // } + public function save(User $user): void + { + $this->getEntityManager()->persist($user); + $this->getEntityManager()->flush(); + } } diff --git a/tests/AuthDefaultTestCase.php b/tests/AuthDefaultTestCase.php index 38c1e9c..f937a36 100644 --- a/tests/AuthDefaultTestCase.php +++ b/tests/AuthDefaultTestCase.php @@ -7,11 +7,25 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Contracts\HttpClient\ResponseInterface; +/** + * Base test case that centralizes client creation and request helpers. + */ class AuthDefaultTestCase extends ApiTestCase { protected const string USER_ENDPOINT = '/users'; + protected const string AUTH_REGISTER_ENDPOINT = '/auth/register'; + protected const string AUTH_LOGIN_ENDPOINT = '/auth/login'; + protected const string PASSWORD = 'password'; - protected function request(string $endpoint, array $options = [], ?string $token = null): ResponseInterface + protected array $defaultHeaders = ['Content-Type' => 'application/ld+json']; + + protected function setUp(): void + { + parent::setUp(); + self::bootKernel(); + } + + protected function sendRequest(string $method, string $endpoint, array $options = [], ?string $token = null): ResponseInterface { $client = self::createClient(); @@ -19,65 +33,79 @@ protected function request(string $endpoint, array $options = [], ?string $token $options['auth_bearer'] = $token; } - return $client->request(Request::METHOD_GET, $endpoint, $options); + $options['headers'] = array_merge($this->defaultHeaders, $options['headers'] ?? []); + + return $client->request($method, $endpoint, $options); } - protected function postRequest(string $endpoint, array $options = [], ?string $token = null): ResponseInterface + protected function getRequest(string $endpoint, array $options = [], ?string $token = null): ResponseInterface { - $client = self::createClient(); - - if ($token) { - $options['auth_bearer'] = $token; - } - $options['headers'] = ['Content-Type' => 'application/ld+json']; + return $this->sendRequest(Request::METHOD_GET, $endpoint, $options, $token); + } - return $client->request(Request::METHOD_POST, $endpoint, $options); + protected function postRequest(string $endpoint, array $options = [], ?string $token = null): ResponseInterface + { + return $this->sendRequest(Request::METHOD_POST, $endpoint, $options, $token); } protected function putRequest(string $endpoint, array $options = [], ?string $token = null): ResponseInterface { - $client = self::createClient(); - if ($token) { - $options['auth_bearer'] = $token; - } - $options['headers'] = ['Content-Type' => 'application/ld+json']; - - return $client->request(Request::METHOD_PUT, $endpoint, $options); + return $this->sendRequest(Request::METHOD_PUT, $endpoint, $options, $token); } protected function patchRequest(string $endpoint, array $options = [], ?string $token = null): array { - $client = self::createClient(); - if ($token) { - $options['auth_bearer'] = $token; - } - $options['headers'] = ['Content-Type' => 'application/merge-patch+json']; + $options['headers'] = array_merge($options['headers'] ?? [], ['Content-Type' => 'application/merge-patch+json']); + $response = $this->sendRequest(Request::METHOD_PATCH, $endpoint, $options, $token); + $this->assertResponseStatusCodeSame(Response::HTTP_OK); - return $client->request(Request::METHOD_PATCH, $endpoint, $options)->toArray(); + return $response->toArray(); } protected function deleteRequest(string $endpoint, array $options = [], ?string $token = null): ResponseInterface { - $client = self::createClient(); - if ($token) { - $options['auth_bearer'] = $token; - } - $options['headers'] = ['Content-Type' => 'application/ld+json']; - - return $client->request(Request::METHOD_DELETE, $endpoint, $options); + return $this->sendRequest(Request::METHOD_DELETE, $endpoint, $options, $token); } - protected function createUser(string $email = 'emailtest@skintrack.com', string $firstname = 'imtestingsomething', string $password = 'password'): array + protected function createUser(string $email = 'user@skintrack.com', string $firstname = 'USER', string $password = self::PASSWORD, array $roles = ['ROLE_USER']): array { - $userResponse = $this->postRequest(self::USER_ENDPOINT, [ + $response = $this->postRequest(self::AUTH_REGISTER_ENDPOINT, [ 'json' => [ 'email' => $email, 'password' => $password, 'firstname' => $firstname, + 'roles' => $roles, ], ]); + $this->assertResponseStatusCodeSame(Response::HTTP_CREATED); - return $userResponse->toArray(); + return $response->toArray(); + } + + protected function createAdmin(string $email = 'admin@skintrack.com', string $firstname = 'ADMIN', string $password = self::PASSWORD, array $roles = ['ROLE_ADMIN']): array + { + return $this->createUser($email, $firstname, $password, $roles); + } + + protected function login(string $email = 'chlooe@skintrack.com', string $password = self::PASSWORD): array + { + $response = $this->postRequest(self::AUTH_LOGIN_ENDPOINT, [ + 'json' => [ + 'email' => $email, + 'password' => $password, + ], + ]); + + $this->assertResponseStatusCodeSame(Response::HTTP_OK); + + return $response->toArray(); + } + + protected function getAdminToken(): ?string + { + $loggedAdmin = $this->login('admin@skintrack.com', self::PASSWORD); + + return $loggedAdmin['token']; } } diff --git a/tests/Functional/UserAuthenticationTest.php b/tests/Functional/UserAuthenticationTest.php new file mode 100644 index 0000000..4370798 --- /dev/null +++ b/tests/Functional/UserAuthenticationTest.php @@ -0,0 +1,48 @@ +createUser(self::USER_2_SKINTRACK_COM); + $this->assertArrayHasKey('id', $data); + $this->assertArrayNotHasKey('password', $data); + } + + public function testRegisterWithMissingFields(): void + { + $this->postRequest(self::AUTH_REGISTER_ENDPOINT, [ + 'json' => [ + 'email' => self::USER_2_SKINTRACK_COM, + 'password' => '', + 'firstname' => '', + ], + ]); + $this->assertResponseStatusCodeSame(Response::HTTP_CONFLICT, 'Missing required fields'); + } + + public function testLogin(): void + { + $data = $this->login(self::USER_2_SKINTRACK_COM); + $this->assertArrayHasKey('token', $data); + $this->assertArrayHasKey('refresh_token', $data); + } + + public function testLoginWithWrongCredentials(): void + { + $this->postRequest(self::AUTH_LOGIN_ENDPOINT, [ + 'json' => [ + 'email' => self::USER_2_SKINTRACK_COM, + 'password' => 'wrong-password', + ], + ]); + $this->assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED, 'Wrong credentials'); + } +} diff --git a/tests/Unit/UserTest.php b/tests/Unit/UserTest.php index 6dbc19b..0b0320e 100644 --- a/tests/Unit/UserTest.php +++ b/tests/Unit/UserTest.php @@ -7,69 +7,54 @@ class UserTest extends AuthDefaultTestCase { - protected const array JSON_BODY_POST = [ - 'json' => [ - 'email' => 'test@example.com', - 'password' => 'password', - 'firstname' => 'test', - ], - ]; - - protected const array JSON_BODY_PATCH = [ - 'json' => [ - 'firstname' => 'Jeanne Doe', - ], - ]; - protected const string DELETE_EMAIL = 'userDelete@skintrack.com'; - protected const string GET_EMAIL = 'userGet@skintrack.com'; - public function testGetUsers(): void { - $response = $this->request(self::USER_ENDPOINT); + // GET /users + $adminToken = $this->login()['token']; + $response = $this->getRequest(self::USER_ENDPOINT, token: $adminToken); $this->assertResponseStatusCodeSame(Response::HTTP_OK); - $data = $response->toArray(); - $this->assertEquals(3, $data['totalItems']); - } - public function testGetUsersId(): void - { - $user = $this->createUser(self::GET_EMAIL); - $userId = $user['@id']; + $data = $response->toArray(); + $this->assertEquals(4, $data['totalItems']); - $response = $this->request($userId); + // GET /users/{id} + $user = $this->createUser('superUser@skintrack.com', 'Jeanne Doe'); + $userId = $user['id']; + $response = $this->getRequest(self::USER_ENDPOINT.'/'.$userId, token: $adminToken); $this->assertResponseStatusCodeSame(Response::HTTP_OK); $data = $response->toArray(); - $this->assertEquals($user['@id'], $data['@id']); - } - public function testPostUsers(): void - { - $this->postRequest(self::USER_ENDPOINT, self::JSON_BODY_POST); - - $this->assertResponseStatusCodeSame(Response::HTTP_CREATED); + $this->assertEquals('Jeanne Doe', $data['firstname']); + $this->assertEquals('superUser@skintrack.com', $data['email']); + $this->assertEquals(['ROLE_USER'], $data['roles']); + $this->assertNotContains('password', array_keys($data)); } - public function testPatchUsers(): void + public function testPatchUser(): void { - $user = $this->createUser(); - $userId = $user['@id']; + $adminToken = $this->login()['token']; - $data = $this->patchRequest($userId, self::JSON_BODY_PATCH); + $user = $this->createUser('userToPatch@skintrack.com'); + $userId = $user['id']; - $this->assertResponseStatusCodeSame(Response::HTTP_OK); - - $this->assertEquals('Jeanne Doe', $data['firstname']); + $data = $this->patchRequest(self::USER_ENDPOINT.'/'.$userId, [ + 'json' => [ + 'firstname' => 'Janine', + ], + ], $adminToken); + $this->assertEquals('Janine', $data['firstname']); } - public function testDeleteUsers(): void + public function testDeleteUser(): void { - $user = $this->createUser(self::DELETE_EMAIL); - $userId = $user['@id']; + $adminToken = $this->login()['token']; - $this->deleteRequest($userId); + $user = $this->createUser('userToDelete@skintrack.com'); + $userId = $user['id']; + $this->deleteRequest(self::USER_ENDPOINT.'/'.$userId, token: $adminToken); $this->assertResponseStatusCodeSame(Response::HTTP_NO_CONTENT); } } From 291f1739e72d6ee22f86b1f744bd2ab304dba07b Mon Sep 17 00:00:00 2001 From: Chloe Casali Date: Fri, 14 Nov 2025 17:57:17 +0100 Subject: [PATCH 6/9] fix review --- config/packages/security.yaml | 1 - config/routes.yaml | 3 ++- src/Entity/User.php | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 3b0d29c..9d88e78 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -32,5 +32,4 @@ security: - { path: ^/docs, roles: PUBLIC_ACCESS } - { path: ^/contexts, roles: PUBLIC_ACCESS } - { path: ^/auth, roles: PUBLIC_ACCESS } - - { path: ^/auth/(login|token/refresh|register), roles: PUBLIC_ACCESS } - { path: ^/, roles: ROLE_ADMIN } \ No newline at end of file diff --git a/config/routes.yaml b/config/routes.yaml index 91dfed5..985191b 100644 --- a/config/routes.yaml +++ b/config/routes.yaml @@ -9,4 +9,5 @@ auth: methods: ['POST'] auth_refresh_token: - path: /auth/token/refresh \ No newline at end of file + path: /auth/token/refresh + methods: ['POST'] \ No newline at end of file diff --git a/src/Entity/User.php b/src/Entity/User.php index 8d8c53a..c115c91 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -22,7 +22,7 @@ operations: [ new Post( uriTemplate: '/auth/login', - name: 'log in' + name: 'login' ), new Post( uriTemplate: '/auth/register', @@ -123,6 +123,5 @@ public function setFirstname(string $firstname): static public function eraseCredentials(): void { - // TODO: Implement eraseCredentials() method. } } From 154fb143521455cf95c1347eb794300c8d9f15a3 Mon Sep 17 00:00:00 2001 From: Chloe Casali Date: Fri, 14 Nov 2025 21:41:58 +0100 Subject: [PATCH 7/9] add test for refresh token --- tests/AuthDefaultTestCase.php | 1 + tests/Functional/UserAuthenticationTest.php | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/tests/AuthDefaultTestCase.php b/tests/AuthDefaultTestCase.php index f937a36..e563495 100644 --- a/tests/AuthDefaultTestCase.php +++ b/tests/AuthDefaultTestCase.php @@ -15,6 +15,7 @@ class AuthDefaultTestCase extends ApiTestCase protected const string USER_ENDPOINT = '/users'; protected const string AUTH_REGISTER_ENDPOINT = '/auth/register'; protected const string AUTH_LOGIN_ENDPOINT = '/auth/login'; + protected const string AUTH_REFRESH_TOKEN_ENDPOINT = '/auth/token/refresh'; protected const string PASSWORD = 'password'; protected array $defaultHeaders = ['Content-Type' => 'application/ld+json']; diff --git a/tests/Functional/UserAuthenticationTest.php b/tests/Functional/UserAuthenticationTest.php index 4370798..59b30ca 100644 --- a/tests/Functional/UserAuthenticationTest.php +++ b/tests/Functional/UserAuthenticationTest.php @@ -33,6 +33,18 @@ public function testLogin(): void $data = $this->login(self::USER_2_SKINTRACK_COM); $this->assertArrayHasKey('token', $data); $this->assertArrayHasKey('refresh_token', $data); + + $refreshTokenResponse = $this->postRequest(self::AUTH_REFRESH_TOKEN_ENDPOINT, [ + 'json' => [ + 'refresh_token' => $data['refresh_token'], + ], + ]); + + // Refresh token + $this->assertResponseStatusCodeSame(Response::HTTP_OK); + $data = $refreshTokenResponse->toArray(); + $this->assertArrayHasKey('token', $data); + $this->assertArrayHasKey('refresh_token', $data); } public function testLoginWithWrongCredentials(): void From 255e67cc6f5c146da4f978567d22243958ed8d66 Mon Sep 17 00:00:00 2001 From: Chloe Casali Date: Fri, 14 Nov 2025 21:51:04 +0100 Subject: [PATCH 8/9] remove deprecations --- config/packages/doctrine.yaml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index 25138b9..cbd9561 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -9,7 +9,7 @@ doctrine: profiling_collect_backtrace: '%kernel.debug%' use_savepoints: true orm: - auto_generate_proxy_classes: true + enable_native_lazy_objects: true enable_lazy_ghost_objects: true report_fields_where_declared: true validate_xml_mapping: true @@ -36,8 +36,6 @@ when@test: when@prod: doctrine: orm: - auto_generate_proxy_classes: false - proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies' query_cache_driver: type: pool pool: doctrine.system_cache_pool From 2d3a7253cbb5f11aa98247eb93c879f918208449 Mon Sep 17 00:00:00 2001 From: Chloe Casali Date: Fri, 14 Nov 2025 22:03:14 +0100 Subject: [PATCH 9/9] try fix lint job --- .github/workflows/ci.yml | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef9666a..dcc7455 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,13 +13,21 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: PHP-CS-Fixer src + - name: PHP-CS-Fixer (src) run: | - docker run --rm -v "${{ github.workspace }}/src":/src ghcr.io/php-cs-fixer/php-cs-fixer:3.72-php8.3 check -- /src + docker run --rm \ + -v "${{ github.workspace }}":/code \ + -w /code \ + ghcr.io/php-cs-fixer/php-cs-fixer:3.72-php8.3 \ + check --using-cache=no --diff --verbose -- ./src - - name: PHP-CS-Fixer tests + - name: PHP-CS-Fixer (tests) run: | - docker run --rm -v "${{ github.workspace }}/tests":/tests ghcr.io/php-cs-fixer/php-cs-fixer:3.72-php8.3 check -- /tests + docker run --rm \ + -v "${{ github.workspace }}":/code \ + -w /code \ + ghcr.io/php-cs-fixer/php-cs-fixer:3.72-php8.3 \ + check --using-cache=no --diff --verbose -- ./tests build: name: Build code