diff --git a/src/new/.env b/src/new/.env index 604717e3c..15195df74 100644 --- a/src/new/.env +++ b/src/new/.env @@ -14,6 +14,9 @@ MONGODB_DB=psc ###> psc ### dir_market_motiv=market/motive/ dir_templateprint=market/templateprint/ +# Release-Feed (RSS/Atom) und optionaler Gitea-Token (leer = anonym/öffentlich) +PSC_RELEASES_FEED=https://git.thomas-peterson.de/boonkerz/printshopcreator/releases.rss +PSC_RELEASES_TOKEN= ###< psc ### ###> nelmio/cors-bundle ### diff --git a/src/new/composer.json b/src/new/composer.json index ef0486ed2..d5c552488 100755 --- a/src/new/composer.json +++ b/src/new/composer.json @@ -62,6 +62,9 @@ "psc/calc": "dev-master", "ramsey/uuid": "4.5.1", "sauladam/shipment-tracker": "dev-master", + "scheb/2fa-bundle": "^8.6", + "scheb/2fa-email": "^8.6", + "scheb/2fa-totp": "^8.6", "scssphp/scssphp": "v1.11.1", "setasign/fpdi-tcpdf": "^2.3", "sofort/sofortlib-php": "3.3.2", diff --git a/src/new/composer.lock b/src/new/composer.lock index a6e959b46..01ce68055 100755 --- a/src/new/composer.lock +++ b/src/new/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": "10c58d73f259e7dfc5d7f7ddf71a0949", + "content-hash": "e04b81bc87a7231b11cc580757ba49a0", "packages": [ { "name": "apimatic/core", @@ -8154,6 +8154,179 @@ }, "time": "2022-06-15T11:04:39+00:00" }, + { + "name": "scheb/2fa-bundle", + "version": "v8.6.0", + "source": { + "type": "git", + "url": "https://github.com/scheb/2fa-bundle.git", + "reference": "e5f8793a8180d874d8a96b600cd6b95b25bd098f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/scheb/2fa-bundle/zipball/e5f8793a8180d874d8a96b600cd6b95b25bd098f", + "reference": "e5f8793a8180d874d8a96b600cd6b95b25bd098f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "~8.4.0 || ~8.5.0", + "symfony/config": "^7.4 || ^8.0", + "symfony/dependency-injection": "^7.4 || ^8.0", + "symfony/event-dispatcher": "^7.4 || ^8.0", + "symfony/framework-bundle": "^7.4 || ^8.0", + "symfony/http-foundation": "^7.4 || ^8.0", + "symfony/http-kernel": "^7.4 || ^8.0", + "symfony/property-access": "^7.4 || ^8.0", + "symfony/security-bundle": "^7.4 || ^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/twig-bundle": "^7.4 || ^8.0" + }, + "conflict": { + "scheb/two-factor-bundle": "*" + }, + "suggest": { + "scheb/2fa-backup-code": "Emergency codes when you have no access to other methods", + "scheb/2fa-email": "Send codes by email", + "scheb/2fa-google-authenticator": "Google Authenticator support", + "scheb/2fa-totp": "Temporary one-time password (TOTP) support (Google Authenticator compatible)", + "scheb/2fa-trusted-device": "Trusted devices support" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Scheb\\TwoFactorBundle\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Scheb", + "email": "me@christianscheb.de" + } + ], + "description": "A generic interface to implement two-factor authentication in Symfony applications", + "homepage": "https://github.com/scheb/2fa", + "keywords": [ + "2fa", + "Authentication", + "symfony", + "two-factor", + "two-step" + ], + "support": { + "source": "https://github.com/scheb/2fa-bundle/tree/v8.6.0" + }, + "time": "2026-06-12T18:24:27+00:00" + }, + { + "name": "scheb/2fa-email", + "version": "v8.6.0", + "source": { + "type": "git", + "url": "https://github.com/scheb/2fa-email.git", + "reference": "7abeebe75193a57ea36961c5b751b451608c4e18" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/scheb/2fa-email/zipball/7abeebe75193a57ea36961c5b751b451608c4e18", + "reference": "7abeebe75193a57ea36961c5b751b451608c4e18", + "shasum": "" + }, + "require": { + "php": "~8.4.0 || ~8.5.0", + "scheb/2fa-bundle": "self.version" + }, + "suggest": { + "symfony/mailer": "Needed if you want to use the default mailer implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Scheb\\TwoFactorBundle\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Scheb", + "email": "me@christianscheb.de" + } + ], + "description": "Extends scheb/2fa-bundle with two-factor authentication via email", + "homepage": "https://github.com/scheb/2fa", + "keywords": [ + "2fa", + "Authentication", + "email", + "symfony", + "two-factor", + "two-step" + ], + "support": { + "source": "https://github.com/scheb/2fa-email/tree/v8.6.0" + }, + "time": "2026-06-02T20:21:59+00:00" + }, + { + "name": "scheb/2fa-totp", + "version": "v8.6.0", + "source": { + "type": "git", + "url": "https://github.com/scheb/2fa-totp.git", + "reference": "ca7562c6b6f9e5bb30cadcc98123327c2540e18f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/scheb/2fa-totp/zipball/ca7562c6b6f9e5bb30cadcc98123327c2540e18f", + "reference": "ca7562c6b6f9e5bb30cadcc98123327c2540e18f", + "shasum": "" + }, + "require": { + "php": "~8.4.0 || ~8.5.0", + "scheb/2fa-bundle": "self.version", + "spomky-labs/otphp": "^11.4" + }, + "suggest": { + "symfony/validator": "Needed if you want to use the TOTP validator constraint" + }, + "type": "library", + "autoload": { + "psr-4": { + "Scheb\\TwoFactorBundle\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Scheb", + "email": "me@christianscheb.de" + } + ], + "description": "Extends scheb/2fa-bundle with two-factor authentication using TOTP", + "homepage": "https://github.com/scheb/2fa", + "keywords": [ + "2fa", + "Authentication", + "symfony", + "totp", + "two-factor", + "two-step" + ], + "support": { + "source": "https://github.com/scheb/2fa-totp/tree/v8.6.0" + }, + "time": "2026-01-24T13:27:55+00:00" + }, { "name": "scssphp/scssphp", "version": "v1.11.1", @@ -8674,6 +8847,76 @@ }, "time": "2025-06-10T07:13:16+00:00" }, + { + "name": "spomky-labs/otphp", + "version": "11.5.0", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/otphp.git", + "reference": "877683d6352b80cdc7020fd43a725629c2524435" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/otphp/zipball/877683d6352b80cdc7020fd43a725629c2524435", + "reference": "877683d6352b80cdc7020fd43a725629c2524435", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^2.0 || ^3.0", + "php": ">=8.1", + "psr/clock": "^1.0", + "symfony/deprecation-contracts": "^3.2" + }, + "require-dev": { + "symfony/error-handler": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "OTPHP\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/Spomky-Labs/otphp/contributors" + } + ], + "description": "A PHP library for generating one time passwords according to RFC 4226 (HOTP Algorithm) and the RFC 6238 (TOTP Algorithm) and compatible with Google Authenticator", + "homepage": "https://github.com/Spomky-Labs/otphp", + "keywords": [ + "FreeOTP", + "RFC 4226", + "RFC 6238", + "google authenticator", + "hotp", + "otp", + "totp" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/otphp/issues", + "source": "https://github.com/Spomky-Labs/otphp/tree/11.5.0" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-06-06T23:41:24+00:00" + }, { "name": "symfony/ai-agent", "version": "v0.5.0", @@ -19411,67 +19654,6 @@ ], "time": "2025-11-17T20:03:58+00:00" }, - { - "name": "tomasvotruba/symfony-config-generator", - "version": "0.1.10", - "source": { - "type": "git", - "url": "https://github.com/TomasVotruba/symfony-config-generator.git", - "reference": "2230f0a4838afc49ef50f0b12d02b3027d0c5332" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/TomasVotruba/symfony-config-generator/zipball/2230f0a4838afc49ef50f0b12d02b3027d0c5332", - "reference": "2230f0a4838afc49ef50f0b12d02b3027d0c5332", - "shasum": "" - }, - "require": { - "php": "^8.0", - "symfony/config": "^5.4|^6.4|^7.0", - "symfony/console": "^5.4|^6.4|^7.0", - "symfony/dependency-injection": "^5.4|^6.4|^7.0", - "webmozart/assert": "^1.11" - }, - "require-dev": { - "phpstan/phpstan": "^1.10.56", - "rector/rector": "^1.0", - "symplify/easy-coding-standard": "^12.1", - "symplify/phpstan-rules": "^12.4", - "tomasvotruba/class-leak": "^0.2", - "tracy/tracy": "^2.10" - }, - "bin": [ - "bin/symfony-config-generator", - "bin/symfony-config-generator.php" - ], - "type": "library", - "autoload": { - "psr-4": { - "TomasVotruba\\SymfonyConfigGenerator\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "Generate Symfony 5.3+ config builder classes using CLI to improve static analysis and IDE support", - "support": { - "issues": "https://github.com/TomasVotruba/symfony-config-generator/issues", - "source": "https://github.com/TomasVotruba/symfony-config-generator/tree/0.1.10" - }, - "funding": [ - { - "url": "https://www.paypal.me/rectorphp", - "type": "custom" - }, - { - "url": "https://github.com/tomasvotruba", - "type": "github" - } - ], - "abandoned": "rector/swiss-knife", - "time": "2024-04-10T10:52:45+00:00" - }, { "name": "vincentlanglet/twig-cs-fixer", "version": "3.14.0", diff --git a/src/new/config/bundles.php b/src/new/config/bundles.php index b14f73add..963740e44 100755 --- a/src/new/config/bundles.php +++ b/src/new/config/bundles.php @@ -65,4 +65,5 @@ return [ Symfony\UX\Vue\VueBundle::class => ['all' => true], Spiriit\Bundle\FormFilterBundle\SpiriitFormFilterBundle::class => ['all' => true], Symfony\AI\AiBundle\AiBundle::class => ['all' => true], + Scheb\TwoFactorBundle\SchebTwoFactorBundle::class => ['all' => true], ]; diff --git a/src/new/config/packages/scheb_2fa.yaml b/src/new/config/packages/scheb_2fa.yaml new file mode 100644 index 000000000..b5631aeba --- /dev/null +++ b/src/new/config/packages/scheb_2fa.yaml @@ -0,0 +1,22 @@ +# See the configuration reference at https://symfony.com/bundles/SchebTwoFactorBundle/6.x/configuration.html +scheb_two_factor: + security_tokens: + - Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken + - Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken + + # E-Mail-Code-Verfahren (Code geht an das separate 2FA-Feld des Benutzers) + email: + enabled: true + sender_email: noreply@printshopcreator.de + sender_name: PrintshopCreator + digits: 6 + + # TOTP / Authenticator-App + totp: + enabled: true + server_name: PrintshopCreator + issuer: PrintshopCreator + parameters: + algorithm: sha1 + digits: 6 + period: 30 diff --git a/src/new/config/packages/security.php b/src/new/config/packages/security.php index ae67f44dc..cb2f87cb7 100755 --- a/src/new/config/packages/security.php +++ b/src/new/config/packages/security.php @@ -101,6 +101,11 @@ return static function (ContainerConfigurator $containerConfigurator): void { 'username_parameter' => 'username', 'password_parameter' => 'password', ], + 'two_factor' => [ + 'auth_form_path' => '2fa_login', + 'check_path' => '2fa_login_check', + 'enable_csrf' => true, + ], 'logout' => [ 'invalidate_session' => false, 'path' => 'psc_backend_logout', @@ -163,6 +168,12 @@ return static function (ContainerConfigurator $containerConfigurator): void { 'path' => '^/backend/order/detail/package/download', 'roles' => 'PUBLIC_ACCESS', ], + [ + // Nur die 2FA-Challenge-Routen (/backend/2fa und /backend/2fa_check), + // NICHT /backend/2fa-settings (das braucht volle Authentifizierung). + 'path' => '^/backend/2fa(_check)?$', + 'roles' => 'IS_AUTHENTICATED_2FA_IN_PROGRESS', + ], [ 'path' => '^/backend', 'roles' => 'ROLE_SHOP', diff --git a/src/new/config/reference.php b/src/new/config/reference.php index 848c5bec1..1e5355ee0 100644 --- a/src/new/config/reference.php +++ b/src/new/config/reference.php @@ -1910,6 +1910,28 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * success_handler?: scalar|Param|null, * failure_handler?: scalar|Param|null, * }, + * two_factor?: array{ + * check_path?: scalar|Param|null, // Default: "/2fa_check" + * post_only?: bool|Param, // Default: true + * auth_form_path?: scalar|Param|null, // Default: "/2fa" + * always_use_default_target_path?: bool|Param, // Default: false + * default_target_path?: scalar|Param|null, // Default: "/" + * success_handler?: scalar|Param|null, // Default: null + * failure_handler?: scalar|Param|null, // Default: null + * authentication_required_handler?: scalar|Param|null, // Default: null + * auth_code_parameter_name?: scalar|Param|null, // Default: "_auth_code" + * trusted_parameter_name?: scalar|Param|null, // Default: "_trusted" + * remember_me_sets_trusted?: scalar|Param|null, // Default: false + * multi_factor?: bool|Param, // Default: false + * prepare_on_login?: bool|Param, // Default: false + * prepare_on_access_denied?: bool|Param, // Default: false + * enable_csrf?: scalar|Param|null, // Default: false + * csrf_parameter?: scalar|Param|null, // Default: "_csrf_token" + * csrf_token_id?: scalar|Param|null, // Default: "two_factor" + * csrf_header?: scalar|Param|null, // Default: null + * csrf_token_manager?: scalar|Param|null, // Default: "scheb_two_factor.csrf_token_manager" + * provider?: scalar|Param|null, // Default: null + * }, * }>, * access_control?: list, * } + * @psalm-type SchebTwoFactorConfig = array{ + * persister?: scalar|Param|null, // Default: "scheb_two_factor.persister.doctrine" + * model_manager_name?: scalar|Param|null, // Default: null + * security_tokens?: list, + * ip_whitelist?: list, + * ip_whitelist_provider?: scalar|Param|null, // Default: "scheb_two_factor.default_ip_whitelist_provider" + * two_factor_token_factory?: scalar|Param|null, // Default: "scheb_two_factor.default_token_factory" + * two_factor_provider_decider?: scalar|Param|null, // Default: "scheb_two_factor.default_provider_decider" + * two_factor_condition?: scalar|Param|null, // Default: null + * code_reuse_cache?: scalar|Param|null, // Default: null + * code_reuse_cache_duration?: int|Param, // Default: 60 + * code_reuse_default_handler?: scalar|Param|null, // Default: null + * email?: bool|array{ + * enabled?: scalar|Param|null, // Default: false + * mailer?: scalar|Param|null, // Default: null + * code_generator?: scalar|Param|null, // Default: "scheb_two_factor.security.email.default_code_generator" + * form_renderer?: scalar|Param|null, // Default: null + * sender_email?: scalar|Param|null, // Default: null + * sender_name?: scalar|Param|null, // Default: null + * template?: scalar|Param|null, // Default: "@SchebTwoFactor/Authentication/form.html.twig" + * digits?: int|Param, // Default: 4 + * }, + * totp?: bool|array{ + * enabled?: scalar|Param|null, // Default: false + * form_renderer?: scalar|Param|null, // Default: null + * issuer?: scalar|Param|null, // Default: null + * server_name?: scalar|Param|null, // Default: null + * leeway?: int|Param, // Default: 0 + * parameters?: list, + * template?: scalar|Param|null, // Default: "@SchebTwoFactor/Authentication/form.html.twig" + * }, + * } * @psalm-type ConfigType = array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, @@ -2963,6 +3017,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * vue?: VueConfig, * spiriit_form_filter?: SpiriitFormFilterConfig, * ai?: AiConfig, + * scheb_two_factor?: SchebTwoFactorConfig, * "when@dev"?: array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, @@ -2999,6 +3054,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * vue?: VueConfig, * spiriit_form_filter?: SpiriitFormFilterConfig, * ai?: AiConfig, + * scheb_two_factor?: SchebTwoFactorConfig, * }, * "when@prod"?: array{ * imports?: ImportsConfig, @@ -3031,6 +3087,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * vue?: VueConfig, * spiriit_form_filter?: SpiriitFormFilterConfig, * ai?: AiConfig, + * scheb_two_factor?: SchebTwoFactorConfig, * }, * "when@test"?: array{ * imports?: ImportsConfig, @@ -3068,6 +3125,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * vue?: VueConfig, * spiriit_form_filter?: SpiriitFormFilterConfig, * ai?: AiConfig, + * scheb_two_factor?: SchebTwoFactorConfig, * }, * ... $version->getRelease(), 'latestVersion' => $this->isGranted('ROLE_ADMIN') ? $version->getLatestRelease() : null, 'updateAvailable' => $this->isGranted('ROLE_ADMIN') ? $version->isUpdateAvailable() : false, + 'newerReleases' => $this->isGranted('ROLE_ADMIN') ? $version->getNewerReleases() : [], ]; } } diff --git a/src/new/src/PSC/Backend/DashboardBundle/Controller/TwoFactorController.php b/src/new/src/PSC/Backend/DashboardBundle/Controller/TwoFactorController.php new file mode 100644 index 000000000..50ee612bd --- /dev/null +++ b/src/new/src/PSC/Backend/DashboardBundle/Controller/TwoFactorController.php @@ -0,0 +1,145 @@ +getUser(); + + $pendingSecret = $request->getSession()->get(self::SESSION_PENDING_SECRET); + $qrContent = null; + if ($pendingSecret) { + // Secret nur transient setzen (kein flush) um den QR-Inhalt zu erzeugen. + $user->setTotpSecret($pendingSecret); + $qrContent = $this->totpAuthenticator->getQRContent($user); + $user->setTotpSecret(null); + } + + return $this->render('@PSCBackendDashboard/security/2fa.html.twig', [ + 'emailEnabled' => $user->isTwoFactorEmailEnabled(), + 'twoFactorEmail' => $user->getTwoFactorEmail(), + 'loginEmail' => $user->getEmail(), + 'totpEnabled' => $user->isTotpAuthenticationEnabled(), + 'pendingSecret' => $pendingSecret, + 'qrContent' => $qrContent, + ]); + } + + #[Route(path: '/2fa-settings/email', name: 'psc_backend_2fa_email', methods: ['POST'])] + public function email(Request $request): Response + { + if (!$this->isCsrfTokenValid('2fa_email', (string) $request->request->get('_token'))) { + $this->addFlash('error', 'Ungültiges Formular (CSRF).'); + + return $this->redirectToRoute('psc_backend_2fa_settings'); + } + + /** @var Contact $user */ + $user = $this->getUser(); + + $enabled = (bool) $request->request->get('enabled'); + $user->setTwoFactorEmail($request->request->get('two_factor_email')); + $user->setTwoFactorEmailEnabled($enabled); + + $this->entityManager->flush(); + + $this->addFlash('success', $enabled + ? 'E-Mail-2FA aktiviert.' + : 'E-Mail-2FA deaktiviert.'); + + return $this->redirectToRoute('psc_backend_2fa_settings'); + } + + #[Route(path: '/2fa-settings/totp/start', name: 'psc_backend_2fa_totp_start', methods: ['POST'])] + public function totpStart(Request $request): Response + { + if (!$this->isCsrfTokenValid('2fa_totp_start', (string) $request->request->get('_token'))) { + return $this->redirectToRoute('psc_backend_2fa_settings'); + } + + $request->getSession()->set(self::SESSION_PENDING_SECRET, $this->totpAuthenticator->generateSecret()); + + return $this->redirectToRoute('psc_backend_2fa_settings'); + } + + #[Route(path: '/2fa-settings/totp/confirm', name: 'psc_backend_2fa_totp_confirm', methods: ['POST'])] + public function totpConfirm(Request $request): Response + { + if (!$this->isCsrfTokenValid('2fa_totp_confirm', (string) $request->request->get('_token'))) { + return $this->redirectToRoute('psc_backend_2fa_settings'); + } + + /** @var Contact $user */ + $user = $this->getUser(); + $pendingSecret = $request->getSession()->get(self::SESSION_PENDING_SECRET); + $code = trim((string) $request->request->get('code')); + + if (!$pendingSecret) { + $this->addFlash('error', 'Keine Einrichtung aktiv. Bitte erneut starten.'); + + return $this->redirectToRoute('psc_backend_2fa_settings'); + } + + // Code gegen das ausstehende Secret prüfen (transient gesetzt). + $user->setTotpSecret($pendingSecret); + $valid = $this->totpAuthenticator->checkCode($user, $code); + + if (!$valid) { + $user->setTotpSecret(null); + $this->addFlash('error', 'Code ungültig. Bitte erneut versuchen.'); + + return $this->redirectToRoute('psc_backend_2fa_settings'); + } + + // Gültig: Secret dauerhaft speichern, Einrichtung abschließen. + $this->entityManager->flush(); + $request->getSession()->remove(self::SESSION_PENDING_SECRET); + + $this->addFlash('success', 'TOTP / Authenticator-App aktiviert.'); + + return $this->redirectToRoute('psc_backend_2fa_settings'); + } + + #[Route(path: '/2fa-settings/totp/disable', name: 'psc_backend_2fa_totp_disable', methods: ['POST'])] + public function totpDisable(Request $request): Response + { + if (!$this->isCsrfTokenValid('2fa_totp_disable', (string) $request->request->get('_token'))) { + return $this->redirectToRoute('psc_backend_2fa_settings'); + } + + /** @var Contact $user */ + $user = $this->getUser(); + $user->setTotpSecret(null); + $this->entityManager->flush(); + $request->getSession()->remove(self::SESSION_PENDING_SECRET); + + $this->addFlash('success', 'TOTP deaktiviert.'); + + return $this->redirectToRoute('psc_backend_2fa_settings'); + } +} diff --git a/src/new/src/PSC/Backend/DashboardBundle/Resources/views/dashboard/index.html.twig b/src/new/src/PSC/Backend/DashboardBundle/Resources/views/dashboard/index.html.twig index c3d7272e3..2f4b4b2c9 100755 --- a/src/new/src/PSC/Backend/DashboardBundle/Resources/views/dashboard/index.html.twig +++ b/src/new/src/PSC/Backend/DashboardBundle/Resources/views/dashboard/index.html.twig @@ -56,6 +56,31 @@ +
+ {% if newerReleases is not empty %} +
+
+

