diff --git a/.env b/.env index 5fa872c..fe26c23 100644 --- a/.env +++ b/.env @@ -39,3 +39,10 @@ DEFAULT_URI=http://localhost ###> nelmio/cors-bundle ### CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$' ###< nelmio/cors-bundle ### + +###> lexik/jwt-authentication-bundle ### +JWT_SECRET_KEY= +JWT_PUBLIC_KEY= +JWT_PASSPHRASE= +COOKIE_SECURE=1 +###< lexik/jwt-authentication-bundle ### diff --git a/.gitignore b/.gitignore index 075a0c2..1bfdaec 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ /var/ /vendor/ /LOG/ +/config/jwt/*.pem ###< symfony/framework-bundle ### ###> friendsofphp/php-cs-fixer ### @@ -23,3 +24,7 @@ ###> docker ### docker/.env.docker.local ###< docker ### + +###> lexik/jwt-authentication-bundle ### +/config/jwt/*.pem +###< lexik/jwt-authentication-bundle ### diff --git a/.idea/data_source_mapping.xml b/.idea/data_source_mapping.xml new file mode 100644 index 0000000..9036044 --- /dev/null +++ b/.idea/data_source_mapping.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/ferme.iml b/.idea/ferme.iml index 07b2241..5003136 100644 --- a/.idea/ferme.iml +++ b/.idea/ferme.iml @@ -145,6 +145,9 @@ + + + diff --git a/.idea/php.xml b/.idea/php.xml index 59aea42..c3e6f97 100644 --- a/.idea/php.xml +++ b/.idea/php.xml @@ -151,6 +151,8 @@ + + diff --git a/.idea/workspace.xml b/.idea/workspace.xml index 15a4e64..cf980aa 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -5,12 +5,26 @@ + + + + + + + + + + - - - - + + + + + + + + @@ -195,6 +209,8 @@ + + { @@ -207,28 +223,31 @@ - { - "keyToString": { - "RunOnceActivity.MCP Project settings loaded": "true", - "RunOnceActivity.ShowReadmeOnStart": "true", - "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true", - "RunOnceActivity.git.unshallow": "true", - "RunOnceActivity.typescript.service.memoryLimit.init": "true", - "git-widget-placeholder": "feat/reception-generation-bon", - "node.js.detected.package.eslint": "true", - "node.js.detected.package.tslint": "true", - "node.js.selected.package.eslint": "(autodetect)", - "node.js.selected.package.tslint": "(autodetect)", - "nodejs_package_manager_path": "npm", - "settings.editor.selected.configurable": "reference.webide.settings.project.settings.php.debug", - "vue.rearranger.settings.migration": "true" + +}]]> @@ -389,4 +408,8 @@ + + + + \ No newline at end of file diff --git a/README.md b/README.md index a2aad53..670678a 100644 --- a/README.md +++ b/README.md @@ -21,13 +21,17 @@ Dans le cas ou le `make start` plante à cause du port de la bdd, il faut modifi Pour les variables d'environnement, il faut demander un .env.local pour le backend et un .env pour le frontend à votre collègue. Vérifier que dans le .env.local, vous avez : -* APP_SECRET (doit être différent de celui de votre collègue, puisque utilisé pour signer des tokens) -* DATABASE_URL +* APP_SECRET (à généré dans le conteneur avec la commande php -r "echo bin2hex(random_bytes(32));" et doit être différent de celui de votre collègue, puisque utilisé pour signer des tokens) +* DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8" * PONT_BASCULE_BYPASS (doit être à true en dev) * PONT_BASCULE_URL +* JWT_SECRET_KEY (à générer avec la commande php bin/console lexik:jwt:generate-keypair) +* JWT_PUBLIC_KEY +* JWT_PASSPHRASE (à généré dans le conteneur avec la commande php -r "echo bin2hex(random_bytes(32));") +* COOKIE_SECURE=0 (en dev 0 et en prod 1) Vérifier que dans le .env du dossier frontend, vous avez : -* NUXT_PUBLIC_API_BASE +* NUXT_PUBLIC_API_BASE="http://localhost:8080/api" ### Configuration xdebug Pour configurer xdebug, il faut ajouter un serveur sur phpstorm. @@ -54,6 +58,19 @@ make dev-nuxt ``` Le front sera accessible sur http://localhost:3000 +### Authentification +Ce projet utilise l'authentification JWT avec un cookie httpOnly (LexikJWTAuthenticationBundle). +Le frontend ne lit jamais directement le token, le navigateur envoie automatiquement le cookie. + +### Login flow +- Frontend envoie les identifiants à: + - `POST /api/login_check` +- Backend returns: + - `204 No Content` (normal) + - `Set-Cookie: BEARER=...; HttpOnly` +- Le cookie est automatiquement envoyé pour les futures requêtes. +- La déconnexion utilise `POST /api/logout` et redirige vers `/login`. + ## Commandes utiles Pour restart le container ```bash @@ -71,3 +88,17 @@ Pour clear le cache Symfony ```bash make cache-clear ``` +Faire une migration +```bash +make migration-migrate +``` +Pour générer un password pour un user +```bash +make shell +php bin/console security:hash-password +``` +Sélectionner entity User, taper sont mdp, le copier et l'ajouter dans l'insert de bdd suivant : +```sql +INSERT INTO "user" (username, roles, password) +VALUES ('Mon user', '["ROLE_USER"]', 'Mon mdp hashé'); +``` diff --git a/composer.json b/composer.json index 2a1bc7d..219d46d 100644 --- a/composer.json +++ b/composer.json @@ -13,6 +13,7 @@ "doctrine/doctrine-migrations-bundle": "^4.0", "doctrine/orm": "^3.6", "dompdf/dompdf": "^3.1", + "lexik/jwt-authentication-bundle": "*", "nelmio/cors-bundle": "^2.6", "phpdocumentor/reflection-docblock": "^5.6", "phpstan/phpdoc-parser": "^2.3", diff --git a/composer.lock b/composer.lock index 50c2b0b..1bc79ae 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": "5cd56256b984963ecd4eaa17f2612f57", + "content-hash": "f619208e7dd3272e671e7c2b139afa87", "packages": [ { "name": "api-platform/doctrine-common", @@ -2516,6 +2516,195 @@ }, "time": "2026-01-02T16:01:13+00:00" }, + { + "name": "lcobucci/jwt", + "version": "5.6.0", + "source": { + "type": "git", + "url": "https://github.com/lcobucci/jwt.git", + "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lcobucci/jwt/zipball/bb3e9f21e4196e8afc41def81ef649c164bca25e", + "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "ext-sodium": "*", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/clock": "^1.0" + }, + "require-dev": { + "infection/infection": "^0.29", + "lcobucci/clock": "^3.2", + "lcobucci/coding-standard": "^11.0", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.2", + "phpstan/phpstan": "^1.10.7", + "phpstan/phpstan-deprecation-rules": "^1.1.3", + "phpstan/phpstan-phpunit": "^1.3.10", + "phpstan/phpstan-strict-rules": "^1.5.0", + "phpunit/phpunit": "^11.1" + }, + "suggest": { + "lcobucci/clock": ">= 3.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Lcobucci\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Luís Cobucci", + "email": "lcobucci@gmail.com", + "role": "Developer" + } + ], + "description": "A simple library to work with JSON Web Token and JSON Web Signature", + "keywords": [ + "JWS", + "jwt" + ], + "support": { + "issues": "https://github.com/lcobucci/jwt/issues", + "source": "https://github.com/lcobucci/jwt/tree/5.6.0" + }, + "funding": [ + { + "url": "https://github.com/lcobucci", + "type": "github" + }, + { + "url": "https://www.patreon.com/lcobucci", + "type": "patreon" + } + ], + "time": "2025-10-17T11:30:53+00:00" + }, + { + "name": "lexik/jwt-authentication-bundle", + "version": "v3.2.0", + "source": { + "type": "git", + "url": "https://github.com/lexik/LexikJWTAuthenticationBundle.git", + "reference": "60df75dc70ee6f597929cb2f0812adda591dfa4b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lexik/LexikJWTAuthenticationBundle/zipball/60df75dc70ee6f597929cb2f0812adda591dfa4b", + "reference": "60df75dc70ee6f597929cb2f0812adda591dfa4b", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "lcobucci/jwt": "^5.0", + "php": ">=8.2", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.4|^3.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/security-bundle": "^6.4|^7.0|^8.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|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/filesystem": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/phpunit-bridge": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.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.2.0" + }, + "funding": [ + { + "url": "https://github.com/chalasr", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/lexik/jwt-authentication-bundle", + "type": "tidelift" + } + ], + "time": "2025-12-20T17:47:00+00:00" + }, { "name": "masterminds/html5", "version": "2.10.0", diff --git a/config/bundles.php b/config/bundles.php index ccc600f..b80cfea 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -1,11 +1,23 @@ ['all' => true], - Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], - Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], - Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], - Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], - Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true], - ApiPlatform\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true], + FrameworkBundle::class => ['all' => true], + TwigBundle::class => ['all' => true], + SecurityBundle::class => ['all' => true], + DoctrineBundle::class => ['all' => true], + DoctrineMigrationsBundle::class => ['all' => true], + NelmioCorsBundle::class => ['all' => true], + LexikJWTAuthenticationBundle::class => ['all' => true], + ApiPlatformBundle::class => ['all' => true], ]; diff --git a/config/packages/lexik_jwt_authentication.yaml b/config/packages/lexik_jwt_authentication.yaml new file mode 100644 index 0000000..1780ac4 --- /dev/null +++ b/config/packages/lexik_jwt_authentication.yaml @@ -0,0 +1,20 @@ +lexik_jwt_authentication: + secret_key: '%kernel.project_dir%/config/jwt/private.pem' + public_key: '%kernel.project_dir%/config/jwt/public.pem' + pass_phrase: '%env(JWT_PASSPHRASE)%' + token_ttl: 86400 + token_extractors: + authorization_header: + enabled: true + prefix: Bearer + name: Authorization + cookie: + enabled: true + name: BEARER + set_cookies: + BEARER: + lifetime: 86400 + path: / + samesite: lax + secure: '%env(bool:COOKIE_SECURE)%' + httpOnly: true diff --git a/config/packages/nelmio_cors.yaml b/config/packages/nelmio_cors.yaml index c766508..7b20162 100644 --- a/config/packages/nelmio_cors.yaml +++ b/config/packages/nelmio_cors.yaml @@ -4,6 +4,7 @@ nelmio_cors: allow_origin: ['%env(CORS_ALLOW_ORIGIN)%'] allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE'] allow_headers: ['Content-Type', 'Authorization'] + allow_credentials: true expose_headers: ['Link'] max_age: 3600 paths: diff --git a/config/packages/prod/api_platform.yaml b/config/packages/prod/api_platform.yaml new file mode 100644 index 0000000..86f361c --- /dev/null +++ b/config/packages/prod/api_platform.yaml @@ -0,0 +1,4 @@ +api_platform: + enable_docs: false + enable_swagger: false + enable_swagger_ui: false diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 8964044..4db6922 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -1,20 +1,43 @@ security: # https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords password_hashers: + App\Entity\User: 'auto' 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 } + app_user_provider: + entity: + class: App\Entity\User + property: username firewalls: dev: # Ensure dev tools and static assets are always allowed pattern: ^/(_profiler|_wdt|assets|build)/ security: false - main: - lazy: true - provider: users_in_memory + login: + pattern: ^/login_check + stateless: true + provider: app_user_provider + json_login: + check_path: /login_check + username_path: username + password_path: password + success_handler: lexik_jwt_authentication.handler.authentication_success + failure_handler: lexik_jwt_authentication.handler.authentication_failure + api: + pattern: ^/ + stateless: true + provider: app_user_provider + jwt: ~ + logout: + path: /logout + target: /login + enable_csrf: false + delete_cookies: + BEARER: + path: / # Activate different ways to authenticate: # https://symfony.com/doc/current/security.html#the-firewall @@ -24,8 +47,9 @@ security: # Note: Only the *first* matching rule is applied access_control: - # - { path: ^/admin, roles: ROLE_ADMIN } - # - { path: ^/profile, roles: ROLE_USER } + - { path: ^/login_check, roles: PUBLIC_ACCESS } + - { path: ^/users, roles: PUBLIC_ACCESS, methods: [GET] } + - { path: ^/, roles: IS_AUTHENTICATED_FULLY } when@test: security: diff --git a/config/reference.php b/config/reference.php index 5437970..8299155 100644 --- a/config/reference.php +++ b/config/reference.php @@ -770,6 +770,9 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * property?: scalar|null|Param, // Default: null * manager_name?: scalar|null|Param, // Default: null * }, + * lexik_jwt?: array{ + * class?: scalar|null|Param, // Default: "Lexik\\Bundle\\JWTAuthenticationBundle\\Security\\User\\JWTUser" + * }, * }>, * firewalls: array, * } + * @psalm-type LexikJwtAuthenticationConfig = array{ + * public_key?: scalar|null|Param, // The key used to sign tokens (useless for HMAC). If not set, the key will be automatically computed from the secret key. // Default: null + * additional_public_keys?: list, + * secret_key?: scalar|null|Param, // The key used to sign tokens. It can be a raw secret (for HMAC), a raw RSA/ECDSA key or the path to a file itself being plaintext or PEM. // Default: null + * pass_phrase?: scalar|null|Param, // The key passphrase (useless for HMAC) // Default: "" + * token_ttl?: scalar|null|Param, // Default: 3600 + * allow_no_expiration?: bool|Param, // Allow tokens without "exp" claim (i.e. indefinitely valid, no lifetime) to be considered valid. Caution: usage of this should be rare. // Default: false + * clock_skew?: scalar|null|Param, // Default: 0 + * encoder?: array{ + * service?: scalar|null|Param, // Default: "lexik_jwt_authentication.encoder.lcobucci" + * signature_algorithm?: scalar|null|Param, // Default: "RS256" + * }, + * user_id_claim?: scalar|null|Param, // Default: "username" + * token_extractors?: array{ + * authorization_header?: bool|array{ + * enabled?: bool|Param, // Default: true + * prefix?: scalar|null|Param, // Default: "Bearer" + * name?: scalar|null|Param, // Default: "Authorization" + * }, + * cookie?: bool|array{ + * enabled?: bool|Param, // Default: false + * name?: scalar|null|Param, // Default: "BEARER" + * }, + * query_parameter?: bool|array{ + * enabled?: bool|Param, // Default: false + * name?: scalar|null|Param, // Default: "bearer" + * }, + * split_cookie?: bool|array{ + * enabled?: bool|Param, // Default: false + * cookies?: list, + * }, + * }, + * remove_token_from_body_when_cookies_used?: scalar|null|Param, // Default: true + * set_cookies?: array, + * }>, + * api_platform?: bool|array{ // API Platform compatibility: add check_path in OpenAPI documentation. + * enabled?: bool|Param, // Default: false + * check_path?: scalar|null|Param, // The login check path to add in OpenAPI. // Default: null + * username_path?: scalar|null|Param, // The path to the username in the JSON body. // Default: null + * password_path?: scalar|null|Param, // The path to the password in the JSON body. // Default: null + * }, + * access_token_issuance?: bool|array{ + * enabled?: bool|Param, // Default: false + * signature?: array{ + * algorithm: scalar|null|Param, // The algorithm use to sign the access tokens. + * key: scalar|null|Param, // The signature key. It shall be JWK encoded. + * }, + * encryption?: bool|array{ + * enabled?: bool|Param, // Default: false + * key_encryption_algorithm: scalar|null|Param, // The key encryption algorithm is used to encrypt the token. + * content_encryption_algorithm: scalar|null|Param, // The key encryption algorithm is used to encrypt the token. + * key: scalar|null|Param, // The encryption key. It shall be JWK encoded. + * }, + * }, + * access_token_verification?: bool|array{ + * enabled?: bool|Param, // Default: false + * signature?: array{ + * header_checkers?: list, + * claim_checkers?: list, + * mandatory_claims?: list, + * allowed_algorithms?: list, + * keyset: scalar|null|Param, // The signature keyset. It shall be JWKSet encoded. + * }, + * encryption?: bool|array{ + * enabled?: bool|Param, // Default: false + * continue_on_decryption_failure?: bool|Param, // If enable, non-encrypted tokens or tokens that failed during decryption or verification processes are accepted. // Default: false + * header_checkers?: list, + * allowed_key_encryption_algorithms?: list, + * allowed_content_encryption_algorithms?: list, + * keyset: scalar|null|Param, // The encryption keyset. It shall be JWKSet encoded. + * }, + * }, + * blocklist_token?: bool|array{ + * enabled?: bool|Param, // Default: false + * cache?: scalar|null|Param, // Storage to track blocked tokens // Default: "cache.app" + * }, + * } * @psalm-type ApiPlatformConfig = array{ * title?: scalar|null|Param, // The title of the API. // Default: "" * description?: scalar|null|Param, // The description of the API. // Default: "" @@ -1526,6 +1618,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * doctrine?: DoctrineConfig, * doctrine_migrations?: DoctrineMigrationsConfig, * nelmio_cors?: NelmioCorsConfig, + * lexik_jwt_authentication?: LexikJwtAuthenticationConfig, * api_platform?: ApiPlatformConfig, * "when@dev"?: array{ * imports?: ImportsConfig, @@ -1537,6 +1630,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * doctrine?: DoctrineConfig, * doctrine_migrations?: DoctrineMigrationsConfig, * nelmio_cors?: NelmioCorsConfig, + * lexik_jwt_authentication?: LexikJwtAuthenticationConfig, * api_platform?: ApiPlatformConfig, * }, * "when@prod"?: array{ @@ -1549,6 +1643,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * doctrine?: DoctrineConfig, * doctrine_migrations?: DoctrineMigrationsConfig, * nelmio_cors?: NelmioCorsConfig, + * lexik_jwt_authentication?: LexikJwtAuthenticationConfig, * api_platform?: ApiPlatformConfig, * }, * "when@test"?: array{ @@ -1561,6 +1656,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * doctrine?: DoctrineConfig, * doctrine_migrations?: DoctrineMigrationsConfig, * nelmio_cors?: NelmioCorsConfig, + * lexik_jwt_authentication?: LexikJwtAuthenticationConfig, * api_platform?: ApiPlatformConfig, * }, * ... @@ -26,6 +27,7 @@ export const useApi = (): ApiClient => { const config = useRuntimeConfig() const baseURL = config.public.apiBase ?? '/api' const toast = useToast() + const auth = useAuthStore() const nuxtApp = useNuxtApp() const i18n = nuxtApp.$i18n as | { @@ -70,12 +72,17 @@ export const useApi = (): ApiClient => { const client = $fetch.create({ baseURL, retry: 0, - onResponse({ options }) { + credentials: 'include', + onResponse({ options, response }) { const apiOptions = options as ApiFetchOptions<'json'> if (apiOptions?.toast === false) { return } + if (response?.status && response.status >= 400) { + return + } + const successKey = apiOptions?.toastSuccessKey const successMessage = apiOptions?.toastSuccessMessage || diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 5f70f45..fbe4573 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -13,11 +13,20 @@ "create": "Impossible de créer la réception.", "update": "Impossible de mettre à jour la réception.", "weigh": "Impossible de récupérer la pesée." + }, + "auth": { + "login": "Identifiants invalides.", + "users": "Impossible de récupérer les utilisateurs.", + "logout": "Impossible de se déconnecter." } }, "success": { "reception": { "update": "Réception mise à jour avec succès." + }, + "auth": { + "login": "Connexion réussie.", + "logout": "Déconnexion réussie." } } } diff --git a/frontend/layouts/auth.vue b/frontend/layouts/auth.vue new file mode 100644 index 0000000..d1acf22 --- /dev/null +++ b/frontend/layouts/auth.vue @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/layouts/default.vue b/frontend/layouts/default.vue index c9b2d93..ab38e47 100644 --- a/frontend/layouts/default.vue +++ b/frontend/layouts/default.vue @@ -9,7 +9,7 @@ LOGO - + + + Déconnexion + @@ -38,6 +45,17 @@ diff --git a/frontend/middleware/auth.global.ts b/frontend/middleware/auth.global.ts new file mode 100644 index 0000000..1dbd6fb --- /dev/null +++ b/frontend/middleware/auth.global.ts @@ -0,0 +1,17 @@ +import { useAuthStore } from '~/stores/auth' + +export default defineNuxtRouteMiddleware(async (to) => { + const auth = useAuthStore() + + if (to.path === '/login') { + return + } + + if (!auth.isAuthenticated) { + await auth.ensureSession() + } + + if (!auth.isAuthenticated) { + return navigateTo('/login') + } +}) diff --git a/frontend/pages/login.vue b/frontend/pages/login.vue new file mode 100644 index 0000000..cc8c809 --- /dev/null +++ b/frontend/pages/login.vue @@ -0,0 +1,95 @@ + + + + LOGO + + + + + Utilisateur + + + Choisir un utilisateur + + {{ user.username }} + + + + + + + Mot de passe + + + + + + Connexion + + + + + + diff --git a/frontend/services/auth.ts b/frontend/services/auth.ts new file mode 100644 index 0000000..a573dee --- /dev/null +++ b/frontend/services/auth.ts @@ -0,0 +1,38 @@ +import { useApi } from '~/composables/useApi' +import type { UserData } from '~/services/dto/user-data' + +export async function getUsers() { + const api = useApi() + const data = await api.get('users', {}, { + toastErrorKey: 'errors.auth.users' + }) + if (Array.isArray(data)) { + return data + } + + return data['hydra:member'] ?? [] +} + +export async function getCurrentUser() { + const api = useApi() + return api.get('me', {}, { + toast: false + }) +} + +export async function login(username: string, password: string) { + const api = useApi() + return api.post<{ token: string }>('login_check', { username, password }, { + toastErrorKey: 'errors.auth.login', + toastSuccessKey: 'success.auth.login' + }) +} + +export async function logout() { + const api = useApi() + return api.post('logout', {}, { + toastErrorKey: 'errors.auth.logout', + toastSuccessKey: 'success.auth.logout', + redirect: 'manual' + }) +} diff --git a/frontend/services/dto/user-data.ts b/frontend/services/dto/user-data.ts new file mode 100644 index 0000000..a8cd2a2 --- /dev/null +++ b/frontend/services/dto/user-data.ts @@ -0,0 +1,3 @@ +export interface UserData { + username: string +} diff --git a/frontend/stores/auth.ts b/frontend/stores/auth.ts new file mode 100644 index 0000000..f33a8e3 --- /dev/null +++ b/frontend/stores/auth.ts @@ -0,0 +1,58 @@ +import { defineStore } from 'pinia' +import type { UserData } from '~/services/dto/user-data' +import { getCurrentUser, login, logout } from '~/services/auth' + +export const useAuthStore = defineStore('auth', { + state: () => ({ + user: null as UserData | null, + isLoading: false, + checked: false + }), + getters: { + isAuthenticated: (state) => Boolean(state.user) + }, + actions: { + async ensureSession() { + if (this.checked) { + return this.user + } + + this.checked = true + + try { + const me = await getCurrentUser() + this.user = me + return me + } catch { + this.user = null + return null + } + }, + async login(username: string, password: string) { + this.isLoading = true + + try { + await login(username, password) + const me = await getCurrentUser() + this.user = me + this.checked = true + return me + } finally { + this.isLoading = false + } + }, + async logout() { + this.isLoading = true + + try { + await logout() + } catch { + // Ignore logout errors so we can still clear local auth state. + } finally { + this.user = null + this.checked = true + this.isLoading = false + } + } + } +}) diff --git a/makefile b/makefile index b22c4b2..3f30ce7 100644 --- a/makefile +++ b/makefile @@ -40,13 +40,14 @@ restart: env-init $(DOCKER_COMPOSE) down CURRENT_UID=$(shell id -u) CURRENT_GID=$(shell id -g) $(DOCKER_COMPOSE) up -d -install: copy-git-hook composer-install cache-clear node-use build-nuxtJS +install: copy-git-hook composer-install cache-clear node-use build-nuxtJS migration-migrate # Supprime tout est réinstalle tout (Attention ça supprime la bdd aussi) reset: delete_built_dir remove_orphans build-without-cache start wait install composer-install: $(EXEC_PHP) composer install + $(SYMFONY_CONSOLE) lexik:jwt:generate-keypair --skip-if-exists build-nuxtJS: # $(EXEC_PHP) cp -n frontend/.env.dist frontend/.env.local @@ -72,10 +73,16 @@ build-without-cache: --build-arg="CURRENT_GID=$(shell id -g)" \ --no-cache +migration-migrate: + $(SYMFONY_CONSOLE) bin/console doctrine:migrations:migrate --no-interaction + # Attention, supprime votre bdd local db-reset: $(DOCKER_COMPOSE) down -v $(DOCKER_COMPOSE) up -d + $(MAKE) wait + $(SYMFONY_CONSOLE) doctrine:database:create --if-not-exists + $(MAKE) migration-migrate # Restart la bdd db-restart: diff --git a/migrations/Version20260112000700.php b/migrations/Version20260112000700.php new file mode 100644 index 0000000..601cfb2 --- /dev/null +++ b/migrations/Version20260112000700.php @@ -0,0 +1,27 @@ +addSql('CREATE TABLE "user" (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, username VARCHAR(180) NOT NULL, roles JSON NOT NULL, password VARCHAR(255) NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_USER_USERNAME ON "user" (username)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE "user"'); + } +} diff --git a/migrations/Version20260112000800.php b/migrations/Version20260112000800.php new file mode 100644 index 0000000..4db70eb --- /dev/null +++ b/migrations/Version20260112000800.php @@ -0,0 +1,30 @@ +addSql('CREATE TABLE IF NOT EXISTS "user" (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, username VARCHAR(180) NOT NULL, roles JSON NOT NULL, password VARCHAR(255) NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE UNIQUE INDEX IF NOT EXISTS UNIQ_8D93D649F85E0677 ON "user" (username)'); + $this->addSql('ALTER TABLE weight ALTER type DROP DEFAULT'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE weight ALTER type SET DEFAULT \'gross\''); + $this->addSql('DROP INDEX IF EXISTS UNIQ_8D93D649F85E0677'); + $this->addSql('DROP TABLE IF EXISTS "user"'); + } +} diff --git a/src/Entity/Reception.php b/src/Entity/Reception.php index 5d959ef..939a9ff 100644 --- a/src/Entity/Reception.php +++ b/src/Entity/Reception.php @@ -63,6 +63,7 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer; provider: ReceptionReceiptProvider::class, ), ], + security: "is_granted('ROLE_USER')", )] class Reception { diff --git a/src/Entity/User.php b/src/Entity/User.php new file mode 100644 index 0000000..4bde1e2 --- /dev/null +++ b/src/Entity/User.php @@ -0,0 +1,104 @@ + ['user:read']], + security: "is_granted('ROLE_USER')", + provider: MeProvider::class + ), + new GetCollection( + normalizationContext: ['groups' => ['user:read']], + security: "is_granted('PUBLIC_ACCESS')" + ), + ], + normalizationContext: ['groups' => ['user:read']], + paginationEnabled: false +)] +class User implements UserInterface, PasswordAuthenticatedUserInterface +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[ORM\Column(length: 180, unique: true)] + #[Groups(['user:read'])] + private string $username = ''; + + #[ORM\Column(type: 'json')] + private array $roles = []; + + #[ORM\Column] + private string $password = ''; + + public function getId(): ?int + { + return $this->id; + } + + public function getUsername(): string + { + return $this->username; + } + + public function setUsername(string $username): self + { + $this->username = $username; + + return $this; + } + + public function getUserIdentifier(): string + { + return $this->username; + } + + public function getRoles(): array + { + $roles = $this->roles; + $roles[] = 'ROLE_USER'; + + return array_unique($roles); + } + + public function setRoles(array $roles): self + { + $this->roles = $roles; + + return $this; + } + + public function getPassword(): string + { + return $this->password; + } + + public function setPassword(string $password): self + { + $this->password = $password; + + return $this; + } + + public function eraseCredentials(): void + { + // No-op: we don't store temporary sensitive data on the entity. + } +} diff --git a/src/Entity/Weight.php b/src/Entity/Weight.php index 0b619b5..0a695ff 100644 --- a/src/Entity/Weight.php +++ b/src/Entity/Weight.php @@ -32,6 +32,7 @@ use Symfony\Component\Validator\Constraints as Assert; denormalizationContext: ['groups' => ['weight:write']], ), ], + security: "is_granted('ROLE_USER')", )] #[UniqueEntity(fields: ['reception', 'type'], message: 'A weighing already exists for this type.')] class Weight diff --git a/src/State/MeProvider.php b/src/State/MeProvider.php new file mode 100644 index 0000000..a607236 --- /dev/null +++ b/src/State/MeProvider.php @@ -0,0 +1,27 @@ +security->getUser(); + + if (!$user instanceof User) { + throw new AccessDeniedException('User not authenticated.'); + } + + return $user; + } +} diff --git a/symfony.lock b/symfony.lock index 2fc6d13..3430d59 100644 --- a/symfony.lock +++ b/symfony.lock @@ -61,6 +61,18 @@ ".php-cs-fixer.dist.php" ] }, + "lexik/jwt-authentication-bundle": { + "version": "3.2", + "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.6", "recipe": {