2fa
Some checks failed
Gitea Actions / Run-Tests-On-Amd64 (push) Failing after 30m46s
Gitea Actions / Merge (push) Successful in 5m50s
Gitea Actions / Run-Tests-On-Arm64 (push) Has been cancelled

This commit is contained in:
Thomas Peterson 2026-06-25 08:55:46 +02:00
parent 9906737f39
commit d9ec021f3c
20 changed files with 912 additions and 113 deletions

View File

@ -14,6 +14,9 @@ MONGODB_DB=psc
###> psc ### ###> psc ###
dir_market_motiv=market/motive/ dir_market_motiv=market/motive/
dir_templateprint=market/templateprint/ 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 ### ###< psc ###
###> nelmio/cors-bundle ### ###> nelmio/cors-bundle ###

View File

@ -62,6 +62,9 @@
"psc/calc": "dev-master", "psc/calc": "dev-master",
"ramsey/uuid": "4.5.1", "ramsey/uuid": "4.5.1",
"sauladam/shipment-tracker": "dev-master", "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", "scssphp/scssphp": "v1.11.1",
"setasign/fpdi-tcpdf": "^2.3", "setasign/fpdi-tcpdf": "^2.3",
"sofort/sofortlib-php": "3.3.2", "sofort/sofortlib-php": "3.3.2",

306
src/new/composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "10c58d73f259e7dfc5d7f7ddf71a0949", "content-hash": "e04b81bc87a7231b11cc580757ba49a0",
"packages": [ "packages": [
{ {
"name": "apimatic/core", "name": "apimatic/core",
@ -8154,6 +8154,179 @@
}, },
"time": "2022-06-15T11:04:39+00:00" "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", "name": "scssphp/scssphp",
"version": "v1.11.1", "version": "v1.11.1",
@ -8674,6 +8847,76 @@
}, },
"time": "2025-06-10T07:13:16+00:00" "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", "name": "symfony/ai-agent",
"version": "v0.5.0", "version": "v0.5.0",
@ -19411,67 +19654,6 @@
], ],
"time": "2025-11-17T20:03:58+00:00" "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", "name": "vincentlanglet/twig-cs-fixer",
"version": "3.14.0", "version": "3.14.0",

View File

@ -65,4 +65,5 @@ return [
Symfony\UX\Vue\VueBundle::class => ['all' => true], Symfony\UX\Vue\VueBundle::class => ['all' => true],
Spiriit\Bundle\FormFilterBundle\SpiriitFormFilterBundle::class => ['all' => true], Spiriit\Bundle\FormFilterBundle\SpiriitFormFilterBundle::class => ['all' => true],
Symfony\AI\AiBundle\AiBundle::class => ['all' => true], Symfony\AI\AiBundle\AiBundle::class => ['all' => true],
Scheb\TwoFactorBundle\SchebTwoFactorBundle::class => ['all' => true],
]; ];

View File

@ -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

View File