+ Verfügbare Updates +

+ +
+
    + {% for release in newerReleases %} +
  • + + {{ release.name }} + {% if release.prerelease %} + Pre-Release + {% endif %} + + {% if release.publishedAt %}{{ release.publishedAt|date('d.m.Y') }}{% endif %} +
  • + {% endfor %} +
+
+ {% endif %} + {% if diskUsage %}
@@ -157,6 +182,7 @@
{% endif %} +
diff --git a/src/new/src/PSC/Backend/DashboardBundle/Resources/views/security/2fa.html.twig b/src/new/src/PSC/Backend/DashboardBundle/Resources/views/security/2fa.html.twig new file mode 100644 index 000000000..0142084e8 --- /dev/null +++ b/src/new/src/PSC/Backend/DashboardBundle/Resources/views/security/2fa.html.twig @@ -0,0 +1,79 @@ +{% extends 'backend_tailwind_base.html.twig' %} + +{% block header %} +

Zwei-Faktor-Authentifizierung

+{% endblock %} + +{% block body %} +
+ + {% for label, messages in app.flashes %} + {% for message in messages %} +
{{ message }}
+ {% endfor %} + {% endfor %} + + {# ---------------- E-Mail-Verfahren ---------------- #} +
+

E-Mail-Code

+

Bei jedem Login wird ein Einmal-Code an die unten angegebene E-Mail-Adresse geschickt.

+
+ + +
+ + +

Leer = Login-Adresse ({{ loginEmail }}) verwenden.

+
+ +
+
+ + {# ---------------- TOTP / Authenticator ---------------- #} +
+

Authenticator-App (TOTP)

+

Zeitbasierte Codes über Google/Microsoft Authenticator o.ä.

+ + {% if totpEnabled %} +
+ Aktiviert +
+ + +
+
+ {% elseif pendingSecret %} +
+

1. Scanne den QR-Code mit deiner Authenticator-App (oder gib den Schlüssel manuell ein):

+
+

Schlüssel: {{ pendingSecret }}

+
+ +
+ + +
+ +
+
+ + + {% else %} +
+ + +
+ {% endif %} +
+ +
+{% endblock %} diff --git a/src/new/src/PSC/Shop/EntityBundle/Entity/Contact.php b/src/new/src/PSC/Shop/EntityBundle/Entity/Contact.php index b8269a1d9..1d7779afd 100755 --- a/src/new/src/PSC/Shop/EntityBundle/Entity/Contact.php +++ b/src/new/src/PSC/Shop/EntityBundle/Entity/Contact.php @@ -18,6 +18,10 @@ use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use PSC\Shop\ContactBundle\Model\AccountType; use Ramsey\Uuid\Uuid; +use Scheb\TwoFactorBundle\Model\Email\TwoFactorInterface as EmailTwoFactorInterface; +use Scheb\TwoFactorBundle\Model\Totp\TotpConfiguration; +use Scheb\TwoFactorBundle\Model\Totp\TotpConfigurationInterface; +use Scheb\TwoFactorBundle\Model\Totp\TwoFactorInterface as TotpTwoFactorInterface; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\UserInterface; @@ -30,7 +34,7 @@ use Symfony\Component\Security\Core\User\UserInterface; */ #[ORM\Table(name: 'contact')] #[ORM\Entity(repositoryClass: 'PSC\Shop\ContactBundle\Repository\ContactRepository')] -class Contact implements UserInterface, PasswordAuthenticatedUserInterface, \Serializable +class Contact implements UserInterface, PasswordAuthenticatedUserInterface, \Serializable, EmailTwoFactorInterface, TotpTwoFactorInterface { public $kundenNr = ''; public $calcValue1 = ''; @@ -111,6 +115,24 @@ class Contact implements UserInterface, PasswordAuthenticatedUserInterface, \Ser #[ORM\Column(name: 'uuid', type: 'string', length: 40)] public $uuid; + /** + * Zwei-Faktor-Authentifizierung (Backend) – Felder. Optionales Opt-in pro Benutzer. + */ + #[ORM\Column(name: 'two_factor_email_enabled', type: 'boolean', nullable: true)] + protected ?bool $twoFactorEmailEnabled = null; + + /** Separate E-Mail-Adresse, an die der 2FA-Code geschickt wird. */ + #[ORM\Column(name: 'two_factor_email', type: 'string', length: 255, nullable: true)] + protected ?string $twoFactorEmail = null; + + /** Aktuell generierter E-Mail-Auth-Code (von scheb verwaltet). */ + #[ORM\Column(name: 'email_auth_code', type: 'string', length: 255, nullable: true)] + protected ?string $emailAuthCode = null; + + /** TOTP-Secret (gesetzt = TOTP aktiviert). */ + #[ORM\Column(name: 'totp_secret', type: 'string', length: 255, nullable: true)] + protected ?string $totpSecret = null; + #[ORM\JoinTable(name: 'contact_role')] #[ORM\JoinColumn(name: 'contact_id', referencedColumnName: 'id')] #[ORM\InverseJoinColumn(name: 'role_id', referencedColumnName: 'id')] @@ -333,6 +355,80 @@ class Contact implements UserInterface, PasswordAuthenticatedUserInterface, \Ser //$this->setPassword(""); } + /* ===================== 2FA: E-Mail-Verfahren (scheb) ===================== */ + + public function isEmailAuthEnabled(): bool + { + return (bool) $this->twoFactorEmailEnabled; + } + + public function getEmailAuthRecipient(): string + { + // Code geht an das separate 2FA-Feld; Fallback auf die Login-E-Mail. + return $this->twoFactorEmail ?: (string) $this->getEmail(); + } + + public function getEmailAuthCode(): ?string + { + return $this->emailAuthCode; + } + + public function setEmailAuthCode(string $authCode): void + { + $this->emailAuthCode = $authCode; + } + + public function isTwoFactorEmailEnabled(): bool + { + return (bool) $this->twoFactorEmailEnabled; + } + + public function setTwoFactorEmailEnabled(?bool $enabled): void + { + $this->twoFactorEmailEnabled = $enabled; + } + + public function getTwoFactorEmail(): ?string + { + return $this->twoFactorEmail; + } + + public function setTwoFactorEmail(?string $email): void + { + $this->twoFactorEmail = $email !== null && trim($email) !== '' ? trim($email) : null; + } + + /* ===================== 2FA: TOTP-Verfahren (scheb) ====================== */ + + public function isTotpAuthenticationEnabled(): bool + { + return $this->totpSecret !== null && $this->totpSecret !== ''; + } + + public function getTotpAuthenticationUsername(): string + { + return (string) $this->getEmail(); + } + + public function getTotpAuthenticationConfiguration(): ?TotpConfigurationInterface + { + if (!$this->isTotpAuthenticationEnabled()) { + return null; + } + + return new TotpConfiguration($this->totpSecret, TotpConfiguration::ALGORITHM_SHA1, 30, 6); + } + + public function getTotpSecret(): ?string + { + return $this->totpSecret; + } + + public function setTotpSecret(?string $secret): void + { + $this->totpSecret = $secret; + } + /** * Gibt den Namen zurück * diff --git a/src/new/src/PSC/System/SettingsBundle/Resources/config/services.yml b/src/new/src/PSC/System/SettingsBundle/Resources/config/services.yml index c3dbbac68..934ddb375 100755 --- a/src/new/src/PSC/System/SettingsBundle/Resources/config/services.yml +++ b/src/new/src/PSC/System/SettingsBundle/Resources/config/services.yml @@ -4,6 +4,9 @@ services: autoconfigure: true bind: $projectDir: '%kernel.project_dir%' + # Release-Feed-URL und optionaler Gitea-Token (privat). Leer = anonym. + $releasesFeed: '%env(PSC_RELEASES_FEED)%' + $releasesToken: '%env(PSC_RELEASES_TOKEN)%' PSC\System\SettingsBundle\: resource: '../../*/*' diff --git a/src/new/src/PSC/System/SettingsBundle/Service/Version.php b/src/new/src/PSC/System/SettingsBundle/Service/Version.php index 1caf860b9..cb2f7c774 100644 --- a/src/new/src/PSC/System/SettingsBundle/Service/Version.php +++ b/src/new/src/PSC/System/SettingsBundle/Service/Version.php @@ -10,17 +10,14 @@ use Symfony\Contracts\HttpClient\HttpClientInterface; class Version { - /** - * RSS-Feed mit den veröffentlichten Releases. - */ - private const RELEASES_FEED = 'https://git.thomas-peterson.de/boonkerz/printshopcreator/releases.rss'; - private ?array $data = null; public function __construct( private readonly KernelInterface $kernel, private readonly HttpClientInterface $httpClient, private readonly CacheInterface $cache, + private readonly string $releasesFeed = 'https://git.thomas-peterson.de/boonkerz/printshopcreator/releases.rss', + private readonly string $releasesToken = '', ) {} private function load(): void @@ -49,49 +46,90 @@ class Version } /** - * Neueste verfügbare Version aus dem Release-RSS-Feed. - * Ergebnis wird zwischengespeichert, damit das Dashboard schnell bleibt und - * der Feed nicht bei jedem Aufruf abgefragt wird. Bei Fehlern: null. + * Alle veröffentlichten Releases aus der Gitea-API (inkl. Pre-Release-Flag). + * Wird zwischengespeichert, damit das Dashboard schnell bleibt. Bei Fehlern: []. + * + * @return list + */ + public function getReleases(): array + { + return $this->cache->get('psc_releases_list', function (ItemInterface $item): array { + $item->expiresAfter(3600); + + $api = $this->getApiUrl(); + if ($api === null) { + return []; + } + + try { + $options = ['timeout' => 5, 'query' => ['limit' => 30]]; + if ($this->releasesToken !== '') { + $options['headers'] = ['Authorization' => 'token ' . $this->releasesToken]; + } + + $response = $this->httpClient->request('GET', $api, $options); + if ($response->getStatusCode() !== 200) { + return []; + } + + $releases = []; + foreach ($response->toArray(false) as $entry) { + if (!empty($entry['draft'])) { + continue; + } + $tag = (string) ($entry['tag_name'] ?? $entry['name'] ?? ''); + if (!preg_match('/\d+\.\d+(?:\.\d+)*/', $tag, $matches)) { + continue; + } + $releases[] = [ + 'version' => $matches[0], + 'name' => (string) ($entry['name'] ?? $tag), + 'tag' => $tag, + 'prerelease' => (bool) ($entry['prerelease'] ?? false), + 'url' => (string) ($entry['html_url'] ?? ''), + 'publishedAt' => $entry['published_at'] ?? null, + ]; + } + + return $releases; + } catch (\Throwable) { + return []; + } + }); + } + + /** + * Releases, die neuer als die installierte Version sind (absteigend sortiert). + * + * @return list + */ + public function getNewerReleases(): array + { + $current = $this->getRelease(); + + $newer = array_values(array_filter( + $this->getReleases(), + fn (array $release): bool => version_compare($release['version'], $current, '>'), + )); + + usort($newer, fn (array $a, array $b): int => version_compare($b['version'], $a['version'])); + + return $newer; + } + + /** + * Neueste verfügbare Version (höchste Versionsnummer). Bei Fehlern: null. */ public function getLatestRelease(): ?string { - return $this->cache->get('psc_latest_release_version', function (ItemInterface $item): ?string { - $item->expiresAfter(3600); - - try { - $response = $this->httpClient->request('GET', self::RELEASES_FEED, [ - 'timeout' => 5, - ]); - - $content = $response->getContent(); - $xml = @simplexml_load_string($content); - - if ($xml === false) { - return null; - } - - // RSS 2.0 (channel->item) bzw. Atom (entry) unterstützen. - $title = null; - if (isset($xml->channel->item[0]->title)) { - $title = (string) $xml->channel->item[0]->title; - } elseif (isset($xml->entry[0]->title)) { - $title = (string) $xml->entry[0]->title; - } - - if ($title === null || $title === '') { - return null; - } - - // Versionsnummer (z.B. 2.3.7) aus dem Titel extrahieren. - if (preg_match('/\d+\.\d+(?:\.\d+)*/', $title, $matches)) { - return $matches[0]; - } - - return ltrim(trim($title), 'vV'); - } catch (\Throwable) { - return null; + $latest = null; + foreach ($this->getReleases() as $release) { + if ($latest === null || version_compare($release['version'], $latest, '>')) { + $latest = $release['version']; } - }); + } + + return $latest; } /** @@ -99,12 +137,23 @@ class Version */ public function isUpdateAvailable(): bool { - $latest = $this->getLatestRelease(); + return $this->getNewerReleases() !== []; + } - if ($latest === null || $latest === '') { - return false; + /** + * Leitet die Gitea-API-URL für Releases aus der konfigurierten Feed-URL ab. + * z.B. https://host/owner/repo/releases.rss -> https://host/api/v1/repos/owner/repo/releases + */ + private function getApiUrl(): ?string + { + if ($this->releasesFeed === '') { + return null; } - return version_compare($latest, $this->getRelease(), '>'); + if (preg_match('#^(https?://[^/]+)/([^/]+)/([^/]+)/releases\.(?:rss|atom)#', $this->releasesFeed, $m)) { + return $m[1] . '/api/v1/repos/' . $m[2] . '/' . $m[3] . '/releases'; + } + + return null; } } diff --git a/src/new/src/PSC/System/UpdateBundle/Migrations/Version20260624160000.php b/src/new/src/PSC/System/UpdateBundle/Migrations/Version20260624160000.php new file mode 100644 index 000000000..ad913a9b4 --- /dev/null +++ b/src/new/src/PSC/System/UpdateBundle/Migrations/Version20260624160000.php @@ -0,0 +1,30 @@ +entityManager->getConnection(); + + $columns = [ + 'two_factor_email_enabled' => 'TINYINT(1) NULL DEFAULT NULL', + 'two_factor_email' => 'VARCHAR(255) NULL DEFAULT NULL', + 'email_auth_code' => 'VARCHAR(255) NULL DEFAULT NULL', + 'totp_secret' => 'VARCHAR(255) NULL DEFAULT NULL', + ]; + + foreach ($columns as $name => $definition) { + $exists = $connection->fetchOne( + "SELECT COUNT(*) FROM information_schema.COLUMNS " + . "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'contact' AND COLUMN_NAME = ?", + [$name], + ); + + if (!$exists) { + $connection->executeStatement("ALTER TABLE contact ADD COLUMN `$name` $definition"); + } + } + } +} diff --git a/src/new/symfony.lock b/src/new/symfony.lock index 3614dd839..39b9bb919 100755 --- a/src/new/symfony.lock +++ b/src/new/symfony.lock @@ -432,6 +432,19 @@ "sauladam/shipment-tracker": { "version": "dev-master" }, + "scheb/2fa-bundle": { + "version": "8.6", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "6.0", + "ref": "1e6f68089146853a790b5da9946fc5974f6fcd49" + }, + "files": [ + "config/packages/scheb_2fa.yaml", + "config/routes/scheb_2fa.yaml" + ] + }, "scssphp/scssphp": { "version": "dev-master" }, diff --git a/src/new/templates/backend_tailwind_base.html.twig b/src/new/templates/backend_tailwind_base.html.twig index 828540fd3..c5dba81ed 100644 --- a/src/new/templates/backend_tailwind_base.html.twig +++ b/src/new/templates/backend_tailwind_base.html.twig @@ -82,6 +82,11 @@
{% endif %}
+ {% if app.user and not (app.user.twoFactorEmailEnabled or app.user.totpAuthenticationEnabled) %} + + {% endif %}
{% if app.request.locale == 'de_DE' %} EN @@ -120,6 +125,13 @@ -->
+ + + + + 2FA + + diff --git a/src/new/templates/bundles/SchebTwoFactorBundle/Authentication/form.html.twig b/src/new/templates/bundles/SchebTwoFactorBundle/Authentication/form.html.twig new file mode 100644 index 000000000..026e67d7e --- /dev/null +++ b/src/new/templates/bundles/SchebTwoFactorBundle/Authentication/form.html.twig @@ -0,0 +1,50 @@ +{% extends 'backend_login.html.twig' %} +{% block body %} +
+
Logo
+ +

Zwei-Faktor-Authentifizierung

+ + {% if authenticationError %} +
+ {{ authenticationError|trans(authenticationErrorData, 'SchebTwoFactorBundle') }} +
+ {% endif %} + + {% if availableTwoFactorProviders|length > 1 %} +
+ {% endif %} + +
+ + + + {% if displayTrustedOption %} + + {% endif %} + {% if isCsrfProtectionEnabled %} + + {% endif %} + + +
+ +

+ {{ "cancel"|trans({}, 'SchebTwoFactorBundle') }} +

+
+{% endblock %} diff --git a/src/new/version.yaml b/src/new/version.yaml index 7cba19c88..1ca886f60 100755 --- a/src/new/version.yaml +++ b/src/new/version.yaml @@ -1,8 +1,16 @@ info: - datum: 23.06.2026 - release: 2.3.7 + datum: 24.06.2026 + release: 2.3.8 changelog: + - version: 2.3.8 + datum: 24.06.2026 + changes: + - "Zwei-Faktor-Authentifizierung (2FA) fürs Backend – optional pro Benutzer" + - "2FA-Verfahren: TOTP (Authenticator-App) und E-Mail-Code, einzeln oder zusammen aktivierbar" + - "E-Mail-Code geht an eine separate, frei wählbare 2FA-Adresse (Fallback: Login-E-Mail)" + - "Selbstverwaltung unter /backend/2fa-settings (QR-Einrichtung für TOTP, Aktivieren/Deaktivieren)" + - "Dashboard: Anzeige der aktuellen Version sowie verfügbarer (neuerer) Releases inkl. Pre-Release-Kennzeichnung" - version: 2.3.7 datum: 23.06.2026 changes: