From 47b5dd1c23fc7277156687b620ad783dc72140f3 Mon Sep 17 00:00:00 2001 From: olli Date: Sat, 8 Nov 2025 18:01:09 +0100 Subject: [PATCH] feat: Add settings management for password policies and login options - Introduced a new SettingsManagement view for administrators to manage system settings. - Added routes and components for settings management, including minimum password length and password login options. - Implemented a SettingsService to handle retrieval and updating of settings. - Created a new Setting entity and repository for database interactions. - Added validation for password length using a custom PasswordMinLength validator. - Updated SecurityController to check if password login is allowed. - Enhanced UserManagement view to provide detailed error messages on save and delete operations. - Implemented a DuplicateEmailExceptionListener to handle unique constraint violations for email addresses. - Updated security configuration to include the new LoginFormAuthenticator. - Created API endpoints for fetching and updating settings, secured with ROLE_ADMIN. --- SETTINGS.md | 214 ++++++++++++++ assets/js/App.vue | 126 ++++++++- assets/js/router.js | 2 + assets/js/views/SettingsManagement.vue | 264 ++++++++++++++++++ assets/js/views/UserManagement.vue | 53 +++- config/packages/security.yaml | 6 +- migrations/Version20251108164116.php | 36 +++ src/Controller/SecurityController.php | 9 + src/Controller/SettingsController.php | 54 ++++ src/Entity/Setting.php | 127 +++++++++ src/Entity/User.php | 6 + .../DuplicateEmailExceptionListener.php | 48 ++++ src/Repository/SettingRepository.php | 58 ++++ src/Security/LoginFormAuthenticator.php | 70 +++++ src/Service/SettingsService.php | 90 ++++++ src/Validator/PasswordMinLength.php | 18 ++ src/Validator/PasswordMinLengthValidator.php | 40 +++ templates/security/login.html.twig | 102 +++---- 18 files changed, 1261 insertions(+), 62 deletions(-) create mode 100644 SETTINGS.md create mode 100644 assets/js/views/SettingsManagement.vue create mode 100644 migrations/Version20251108164116.php create mode 100644 src/Controller/SettingsController.php create mode 100644 src/Entity/Setting.php create mode 100644 src/EventListener/DuplicateEmailExceptionListener.php create mode 100644 src/Repository/SettingRepository.php create mode 100644 src/Security/LoginFormAuthenticator.php create mode 100644 src/Service/SettingsService.php create mode 100644 src/Validator/PasswordMinLength.php create mode 100644 src/Validator/PasswordMinLengthValidator.php diff --git a/SETTINGS.md b/SETTINGS.md new file mode 100644 index 0000000..e48ed76 --- /dev/null +++ b/SETTINGS.md @@ -0,0 +1,214 @@ +# System-Einstellungen + +Die System-Einstellungen erlauben es Administratoren, sicherheitsrelevante Konfigurationen für das CRM-System vorzunehmen. + +## Zugriff + +Die Einstellungsseite ist nur für Benutzer mit der Rolle `ROLE_ADMIN` zugänglich und kann über die Navigation unter **Einstellungen** erreicht werden. + +URL: `/settings` + +## Verfügbare Einstellungen + +### 1. Mindestlänge für Passwörter + +**Einstellung:** `security.password_min_length` +**Typ:** Integer (4-128) +**Standard:** 8 Zeichen + +Legt die Mindestanzahl an Zeichen fest, die ein Passwort haben muss. Diese Einstellung wird beim Erstellen und Aktualisieren von Benutzern über die API validiert. + +**Verwendung im Code:** +```php +$minLength = $settingsService->getPasswordMinLength(); +``` + +**Validator:** +Der `PasswordMinLength`-Validator prüft automatisch bei der Benutzerregistrierung und beim Passwort-Update, ob das Passwort die konfigurierte Mindestlänge erfüllt. + +### 2. Login mit E-Mail und Passwort + +**Einstellung:** `security.allow_password_login` +**Typ:** Boolean +**Standard:** true (erlaubt) + +Steuert, ob sich Benutzer mit E-Mail und Passwort anmelden können. Wenn diese Einstellung deaktiviert ist, müssen Benutzer alternative Anmeldemethoden wie OIDC (Pocket-ID) verwenden. + +**Verwendung im Code:** +```php +$isAllowed = $settingsService->isPasswordLoginAllowed(); +``` + +**Wichtig:** +- Stellen Sie sicher, dass mindestens eine alternative Login-Methode (z.B. OIDC) konfiguriert ist, bevor Sie den Passwort-Login deaktivieren +- Die Login-Seite zeigt automatisch nur die verfügbaren Anmeldemethoden an +- Der `LoginFormAuthenticator` blockiert Passwort-Login-Versuche, wenn die Einstellung deaktiviert ist + +## API Endpoints + +### GET /api/settings +Lädt alle Einstellungen + +**Berechtigung:** ROLE_ADMIN + +**Response:** +```json +{ + "settings": { + "passwordMinLength": 8, + "allowPasswordLogin": true + } +} +``` + +### PUT /api/settings +Aktualisiert Einstellungen + +**Berechtigung:** ROLE_ADMIN + +**Request:** +```json +{ + "settings": { + "passwordMinLength": 12, + "allowPasswordLogin": false + } +} +``` + +**Response:** +```json +{ + "success": true, + "settings": { + "passwordMinLength": 12, + "allowPasswordLogin": false + }, + "message": "Settings updated successfully" +} +``` + +## Datenbank-Schema + +Die Einstellungen werden in der Tabelle `settings` gespeichert: + +```sql +CREATE TABLE settings ( + id INT AUTO_INCREMENT PRIMARY KEY, + setting_key VARCHAR(100) UNIQUE NOT NULL, + setting_value LONGTEXT, + setting_type VARCHAR(50) NOT NULL, + description VARCHAR(255), + updated_at DATETIME NOT NULL +); +``` + +**Unterstützte Typen:** +- `string`: Textwerte +- `integer`: Ganzzahlen +- `boolean`: true/false (gespeichert als '1'/'0') +- `float`: Dezimalzahlen +- `json`: JSON-Daten + +## Verwendung im Code + +### SettingsService verwenden + +```php +use App\Service\SettingsService; + +class MyController extends AbstractController +{ + public function __construct( + private SettingsService $settingsService + ) {} + + public function myAction(): Response + { + // Einzelne Einstellung lesen + $minLength = $this->settingsService->getPasswordMinLength(); + $isAllowed = $this->settingsService->isPasswordLoginAllowed(); + + // Alle Einstellungen abrufen + $allSettings = $this->settingsService->getAllSettings(); + + // Einstellung setzen + $this->settingsService->setPasswordMinLength(10); + $this->settingsService->setPasswordLoginAllowed(false); + + // Mehrere Einstellungen auf einmal aktualisieren + $this->settingsService->updateSettings([ + 'passwordMinLength' => 12, + 'allowPasswordLogin' => true + ]); + + // ... + } +} +``` + +### Neue Einstellungen hinzufügen + +1. **Konstante in SettingsService definieren:** +```php +public const MY_NEW_SETTING = 'category.my_new_setting'; +``` + +2. **Getter/Setter hinzufügen:** +```php +public function getMyNewSetting(): mixed +{ + return $this->settingRepository->getValue(self::MY_NEW_SETTING, $defaultValue); +} + +public function setMyNewSetting(mixed $value): void +{ + $this->settingRepository->setValue( + self::MY_NEW_SETTING, + $value, + 'string', // oder 'boolean', 'integer', etc. + 'Description of my setting' + ); +} +``` + +3. **In getAllSettings() und updateSettings() ergänzen:** +```php +public function getAllSettings(): array +{ + return [ + // ... existing settings + 'myNewSetting' => $this->getMyNewSetting(), + ]; +} + +public function updateSettings(array $settings): void +{ + // ... existing code + + if (isset($settings['myNewSetting'])) { + $this->setMyNewSetting($settings['myNewSetting']); + } +} +``` + +4. **Vue-Komponente aktualisieren:** +Fügen Sie das neue Feld in `assets/js/views/SettingsManagement.vue` hinzu. + +## Sicherheit + +- Alle Einstellungs-Endpoints sind durch `#[IsGranted('ROLE_ADMIN')]` geschützt +- Der `LoginFormAuthenticator` prüft vor jedem Login, ob Passwort-Login erlaubt ist +- Der `PasswordMinLength`-Validator validiert Passwörter gegen die konfigurierte Mindestlänge +- Einstellungen werden automatisch bei jeder Änderung mit Zeitstempel versehen + +## Migration + +Die Tabelle wird durch `Version20251108164116` erstellt und mit Standardwerten initialisiert: +- `security.password_min_length`: 8 +- `security.allow_password_login`: true (1) + +Um die Migration auszuführen: +```bash +php bin/console doctrine:migrations:migrate +``` diff --git a/assets/js/App.vue b/assets/js/App.vue index 5e71181..909cedd 100644 --- a/assets/js/App.vue +++ b/assets/js/App.vue @@ -27,12 +27,25 @@ Deals - - Benutzerverwaltung - - - Rollenverwaltung - + + +
@@ -64,6 +77,7 @@ const authStore = useAuthStore(); authStore.initializeFromElement(document.getElementById('app')); const mobileMenuOpen = ref(false); +const adminMenuOpen = ref(false); const toggleMobileMenu = () => { mobileMenuOpen.value = !mobileMenuOpen.value; @@ -71,6 +85,15 @@ const toggleMobileMenu = () => { const closeMobileMenu = () => { mobileMenuOpen.value = false; + adminMenuOpen.value = false; +}; + +const toggleAdminMenu = () => { + adminMenuOpen.value = !adminMenuOpen.value; +}; + +const handleAdminMenuClick = () => { + closeMobileMenu(); }; @@ -251,6 +274,97 @@ const closeMobileMenu = () => { } } + .nav-dropdown { + position: relative; + + .nav-dropdown-toggle { + color: white; + text-decoration: none; + padding: 0.75rem 1rem; + border-radius: 8px; + transition: all 0.2s; + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 1rem; + white-space: nowrap; + cursor: pointer; + + @media (min-width: 768px) { + padding: 0.6rem 1.2rem; + font-size: 1rem; + } + + i { + font-size: 1.1rem; + } + + .dropdown-icon { + margin-left: auto; + font-size: 0.8rem; + transition: transform 0.3s ease; + + &.rotated { + transform: rotate(180deg); + } + } + + &:hover { + background: rgba(255, 255, 255, 0.15); + } + + &.active { + background: rgba(255, 255, 255, 0.15); + } + } + + .nav-dropdown-menu { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding-left: 1rem; + margin-top: 0.25rem; + + @media (min-width: 768px) { + position: absolute; + top: 100%; + left: 0; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); + padding: 0.5rem; + margin-top: 0.5rem; + min-width: 220px; + z-index: 1000; + } + + a { + color: white; + text-decoration: none; + padding: 0.6rem 1rem; + border-radius: 6px; + transition: all 0.2s; + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.95rem; + + i { + font-size: 1rem; + } + + &:hover { + background: rgba(255, 255, 255, 0.2); + } + + &.router-link-active { + background: rgba(255, 255, 255, 0.3); + font-weight: 600; + } + } + } + } + .user-info { display: flex; align-items: center; diff --git a/assets/js/router.js b/assets/js/router.js index 1f2fd03..9720a25 100644 --- a/assets/js/router.js +++ b/assets/js/router.js @@ -5,6 +5,7 @@ import CompanyList from './views/CompanyList.vue'; import DealList from './views/DealList.vue'; import UserManagement from './views/UserManagement.vue'; import RoleManagement from './views/RoleManagement.vue'; +import SettingsManagement from './views/SettingsManagement.vue'; const routes = [ { path: '/', name: 'dashboard', component: Dashboard }, @@ -13,6 +14,7 @@ const routes = [ { path: '/deals', name: 'deals', component: DealList }, { path: '/users', name: 'users', component: UserManagement, meta: { requiresAdmin: true } }, { path: '/roles', name: 'roles', component: RoleManagement, meta: { requiresAdmin: true } }, + { path: '/settings', name: 'settings', component: SettingsManagement, meta: { requiresAdmin: true } }, ]; const router = createRouter({ diff --git a/assets/js/views/SettingsManagement.vue b/assets/js/views/SettingsManagement.vue new file mode 100644 index 0000000..533abc9 --- /dev/null +++ b/assets/js/views/SettingsManagement.vue @@ -0,0 +1,264 @@ + + + + + diff --git a/assets/js/views/UserManagement.vue b/assets/js/views/UserManagement.vue index 7cbbb6c..2f89aa3 100644 --- a/assets/js/views/UserManagement.vue +++ b/assets/js/views/UserManagement.vue @@ -395,7 +395,27 @@ const saveUser = async () => { body: JSON.stringify(payload) }); - if (!response.ok) throw new Error('Fehler beim Speichern'); + if (!response.ok) { + const errorData = await response.json().catch(() => null); + let errorMessage = 'Benutzer konnte nicht gespeichert werden'; + + // Extract validation errors from API Platform response + if (errorData) { + if (errorData['hydra:description']) { + errorMessage = errorData['hydra:description']; + } else if (errorData.violations && errorData.violations.length > 0) { + // Format validation errors + const violations = errorData.violations.map(v => v.message).join(', '); + errorMessage = `Validierungsfehler: ${violations}`; + } else if (errorData.detail) { + errorMessage = errorData.detail; + } else if (errorData.message) { + errorMessage = errorData.message; + } + } + + throw new Error(errorMessage); + } toast.add({ severity: 'success', @@ -407,7 +427,12 @@ const saveUser = async () => { dialogVisible.value = false; await fetchUsers(); } catch (error) { - toast.add({ severity: 'error', summary: 'Fehler', detail: 'Benutzer konnte nicht gespeichert werden', life: 3000 }); + toast.add({ + severity: 'error', + summary: 'Fehler', + detail: error.message || 'Benutzer konnte nicht gespeichert werden', + life: 5000 + }); } finally { saving.value = false; } @@ -426,13 +451,33 @@ const deleteUser = async () => { credentials: 'same-origin' }); - if (!response.ok) throw new Error('Fehler beim Löschen'); + if (!response.ok) { + const errorData = await response.json().catch(() => null); + let errorMessage = 'Benutzer konnte nicht gelöscht werden'; + + if (errorData) { + if (errorData['hydra:description']) { + errorMessage = errorData['hydra:description']; + } else if (errorData.detail) { + errorMessage = errorData.detail; + } else if (errorData.message) { + errorMessage = errorData.message; + } + } + + throw new Error(errorMessage); + } toast.add({ severity: 'success', summary: 'Erfolg', detail: 'Benutzer wurde gelöscht', life: 3000 }); deleteDialogVisible.value = false; await fetchUsers(); } catch (error) { - toast.add({ severity: 'error', summary: 'Fehler', detail: 'Benutzer konnte nicht gelöscht werden', life: 3000 }); + toast.add({ + severity: 'error', + summary: 'Fehler', + detail: error.message || 'Benutzer konnte nicht gelöscht werden', + life: 5000 + }); } finally { deleting.value = false; } diff --git a/config/packages/security.yaml b/config/packages/security.yaml index af0cfe9..20f9981 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -17,12 +17,8 @@ security: lazy: true provider: app_user_provider custom_authenticators: + - App\Security\LoginFormAuthenticator - App\Security\PocketIdAuthenticator - form_login: - login_path: app_login - check_path: app_login - enable_csrf: true - default_target_path: / logout: path: app_logout target: app_login diff --git a/migrations/Version20251108164116.php b/migrations/Version20251108164116.php new file mode 100644 index 0000000..f500cb0 --- /dev/null +++ b/migrations/Version20251108164116.php @@ -0,0 +1,36 @@ +addSql('CREATE TABLE settings (id INT AUTO_INCREMENT NOT NULL, setting_key VARCHAR(100) NOT NULL, setting_value LONGTEXT DEFAULT NULL, setting_type VARCHAR(50) NOT NULL, description VARCHAR(255) DEFAULT NULL, updated_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', UNIQUE INDEX UNIQ_SETTING_KEY (setting_key), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + + // Insert default settings + $now = date('Y-m-d H:i:s'); + $this->addSql("INSERT INTO settings (setting_key, setting_value, setting_type, description, updated_at) VALUES ('security.password_min_length', '8', 'integer', 'Minimum password length required', '$now')"); + $this->addSql("INSERT INTO settings (setting_key, setting_value, setting_type, description, updated_at) VALUES ('security.allow_password_login', '1', 'boolean', 'Allow login with email and password', '$now')"); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP TABLE settings'); + } +} diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php index ff5af96..91cc91e 100644 --- a/src/Controller/SecurityController.php +++ b/src/Controller/SecurityController.php @@ -2,6 +2,7 @@ namespace App\Controller; +use App\Service\SettingsService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; @@ -9,6 +10,10 @@ use Symfony\Component\Security\Http\Authentication\AuthenticationUtils; class SecurityController extends AbstractController { + public function __construct( + private SettingsService $settingsService + ) {} + #[Route(path: '/login', name: 'app_login')] public function login(AuthenticationUtils $authenticationUtils): Response { @@ -23,9 +28,13 @@ class SecurityController extends AbstractController // last username entered by the user $lastUsername = $authenticationUtils->getLastUsername(); + // Check if password login is allowed + $allowPasswordLogin = $this->settingsService->isPasswordLoginAllowed(); + return $this->render('security/login.html.twig', [ 'last_username' => $lastUsername, 'error' => $error, + 'allow_password_login' => $allowPasswordLogin, ]); } diff --git a/src/Controller/SettingsController.php b/src/Controller/SettingsController.php new file mode 100644 index 0000000..8d1f101 --- /dev/null +++ b/src/Controller/SettingsController.php @@ -0,0 +1,54 @@ +json([ + 'settings' => $this->settingsService->getAllSettings() + ]); + } + + #[Route('', name: 'update', methods: ['PUT', 'PATCH'])] + public function updateSettings(Request $request): JsonResponse + { + $data = json_decode($request->getContent(), true); + + if (!isset($data['settings'])) { + return $this->json([ + 'error' => 'Missing settings data' + ], Response::HTTP_BAD_REQUEST); + } + + try { + $this->settingsService->updateSettings($data['settings']); + + return $this->json([ + 'success' => true, + 'settings' => $this->settingsService->getAllSettings(), + 'message' => 'Settings updated successfully' + ]); + } catch (\InvalidArgumentException $e) { + return $this->json([ + 'error' => $e->getMessage() + ], Response::HTTP_BAD_REQUEST); + } + } +} diff --git a/src/Entity/Setting.php b/src/Entity/Setting.php new file mode 100644 index 0000000..147cc47 --- /dev/null +++ b/src/Entity/Setting.php @@ -0,0 +1,127 @@ +updatedAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getSettingKey(): ?string + { + return $this->settingKey; + } + + public function setSettingKey(string $settingKey): static + { + $this->settingKey = $settingKey; + return $this; + } + + public function getSettingValue(): ?string + { + return $this->settingValue; + } + + public function setSettingValue(?string $settingValue): static + { + $this->settingValue = $settingValue; + $this->updatedAt = new \DateTimeImmutable(); + return $this; + } + + public function getSettingType(): ?string + { + return $this->settingType; + } + + public function setSettingType(string $settingType): static + { + $this->settingType = $settingType; + return $this; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(?string $description): static + { + $this->description = $description; + return $this; + } + + public function getUpdatedAt(): ?\DateTimeImmutable + { + return $this->updatedAt; + } + + public function setUpdatedAt(\DateTimeImmutable $updatedAt): static + { + $this->updatedAt = $updatedAt; + return $this; + } + + /** + * Get the typed value + */ + public function getValue(): mixed + { + return match($this->settingType) { + 'boolean' => filter_var($this->settingValue, FILTER_VALIDATE_BOOLEAN), + 'integer' => (int) $this->settingValue, + 'float' => (float) $this->settingValue, + 'json' => json_decode($this->settingValue, true), + default => $this->settingValue, + }; + } + + /** + * Set the typed value + */ + public function setValue(mixed $value): static + { + $this->settingValue = match($this->settingType) { + 'boolean' => $value ? '1' : '0', + 'integer', 'float' => (string) $value, + 'json' => json_encode($value), + default => (string) $value, + }; + $this->updatedAt = new \DateTimeImmutable(); + return $this; + } +} diff --git a/src/Entity/User.php b/src/Entity/User.php index 9f7c9bc..7255e14 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -9,12 +9,14 @@ use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Put; use ApiPlatform\Metadata\Delete; use App\Repository\UserRepository; +use App\Validator\PasswordMinLength; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Validator\Constraints as Assert; #[ORM\Entity(repositoryClass: UserRepository::class)] #[ORM\Table(name: 'users')] @@ -40,6 +42,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[ORM\Column(length: 180)] #[Groups(['user:read', 'user:write'])] + #[Assert\NotBlank(message: 'Die E-Mail-Adresse darf nicht leer sein')] + #[Assert\Email(message: 'Bitte geben Sie eine gültige E-Mail-Adresse ein')] private ?string $email = null; #[ORM\Column(length: 100)] @@ -79,6 +83,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface * Plain password for API write operations (not persisted) */ #[Groups(['user:write'])] + #[Assert\NotBlank(message: 'Das Passwort darf nicht leer sein')] + #[PasswordMinLength] private ?string $plainPassword = null; #[ORM\Column] diff --git a/src/EventListener/DuplicateEmailExceptionListener.php b/src/EventListener/DuplicateEmailExceptionListener.php new file mode 100644 index 0000000..479c241 --- /dev/null +++ b/src/EventListener/DuplicateEmailExceptionListener.php @@ -0,0 +1,48 @@ +getThrowable(); + + // Check if it's a unique constraint violation + if (!$exception instanceof UniqueConstraintViolationException) { + return; + } + + // Check if the error is related to the email field + $message = $exception->getMessage(); + if (stripos($message, 'UNIQ_IDENTIFIER_EMAIL') === false && + stripos($message, 'email') === false) { + return; + } + + // Create a user-friendly error response + $response = new JsonResponse([ + '@context' => '/api/contexts/ConstraintViolationList', + '@type' => 'ConstraintViolationList', + 'hydra:title' => 'An error occurred', + 'hydra:description' => 'Die E-Mail-Adresse wird bereits von einem anderen Benutzer verwendet', + 'violations' => [ + [ + 'propertyPath' => 'email', + 'message' => 'Die E-Mail-Adresse wird bereits von einem anderen Benutzer verwendet', + 'code' => 'DUPLICATE_EMAIL' + ] + ] + ], Response::HTTP_UNPROCESSABLE_ENTITY); + + $event->setResponse($response); + } +} diff --git a/src/Repository/SettingRepository.php b/src/Repository/SettingRepository.php new file mode 100644 index 0000000..9e1bcfb --- /dev/null +++ b/src/Repository/SettingRepository.php @@ -0,0 +1,58 @@ + + */ +class SettingRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Setting::class); + } + + /** + * Find a setting by its key + */ + public function findByKey(string $key): ?Setting + { + return $this->findOneBy(['settingKey' => $key]); + } + + /** + * Get a setting value by key with default fallback + */ + public function getValue(string $key, mixed $default = null): mixed + { + $setting = $this->findByKey($key); + return $setting ? $setting->getValue() : $default; + } + + /** + * Set a setting value by key (creates if not exists) + */ + public function setValue(string $key, mixed $value, string $type = 'string', ?string $description = null): Setting + { + $setting = $this->findByKey($key); + + if (!$setting) { + $setting = new Setting(); + $setting->setSettingKey($key); + $setting->setSettingType($type); + if ($description) { + $setting->setDescription($description); + } + $this->getEntityManager()->persist($setting); + } + + $setting->setValue($value); + $this->getEntityManager()->flush(); + + return $setting; + } +} diff --git a/src/Security/LoginFormAuthenticator.php b/src/Security/LoginFormAuthenticator.php new file mode 100644 index 0000000..738c46b --- /dev/null +++ b/src/Security/LoginFormAuthenticator.php @@ -0,0 +1,70 @@ +settingsService->isPasswordLoginAllowed()) { + throw new CustomUserMessageAuthenticationException( + 'Der Login mit E-Mail und Passwort ist derzeit deaktiviert. Bitte verwenden Sie eine alternative Anmeldemethode.' + ); + } + + $email = $request->request->get('email', ''); + $password = $request->request->get('password', ''); + + $request->getSession()->set(SecurityRequestAttributes::LAST_USERNAME, $email); + + return new Passport( + new UserBadge($email), + new PasswordCredentials($password), + [ + new CsrfTokenBadge('authenticate', $request->request->get('_csrf_token')), + new RememberMeBadge(), + ] + ); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + if ($targetPath = $this->getTargetPath($request->getSession(), $firewallName)) { + return new RedirectResponse($targetPath); + } + + return new RedirectResponse($this->router->generate('app_dashboard')); + } + + protected function getLoginUrl(Request $request): string + { + return $this->router->generate(self::LOGIN_ROUTE); + } +} diff --git a/src/Service/SettingsService.php b/src/Service/SettingsService.php new file mode 100644 index 0000000..f4da1ce --- /dev/null +++ b/src/Service/SettingsService.php @@ -0,0 +1,90 @@ +settingRepository->getValue(self::PASSWORD_MIN_LENGTH, 8); + } + + /** + * Set minimum password length + */ + public function setPasswordMinLength(int $length): void + { + if ($length < 4) { + throw new \InvalidArgumentException('Password minimum length must be at least 4 characters'); + } + if ($length > 128) { + throw new \InvalidArgumentException('Password minimum length cannot exceed 128 characters'); + } + + $this->settingRepository->setValue( + self::PASSWORD_MIN_LENGTH, + $length, + 'integer', + 'Minimum password length required' + ); + } + + /** + * Check if password login is allowed (default: true) + */ + public function isPasswordLoginAllowed(): bool + { + return (bool) $this->settingRepository->getValue(self::ALLOW_PASSWORD_LOGIN, true); + } + + /** + * Set whether password login is allowed + */ + public function setPasswordLoginAllowed(bool $allowed): void + { + $this->settingRepository->setValue( + self::ALLOW_PASSWORD_LOGIN, + $allowed, + 'boolean', + 'Allow login with email and password' + ); + } + + /** + * Get all settings as array + */ + public function getAllSettings(): array + { + return [ + 'passwordMinLength' => $this->getPasswordMinLength(), + 'allowPasswordLogin' => $this->isPasswordLoginAllowed(), + ]; + } + + /** + * Update multiple settings at once + */ + public function updateSettings(array $settings): void + { + if (isset($settings['passwordMinLength'])) { + $this->setPasswordMinLength((int) $settings['passwordMinLength']); + } + + if (isset($settings['allowPasswordLogin'])) { + $this->setPasswordLoginAllowed((bool) $settings['allowPasswordLogin']); + } + } +} diff --git a/src/Validator/PasswordMinLength.php b/src/Validator/PasswordMinLength.php new file mode 100644 index 0000000..23fce12 --- /dev/null +++ b/src/Validator/PasswordMinLength.php @@ -0,0 +1,18 @@ +settingsService->getPasswordMinLength(); + + if (mb_strlen($value) < $minLength) { + $this->context->buildViolation($constraint->message) + ->setParameter('{{ min_length }}', (string) $minLength) + ->addViolation(); + } + } +} diff --git a/templates/security/login.html.twig b/templates/security/login.html.twig index d31758a..0721fb8 100644 --- a/templates/security/login.html.twig +++ b/templates/security/login.html.twig @@ -166,50 +166,56 @@ zum Dashboard
{% else %} -
-
- - + {% if allow_password_login %} + +
+ + +
+ +
+ + +
+ + + +
+ + +
+ + + + +
+ oder
- -
- - + {% else %} +
+ Der Login mit E-Mail und Passwort ist derzeit deaktiviert. Bitte verwenden Sie eine alternative Anmeldemethode.
- - - -
- - -
- - - - -
- oder -
+ {% endif %} -
-

🔐 Test-Zugangsdaten (Development):

-
Administrator: admin@mycrm.local / admin123
-
Vertrieb: sales@mycrm.local / sales123
-
+ {% if allow_password_login %} +
+

🔐 Test-Zugangsdaten (Development):

+
Administrator: admin@mycrm.local / admin123
+
Vertrieb: sales@mycrm.local / sales123
+
+ {% endif %} {% endif %}