@ -101,6 +101,11 @@ return static function (ContainerConfigurator $containerConfigurator): void {
'username_parameter' => 'username', 'username_parameter' => 'username',
'password_parameter' => 'password', 'password_parameter' => 'password',
], ],
'two_factor' => [
'auth_form_path' => '2fa_login',
'check_path' => '2fa_login_check',
'enable_csrf' => true,
],
'logout' => [ 'logout' => [
'invalidate_session' => false, 'invalidate_session' => false,
'path' => 'psc_backend_logout', 'path' => 'psc_backend_logout',
@ -163,6 +168,12 @@ return static function (ContainerConfigurator $containerConfigurator): void {
'path' => '^/backend/order/detail/package/download', 'path' => '^/backend/order/detail/package/download',
'roles' => 'PUBLIC_ACCESS', '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', 'path' => '^/backend',
'roles' => 'ROLE_SHOP', 'roles' => 'ROLE_SHOP',

View File

@ -1910,6 +1910,28 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* success_handler?: scalar|Param|null, * success_handler?: scalar|Param|null,
* failure_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<array{ // Default: [] * access_control?: list<array{ // Default: []
* request_matcher?: scalar|Param|null, // Default: null * request_matcher?: scalar|Param|null, // Default: null
@ -2932,6 +2954,38 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* store?: string|Param, // Service name of store // Default: "Symfony\\AI\\Store\\StoreInterface" * store?: string|Param, // Service name of store // Default: "Symfony\\AI\\Store\\StoreInterface"
* }>, * }>,
* } * }
* @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<scalar|Param|null>,
* ip_whitelist?: list<scalar|Param|null>,
* 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<scalar|Param|null>,
* template?: scalar|Param|null, // Default: "@SchebTwoFactor/Authentication/form.html.twig"
* },
* }
* @psalm-type ConfigType = array{ * @psalm-type ConfigType = array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
* parameters?: ParametersConfig, * parameters?: ParametersConfig,
@ -2963,6 +3017,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* vue?: VueConfig, * vue?: VueConfig,
* spiriit_form_filter?: SpiriitFormFilterConfig, * spiriit_form_filter?: SpiriitFormFilterConfig,
* ai?: AiConfig, * ai?: AiConfig,
* scheb_two_factor?: SchebTwoFactorConfig,
* "when@dev"?: array{ * "when@dev"?: array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
* parameters?: ParametersConfig, * parameters?: ParametersConfig,
@ -2999,6 +3054,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* vue?: VueConfig, * vue?: VueConfig,
* spiriit_form_filter?: SpiriitFormFilterConfig, * spiriit_form_filter?: SpiriitFormFilterConfig,
* ai?: AiConfig, * ai?: AiConfig,
* scheb_two_factor?: SchebTwoFactorConfig,
* }, * },
* "when@prod"?: array{ * "when@prod"?: array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
@ -3031,6 +3087,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* vue?: VueConfig, * vue?: VueConfig,
* spiriit_form_filter?: SpiriitFormFilterConfig, * spiriit_form_filter?: SpiriitFormFilterConfig,
* ai?: AiConfig, * ai?: AiConfig,
* scheb_two_factor?: SchebTwoFactorConfig,
* }, * },
* "when@test"?: array{ * "when@test"?: array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
@ -3068,6 +3125,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* vue?: VueConfig, * vue?: VueConfig,
* spiriit_form_filter?: SpiriitFormFilterConfig, * spiriit_form_filter?: SpiriitFormFilterConfig,
* ai?: AiConfig, * ai?: AiConfig,
* scheb_two_factor?: SchebTwoFactorConfig,
* }, * },
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias * ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
* imports?: ImportsConfig, * imports?: ImportsConfig,

View File

@ -0,0 +1,7 @@
2fa_login:
path: /backend/2fa
defaults:
_controller: "scheb_two_factor.form_controller::form"
2fa_login_check:
path: /backend/2fa_check

View File

@ -178,6 +178,7 @@ class DashboardController extends AbstractController
'currentVersion' => $version->getRelease(), 'currentVersion' => $version->getRelease(),
'latestVersion' => $this->isGranted('ROLE_ADMIN') ? $version->getLatestRelease() : null, 'latestVersion' => $this->isGranted('ROLE_ADMIN') ? $version->getLatestRelease() : null,
'updateAvailable' => $this->isGranted('ROLE_ADMIN') ? $version->isUpdateAvailable() : false, 'updateAvailable' => $this->isGranted('ROLE_ADMIN') ? $version->isUpdateAvailable() : false,
'newerReleases' => $this->isGranted('ROLE_ADMIN') ? $version->getNewerReleases() : [],
]; ];
} }
} }

View File

@ -0,0 +1,145 @@
<?php
namespace PSC\Backend\DashboardBundle\Controller;
use Doctrine\ORM\EntityManagerInterface;
use PSC\Shop\EntityBundle\Entity\Contact;
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Totp\TotpAuthenticatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Self-Service-Verwaltung der Zwei-Faktor-Authentifizierung (optional pro Benutzer).
*/
#[IsGranted('ROLE_SHOP')]
class TwoFactorController extends AbstractController
{
private const SESSION_PENDING_SECRET = '2fa_pending_totp_secret';
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly TotpAuthenticatorInterface $totpAuthenticator,
) {
}
#[Route(path: '/2fa-settings', name: 'psc_backend_2fa_settings')]
public function index(Request $request): Response
{
/** @var Contact $user */
$user = $this->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');
}
}

View File

@ -56,6 +56,31 @@
</div> </div>
</div> </div>
<div class="space-y-4 lg:space-y-8">
{% if newerReleases is not empty %}
<div class="rounded-sm border border bg-white px-5 py-4 shadow-lg dark:border-strokedark dark:bg-boxdark">
<div class="px-2 pt-1 pb-2">
<h2 class="text-lg font-medium tracking-tight filament-card-heading mb-2">
Verfügbare Updates
</h2>
<div aria-hidden="true" class="filament-hr border-t dark:border-gray-700"></div>
</div>
<ul class="px-2 py-1 divide-y dark:divide-gray-700 text-sm">
{% for release in newerReleases %}
<li class="flex items-center justify-between gap-2 py-2">
<span class="flex items-center gap-2">
<a href="{{ release.url }}" target="_blank" rel="noopener" class="text-psc-500 font-medium">{{ release.name }}</a>
{% if release.prerelease %}
<span class="bg-yellow-500 text-yellow-800 text-xs font-medium px-2.5 py-0.5 rounded">Pre-Release</span>
{% endif %}
</span>
{% if release.publishedAt %}<span class="text-xs text-gray-400">{{ release.publishedAt|date('d.m.Y') }}</span>{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if diskUsage %} {% if diskUsage %}
<div class="rounded-sm border border bg-white px-5 py-4 shadow-lg dark:border-strokedark dark:bg-boxdark"> <div class="rounded-sm border border bg-white px-5 py-4 shadow-lg dark:border-strokedark dark:bg-boxdark">
<div class="px-2 pt-1 pb-2"> <div class="px-2 pt-1 pb-2">
@ -157,6 +182,7 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
</div>
<div class="col-span-full"> <div class="col-span-full">
<div class="rounded-sm border border bg-white px-7.5 py-6 shadow-lg dark:border-strokedark dark:bg-boxdark"> <div class="rounded-sm border border bg-white px-7.5 py-6 shadow-lg dark:border-strokedark dark:bg-boxdark">

View File

@ -0,0 +1,79 @@
{% extends 'backend_tailwind_base.html.twig' %}
{% block header %}
<h1 class="text-psc text-2xl font-medium"><i class="fas fa-shield-alt mr-1"></i>Zwei-Faktor-Authentifizierung</h1>
{% endblock %}
{% block body %}
<div class="max-w-3xl flex flex-col gap-6">
{% for label, messages in app.flashes %}
{% for message in messages %}
<div class="rounded-sm border px-4 py-3 text-sm {% if label == 'error' %}bg-red-50 border-red-200 text-red-700{% else %}bg-green-50 border-green-200 text-green-700{% endif %}">{{ message }}</div>
{% endfor %}
{% endfor %}
{# ---------------- E-Mail-Verfahren ---------------- #}
<div class="rounded-sm border bg-white px-5 py-4 shadow-lg dark:border-strokedark dark:bg-boxdark">
<h2 class="text-lg font-medium mb-1">E-Mail-Code</h2>
<p class="text-sm text-gray-500 mb-4">Bei jedem Login wird ein Einmal-Code an die unten angegebene E-Mail-Adresse geschickt.</p>
<form method="post" action="{{ path('psc_backend_2fa_email') }}" class="space-y-4">
<input type="hidden" name="_token" value="{{ csrf_token('2fa_email') }}">
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" name="enabled" value="1" {{ emailEnabled ? 'checked' : '' }} class="rounded border-gray-300">
E-Mail-2FA aktivieren
</label>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">E-Mail für den Sicherheitscode</label>
<input type="email" name="two_factor_email" value="{{ twoFactorEmail }}" placeholder="{{ loginEmail }}"
class="w-full rounded-md border border-gray-300 p-2 text-sm">
<p class="text-xs text-gray-400 mt-1">Leer = Login-Adresse ({{ loginEmail }}) verwenden.</p>
</div>
<button type="submit" class="inline-flex items-center px-4 py-2 rounded-sm text-sm font-medium text-white bg-psc-500 hover:bg-psc-600 shadow-sm">Speichern</button>
</form>
</div>
{# ---------------- TOTP / Authenticator ---------------- #}
<div class="rounded-sm border bg-white px-5 py-4 shadow-lg dark:border-strokedark dark:bg-boxdark">
<h2 class="text-lg font-medium mb-1">Authenticator-App (TOTP)</h2>
<p class="text-sm text-gray-500 mb-4">Zeitbasierte Codes über Google/Microsoft Authenticator o.ä.</p>
{% if totpEnabled %}
<div class="flex items-center gap-3">
<span class="bg-green-100 text-green-800 text-xs font-medium px-2.5 py-0.5 rounded">Aktiviert</span>
<form method="post" action="{{ path('psc_backend_2fa_totp_disable') }}">
<input type="hidden" name="_token" value="{{ csrf_token('2fa_totp_disable') }}">
<button type="submit" class="inline-flex items-center px-3 py-1.5 rounded-sm text-sm font-medium text-white bg-red-600 hover:bg-red-700 shadow-sm">Deaktivieren</button>
</form>
</div>
{% elseif pendingSecret %}
<div class="flex flex-col gap-4">
<p class="text-sm">1. Scanne den QR-Code mit deiner Authenticator-App (oder gib den Schlüssel manuell ein):</p>
<div id="totp-qr" class="bg-white p-3 inline-block w-fit rounded border border-gray-200"></div>
<p class="text-xs text-gray-500">Schlüssel: <code class="bg-gray-100 px-1.5 py-0.5 rounded">{{ pendingSecret }}</code></p>
<form method="post" action="{{ path('psc_backend_2fa_totp_confirm') }}" class="space-y-3">
<input type="hidden" name="_token" value="{{ csrf_token('2fa_totp_confirm') }}">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">2. Code aus der App eingeben</label>
<input type="text" name="code" inputmode="numeric" autocomplete="one-time-code" placeholder="123456"
class="w-40 rounded-md border border-gray-300 p-2 text-sm tracking-widest">
</div>
<button type="submit" class="inline-flex items-center px-4 py-2 rounded-sm text-sm font-medium text-white bg-psc-500 hover:bg-psc-600 shadow-sm">Bestätigen &amp; aktivieren</button>
</form>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
new QRCode(document.getElementById('totp-qr'), { text: {{ qrContent|json_encode|raw }}, width: 200, height: 200 });
});
</script>
{% else %}
<form method="post" action="{{ path('psc_backend_2fa_totp_start') }}">
<input type="hidden" name="_token" value="{{ csrf_token('2fa_totp_start') }}">
<button type="submit" class="inline-flex items-center px-4 py-2 rounded-sm text-sm font-medium text-white bg-psc-500 hover:bg-psc-600 shadow-sm">Einrichtung starten</button>
</form>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -18,6 +18,10 @@ use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use PSC\Shop\ContactBundle\Model\AccountType; use PSC\Shop\ContactBundle\Model\AccountType;
use Ramsey\Uuid\Uuid; 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\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;
@ -30,7 +34,7 @@ use Symfony\Component\Security\Core\User\UserInterface;
*/ */
#[ORM\Table(name: 'contact')] #[ORM\Table(name: 'contact')]
#[ORM\Entity(repositoryClass: 'PSC\Shop\ContactBundle\Repository\ContactRepository')] #[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 $kundenNr = '';
public $calcValue1 = ''; public $calcValue1 = '';
@ -111,6 +115,24 @@ class Contact implements UserInterface, PasswordAuthenticatedUserInterface, \Ser
#[ORM\Column(name: 'uuid', type: 'string', length: 40)] #[ORM\Column(name: 'uuid', type: 'string', length: 40)]
public $uuid; 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\JoinTable(name: 'contact_role')]
#[ORM\JoinColumn(name: 'contact_id', referencedColumnName: 'id')] #[ORM\JoinColumn(name: 'contact_id', referencedColumnName: 'id')]
#[ORM\InverseJoinColumn(name: 'role_id', referencedColumnName: 'id')] #[ORM\InverseJoinColumn(name: 'role_id', referencedColumnName: 'id')]
@ -333,6 +355,80 @@ class Contact implements UserInterface, PasswordAuthenticatedUserInterface, \Ser
//$this->setPassword(""); //$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 * Gibt den Namen zurück
* *

View File

@ -4,6 +4,9 @@ services:
autoconfigure: true autoconfigure: true
bind: bind:
$projectDir: '%kernel.project_dir%' $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\: PSC\System\SettingsBundle\:
resource: '../../*/*' resource: '../../*/*'

View File

@ -10,17 +10,14 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
class Version 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; private ?array $data = null;
public function __construct( public function __construct(
private readonly KernelInterface $kernel, private readonly KernelInterface $kernel,
private readonly HttpClientInterface $httpClient, private readonly HttpClientInterface $httpClient,
private readonly CacheInterface $cache, 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 private function load(): void
@ -49,49 +46,90 @@ class Version
} }
/** /**
* Neueste verfügbare Version aus dem Release-RSS-Feed. * Alle veröffentlichten Releases aus der Gitea-API (inkl. Pre-Release-Flag).
* Ergebnis wird zwischengespeichert, damit das Dashboard schnell bleibt und * Wird zwischengespeichert, damit das Dashboard schnell bleibt. Bei Fehlern: [].
* der Feed nicht bei jedem Aufruf abgefragt wird. Bei Fehlern: null. *
* @return list<array{version:string,name:string,tag:string,prerelease:bool,url:string,publishedAt:?string}>
*/
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<array{version:string,name:string,tag:string,prerelease:bool,url:string,publishedAt:?string}>
*/
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 public function getLatestRelease(): ?string
{ {
return $this->cache->get('psc_latest_release_version', function (ItemInterface $item): ?string { $latest = null;
$item->expiresAfter(3600); foreach ($this->getReleases() as $release) {
if ($latest === null || version_compare($release['version'], $latest, '>')) {
try { $latest = $release['version'];
$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;
} }
}); }
return $latest;
} }
/** /**
@ -99,12 +137,23 @@ class Version
*/ */
public function isUpdateAvailable(): bool 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;
} }
} }

View File

@ -0,0 +1,30 @@
<?php
namespace PSC\System\UpdateBundle\Migrations;
class Version20260624160000 extends Base
{
public function migrateDatabase(): void
{
$connection = $this->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");
}
}
}
}

View File

@ -432,6 +432,19 @@
"sauladam/shipment-tracker": { "sauladam/shipment-tracker": {
"version": "dev-master" "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": { "scssphp/scssphp": {
"version": "dev-master" "version": "dev-master"
}, },

View File

@ -82,6 +82,11 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% if app.user and not (app.user.twoFactorEmailEnabled or app.user.totpAuthenticationEnabled) %}
<div class="flex items-center mr-2">
<a href="{{ path('psc_backend_2fa_settings') }}" class="bg-red-100 text-red-800 text-xs font-medium px-2.5 py-1 rounded whitespace-nowrap" title="Zwei-Faktor-Authentifizierung einrichten">Kein 2FA aktiviert</a>
</div>
{% endif %}
<div> <div>
{% if app.request.locale == 'de_DE' %} {% if app.request.locale == 'de_DE' %}
<a href="{{ app.request.uri }}?_locale=en" class="flex w-full items-center whitespace-nowrap rounded-md p-2 text-sm outline-none hover:text-white focus:text-white hover:bg-psc-500 focus:bg-psc-500">EN</a> <a href="{{ app.request.uri }}?_locale=en" class="flex w-full items-center whitespace-nowrap rounded-md p-2 text-sm outline-none hover:text-white focus:text-white hover:bg-psc-500 focus:bg-psc-500">EN</a>
@ -120,6 +125,13 @@
</button>--> </button>-->
</div> </div>
<a href="{{ path('psc_backend_2fa_settings') }}" class="block flex w-full items-center whitespace-nowrap rounded-md p-2 text-sm outline-none hover:text-white focus:text-white hover:bg-psc-500 focus:bg-psc-500" title="Zwei-Faktor-Authentifizierung">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="filament-dropdown-list-item-icon mr-2 h-5 w-5 rtl:ml-2 rtl:mr-0 group-hover:text-white group-focus:text-white text-primary-500">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
<span class="truncate w-full text-start">2FA</span>
</a>
<a href="{{ path('psc_backend_logout') }}" class="block flex w-full items-center whitespace-nowrap rounded-md p-2 text-sm outline-none hover:text-white focus:text-white hover:bg-psc-500 focus:bg-psc-500" title="Logout"> <a href="{{ path('psc_backend_logout') }}" class="block flex w-full items-center whitespace-nowrap rounded-md p-2 text-sm outline-none hover:text-white focus:text-white hover:bg-psc-500 focus:bg-psc-500" title="Logout">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="filament-dropdown-list-item-icon mr-2 h-5 w-5 rtl:ml-2 rtl:mr-0 group-hover:text-white group-focus:text-white text-primary-500"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="filament-dropdown-list-item-icon mr-2 h-5 w-5 rtl:ml-2 rtl:mr-0 group-hover:text-white group-focus:text-white text-primary-500">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" /> <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />

View File

@ -0,0 +1,50 @@
{% extends 'backend_login.html.twig' %}
{% block body %}
<div class="p-8 space-y-4 bg-white/50 backdrop-blur-xl border border-gray-200 shadow-2xl rounded-2xl relative">
<pre><img src="{{ asset('images/logo.png') }}" alt="Logo"></pre>
<h1 class="text-lg font-medium text-gray-800">Zwei-Faktor-Authentifizierung</h1>
{% if authenticationError %}
<div class="rounded-lg bg-red-50 border border-red-200 text-red-700 text-sm px-3 py-2">
{{ authenticationError|trans(authenticationErrorData, 'SchebTwoFactorBundle') }}
</div>
{% endif %}
{% if availableTwoFactorProviders|length > 1 %}
<div class="text-sm text-gray-600">
{{ "choose_provider"|trans({}, 'SchebTwoFactorBundle') }}:
{% for provider in availableTwoFactorProviders %}
<a class="text-psc-500 hover:underline mr-2" href="{{ path('2fa_login', {'preferProvider': provider}) }}">{{ provider }}</a>
{% endfor %}
</div>
{% endif %}
<form method="post" action="{{ checkPathUrl ? checkPathUrl : path(checkPathRoute) }}">
<label class="text-sm font-medium leading-4 text-gray-700" for="_auth_code">
{{ "auth_code"|trans({}, 'SchebTwoFactorBundle') }} ({{ twoFactorProvider }})
</label>
<input id="_auth_code" type="text" name="{{ authCodeParameterName }}"
inputmode="numeric" autocomplete="one-time-code" autofocus
class="mt-1 block w-full rounded-lg shadow-sm outline-none border-gray-300 focus:border-psc-500 focus:ring-1 focus:ring-inset focus:ring-psc-500 tracking-widest" />
{% if displayTrustedOption %}
<label class="mt-3 flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" name="{{ trustedParameterName }}" class="rounded border-gray-300">
{{ "trusted"|trans({}, 'SchebTwoFactorBundle') }}
</label>
{% endif %}
{% if isCsrfProtectionEnabled %}
<input type="hidden" name="{{ csrfParameterName }}" value="{{ csrf_token(csrfTokenId) }}">
{% endif %}
<button type="submit" class="mt-4 inline-flex items-center justify-center font-medium rounded-lg transition-colors min-h-[2.25rem] px-4 text-sm text-white shadow w-full bg-psc-500">
{{ "login"|trans({}, 'SchebTwoFactorBundle') }}
</button>
</form>
<p class="text-center text-sm">
<a class="text-gray-500 hover:underline" href="{{ logoutPath }}">{{ "cancel"|trans({}, 'SchebTwoFactorBundle') }}</a>
</p>
</div>
{% endblock %}

View File

@ -1,8 +1,16 @@
info: info:
datum: 23.06.2026 datum: 24.06.2026
release: 2.3.7 release: 2.3.8
changelog: 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 - version: 2.3.7
datum: 23.06.2026 datum: 23.06.2026
changes: changes: