feat: Implement password setup functionality with email notifications and token management
This commit is contained in:
parent
47b5dd1c23
commit
cd3eb6afed
3
.env
3
.env
@ -17,6 +17,7 @@
|
|||||||
###> symfony/framework-bundle ###
|
###> symfony/framework-bundle ###
|
||||||
APP_ENV=dev
|
APP_ENV=dev
|
||||||
APP_SECRET=83df005f029c92c8e01026218f588371
|
APP_SECRET=83df005f029c92c8e01026218f588371
|
||||||
|
APP_URL=http://localhost:8000
|
||||||
###< symfony/framework-bundle ###
|
###< symfony/framework-bundle ###
|
||||||
|
|
||||||
###> symfony/routing ###
|
###> symfony/routing ###
|
||||||
@ -43,7 +44,7 @@ MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
|
|||||||
###< symfony/messenger ###
|
###< symfony/messenger ###
|
||||||
|
|
||||||
###> symfony/mailer ###
|
###> symfony/mailer ###
|
||||||
MAILER_DSN=null://null
|
MAILER_DSN=smtp://o.schwarten@osdata.org:pOlygon089@linus.osdata.org:587
|
||||||
###< symfony/mailer ###
|
###< symfony/mailer ###
|
||||||
|
|
||||||
###> nelmio/cors-bundle ###
|
###> nelmio/cors-bundle ###
|
||||||
|
|||||||
@ -120,7 +120,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-field" v-if="!isEditMode || showPasswordField">
|
<div class="form-field" v-if="!isEditMode || showPasswordField">
|
||||||
<label for="password">{{ isEditMode ? 'Neues Passwort' : 'Passwort' }} {{ isEditMode ? '' : '*' }}</label>
|
<label for="password">
|
||||||
|
{{ isEditMode ? 'Neues Passwort' : 'Passwort (optional)' }}
|
||||||
|
</label>
|
||||||
<Password
|
<Password
|
||||||
id="password"
|
id="password"
|
||||||
v-model="formData.plainPassword"
|
v-model="formData.plainPassword"
|
||||||
@ -129,6 +131,11 @@
|
|||||||
:feedback="false"
|
:feedback="false"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
/>
|
/>
|
||||||
|
<small class="form-text">
|
||||||
|
<template v-if="!isEditMode">
|
||||||
|
Wenn Sie kein Passwort vergeben, erhält der Benutzer eine E-Mail mit einem Link zum Einrichten des Passworts.
|
||||||
|
</template>
|
||||||
|
</small>
|
||||||
<small class="p-error" v-if="errors.plainPassword">{{ errors.plainPassword }}</small>
|
<small class="p-error" v-if="errors.plainPassword">{{ errors.plainPassword }}</small>
|
||||||
<Button
|
<Button
|
||||||
v-if="isEditMode && !showPasswordField"
|
v-if="isEditMode && !showPasswordField"
|
||||||
@ -362,9 +369,7 @@ const validateForm = () => {
|
|||||||
if (!formData.value.firstName) errors.value.firstName = 'Vorname ist erforderlich';
|
if (!formData.value.firstName) errors.value.firstName = 'Vorname ist erforderlich';
|
||||||
if (!formData.value.lastName) errors.value.lastName = 'Nachname ist erforderlich';
|
if (!formData.value.lastName) errors.value.lastName = 'Nachname ist erforderlich';
|
||||||
if (!formData.value.email) errors.value.email = 'E-Mail ist erforderlich';
|
if (!formData.value.email) errors.value.email = 'E-Mail ist erforderlich';
|
||||||
if (!isEditMode.value && !formData.value.plainPassword) {
|
// Password is optional for new users - they will receive setup email
|
||||||
errors.value.plainPassword = 'Passwort ist erforderlich';
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.keys(errors.value).length === 0;
|
return Object.keys(errors.value).length === 0;
|
||||||
};
|
};
|
||||||
@ -708,5 +713,13 @@ onMounted(() => {
|
|||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: var(--p-text-muted-color);
|
color: var(--p-text-muted-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-text {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--p-text-muted-color);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -13,7 +13,7 @@ framework:
|
|||||||
max_retries: 3
|
max_retries: 3
|
||||||
multiplier: 2
|
multiplier: 2
|
||||||
failed: 'doctrine://default?queue_name=failed'
|
failed: 'doctrine://default?queue_name=failed'
|
||||||
# sync: 'sync://'
|
sync: 'sync://'
|
||||||
|
|
||||||
default_bus: messenger.bus.default
|
default_bus: messenger.bus.default
|
||||||
|
|
||||||
@ -21,7 +21,7 @@ framework:
|
|||||||
messenger.bus.default: []
|
messenger.bus.default: []
|
||||||
|
|
||||||
routing:
|
routing:
|
||||||
Symfony\Component\Mailer\Messenger\SendEmailMessage: async
|
Symfony\Component\Mailer\Messenger\SendEmailMessage: sync
|
||||||
Symfony\Component\Notifier\Message\ChatMessage: async
|
Symfony\Component\Notifier\Message\ChatMessage: async
|
||||||
Symfony\Component\Notifier\Message\SmsMessage: async
|
Symfony\Component\Notifier\Message\SmsMessage: async
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
# Put parameters here that don't need to change on each machine where the app is deployed
|
# Put parameters here that don't need to change on each machine where the app is deployed
|
||||||
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
|
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
|
||||||
parameters:
|
parameters:
|
||||||
|
app.url: '%env(APP_URL)%'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# default configuration for services in *this* file
|
# default configuration for services in *this* file
|
||||||
@ -28,3 +29,14 @@ services:
|
|||||||
decorates: 'api_platform.doctrine.orm.state.persist_processor'
|
decorates: 'api_platform.doctrine.orm.state.persist_processor'
|
||||||
arguments:
|
arguments:
|
||||||
$processor: '@.inner'
|
$processor: '@.inner'
|
||||||
|
|
||||||
|
# Password Setup Service with APP_URL parameter
|
||||||
|
App\Service\PasswordSetupService:
|
||||||
|
arguments:
|
||||||
|
$appUrl: '%app.url%'
|
||||||
|
$projectDir: '%kernel.project_dir%'
|
||||||
|
|
||||||
|
# Mailer Logger with project directory
|
||||||
|
App\EventListener\MailerLoggerListener:
|
||||||
|
arguments:
|
||||||
|
$projectDir: '%kernel.project_dir%'
|
||||||
|
|||||||
31
migrations/Version20251108170818.php
Normal file
31
migrations/Version20251108170818.php
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20251108170818 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE users ADD password_setup_token VARCHAR(255) DEFAULT NULL, ADD password_setup_token_expires_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\'');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE users DROP password_setup_token, DROP password_setup_token_expires_at');
|
||||||
|
}
|
||||||
|
}
|
||||||
69
src/Controller/PasswordSetupController.php
Normal file
69
src/Controller/PasswordSetupController.php
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Service\PasswordSetupService;
|
||||||
|
use App\Service\SettingsService;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
class PasswordSetupController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private PasswordSetupService $passwordSetupService,
|
||||||
|
private SettingsService $settingsService,
|
||||||
|
private UserPasswordHasherInterface $passwordHasher,
|
||||||
|
private EntityManagerInterface $entityManager
|
||||||
|
) {}
|
||||||
|
|
||||||
|
#[Route('/password-setup/{token}', name: 'app_password_setup', methods: ['GET', 'POST'])]
|
||||||
|
public function passwordSetup(string $token, Request $request): Response
|
||||||
|
{
|
||||||
|
$user = $this->passwordSetupService->getUserByToken($token);
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
return $this->render('security/password_setup_invalid.html.twig');
|
||||||
|
}
|
||||||
|
|
||||||
|
$error = null;
|
||||||
|
$minLength = $this->settingsService->getPasswordMinLength();
|
||||||
|
|
||||||
|
if ($request->isMethod('POST')) {
|
||||||
|
$password = $request->request->get('password');
|
||||||
|
$passwordConfirm = $request->request->get('password_confirm');
|
||||||
|
|
||||||
|
// Validate password
|
||||||
|
if (empty($password)) {
|
||||||
|
$error = 'Das Passwort darf nicht leer sein';
|
||||||
|
} elseif (mb_strlen($password) < $minLength) {
|
||||||
|
$error = "Das Passwort muss mindestens {$minLength} Zeichen lang sein";
|
||||||
|
} elseif ($password !== $passwordConfirm) {
|
||||||
|
$error = 'Die Passwörter stimmen nicht überein';
|
||||||
|
} else {
|
||||||
|
// Hash and save password
|
||||||
|
$hashedPassword = $this->passwordHasher->hashPassword($user, $password);
|
||||||
|
$user->setPassword($hashedPassword);
|
||||||
|
$user->setPasswordSetupToken(null);
|
||||||
|
$user->setPasswordSetupTokenExpiresAt(null);
|
||||||
|
$user->setIsActive(true);
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_login', [
|
||||||
|
'password_set' => 1
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('security/password_setup.html.twig', [
|
||||||
|
'user' => $user,
|
||||||
|
'token' => $token,
|
||||||
|
'error' => $error,
|
||||||
|
'min_length' => $minLength
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -83,10 +83,15 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
* Plain password for API write operations (not persisted)
|
* Plain password for API write operations (not persisted)
|
||||||
*/
|
*/
|
||||||
#[Groups(['user:write'])]
|
#[Groups(['user:write'])]
|
||||||
#[Assert\NotBlank(message: 'Das Passwort darf nicht leer sein')]
|
|
||||||
#[PasswordMinLength]
|
#[PasswordMinLength]
|
||||||
private ?string $plainPassword = null;
|
private ?string $plainPassword = null;
|
||||||
|
|
||||||
|
#[ORM\Column(nullable: true)]
|
||||||
|
private ?string $passwordSetupToken = null;
|
||||||
|
|
||||||
|
#[ORM\Column(nullable: true)]
|
||||||
|
private ?\DateTimeImmutable $passwordSetupTokenExpiresAt = null;
|
||||||
|
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
#[Groups(['user:read'])]
|
#[Groups(['user:read'])]
|
||||||
private ?\DateTimeImmutable $createdAt = null;
|
private ?\DateTimeImmutable $createdAt = null;
|
||||||
@ -297,6 +302,36 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getPasswordSetupToken(): ?string
|
||||||
|
{
|
||||||
|
return $this->passwordSetupToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setPasswordSetupToken(?string $passwordSetupToken): static
|
||||||
|
{
|
||||||
|
$this->passwordSetupToken = $passwordSetupToken;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPasswordSetupTokenExpiresAt(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->passwordSetupTokenExpiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setPasswordSetupTokenExpiresAt(?\DateTimeImmutable $passwordSetupTokenExpiresAt): static
|
||||||
|
{
|
||||||
|
$this->passwordSetupTokenExpiresAt = $passwordSetupTokenExpiresAt;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isPasswordSetupTokenValid(): bool
|
||||||
|
{
|
||||||
|
if (!$this->passwordSetupToken || !$this->passwordSetupTokenExpiresAt) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return $this->passwordSetupTokenExpiresAt > new \DateTimeImmutable();
|
||||||
|
}
|
||||||
|
|
||||||
public function __toString(): string
|
public function __toString(): string
|
||||||
{
|
{
|
||||||
return $this->getFullName();
|
return $this->getFullName();
|
||||||
|
|||||||
183
src/EventListener/MailerLoggerListener.php
Normal file
183
src/EventListener/MailerLoggerListener.php
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\EventListener;
|
||||||
|
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||||
|
use Symfony\Component\Mailer\Event\FailedMessageEvent;
|
||||||
|
use Symfony\Component\Mailer\Event\MessageEvent;
|
||||||
|
use Symfony\Component\Mailer\Event\SentMessageEvent;
|
||||||
|
use Symfony\Component\Mime\Address;
|
||||||
|
use Symfony\Component\Mime\Email;
|
||||||
|
use Symfony\Component\Mime\RawMessage;
|
||||||
|
|
||||||
|
#[AsEventListener(event: MessageEvent::class, priority: 100)]
|
||||||
|
#[AsEventListener(event: SentMessageEvent::class, priority: 100)]
|
||||||
|
#[AsEventListener(event: FailedMessageEvent::class, priority: 100)]
|
||||||
|
class MailerLoggerListener
|
||||||
|
{
|
||||||
|
private string $logFile;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private LoggerInterface $logger,
|
||||||
|
string $projectDir
|
||||||
|
) {
|
||||||
|
$this->logFile = $projectDir . '/var/log/mail.log';
|
||||||
|
|
||||||
|
// Create log directory if it doesn't exist
|
||||||
|
$logDir = dirname($this->logFile);
|
||||||
|
if (!is_dir($logDir)) {
|
||||||
|
mkdir($logDir, 0777, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __invoke(MessageEvent|SentMessageEvent|FailedMessageEvent $event): void
|
||||||
|
{
|
||||||
|
if ($event instanceof FailedMessageEvent) {
|
||||||
|
$this->logFailedEmail($event);
|
||||||
|
} elseif ($event instanceof SentMessageEvent) {
|
||||||
|
$this->logSentEmail($event);
|
||||||
|
} elseif ($event instanceof MessageEvent) {
|
||||||
|
$this->logQueuedEmail($event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function logQueuedEmail(MessageEvent $event): void
|
||||||
|
{
|
||||||
|
$message = $event->getMessage();
|
||||||
|
|
||||||
|
if (!$message instanceof Email) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$timestamp = date('Y-m-d H:i:s');
|
||||||
|
$from = $this->formatAddresses($message->getFrom());
|
||||||
|
$to = $this->formatAddresses($message->getTo());
|
||||||
|
$subject = $message->getSubject();
|
||||||
|
|
||||||
|
$logEntry = sprintf(
|
||||||
|
"[%s] Email queued\n" .
|
||||||
|
" From: %s\n" .
|
||||||
|
" To: %s\n" .
|
||||||
|
" Subject: %s\n" .
|
||||||
|
" ------------------------\n\n",
|
||||||
|
$timestamp,
|
||||||
|
$from,
|
||||||
|
$to,
|
||||||
|
$subject
|
||||||
|
);
|
||||||
|
|
||||||
|
file_put_contents($this->logFile, $logEntry, FILE_APPEND);
|
||||||
|
|
||||||
|
$this->logger->info('Email queued', [
|
||||||
|
'from' => $from,
|
||||||
|
'to' => $to,
|
||||||
|
'subject' => $subject
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function logSentEmail(SentMessageEvent $event): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$rawMessage = $event->getMessage()->getMessage();
|
||||||
|
|
||||||
|
if (!$rawMessage instanceof Email) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$timestamp = date('Y-m-d H:i:s');
|
||||||
|
$from = $this->formatAddresses($rawMessage->getFrom());
|
||||||
|
$to = $this->formatAddresses($rawMessage->getTo());
|
||||||
|
$subject = $rawMessage->getSubject();
|
||||||
|
|
||||||
|
$logEntry = sprintf(
|
||||||
|
"[%s] ✓ Email successfully sent\n" .
|
||||||
|
" From: %s\n" .
|
||||||
|
" To: %s\n" .
|
||||||
|
" Subject: %s\n" .
|
||||||
|
" ------------------------\n\n",
|
||||||
|
$timestamp,
|
||||||
|
$from,
|
||||||
|
$to,
|
||||||
|
$subject
|
||||||
|
);
|
||||||
|
|
||||||
|
file_put_contents($this->logFile, $logEntry, FILE_APPEND);
|
||||||
|
|
||||||
|
$this->logger->info('Email successfully sent', [
|
||||||
|
'from' => $from,
|
||||||
|
'to' => $to,
|
||||||
|
'subject' => $subject
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Silently fail if we can't extract message details
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function logFailedEmail(FailedMessageEvent $event): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$rawMessage = $event->getMessage()->getMessage();
|
||||||
|
|
||||||
|
if (!$rawMessage instanceof Email) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$timestamp = date('Y-m-d H:i:s');
|
||||||
|
$from = $this->formatAddresses($rawMessage->getFrom());
|
||||||
|
$to = $this->formatAddresses($rawMessage->getTo());
|
||||||
|
$subject = $rawMessage->getSubject();
|
||||||
|
$error = $event->getError();
|
||||||
|
$errorMessage = $error ? $error->getMessage() : 'Unknown error';
|
||||||
|
|
||||||
|
$logEntry = sprintf(
|
||||||
|
"[%s] ✗ Email FAILED\n" .
|
||||||
|
" From: %s\n" .
|
||||||
|
" To: %s\n" .
|
||||||
|
" Subject: %s\n" .
|
||||||
|
" Error: %s\n" .
|
||||||
|
" ------------------------\n\n",
|
||||||
|
$timestamp,
|
||||||
|
$from,
|
||||||
|
$to,
|
||||||
|
$subject,
|
||||||
|
$errorMessage
|
||||||
|
);
|
||||||
|
|
||||||
|
file_put_contents($this->logFile, $logEntry, FILE_APPEND);
|
||||||
|
|
||||||
|
$this->logger->error('Email failed to send', [
|
||||||
|
'from' => $from,
|
||||||
|
'to' => $to,
|
||||||
|
'subject' => $subject,
|
||||||
|
'error' => $errorMessage
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Log generic error if we can't extract message details
|
||||||
|
$timestamp = date('Y-m-d H:i:s');
|
||||||
|
$error = $event->getError();
|
||||||
|
$errorMessage = $error ? $error->getMessage() : 'Unknown error';
|
||||||
|
|
||||||
|
$logEntry = sprintf(
|
||||||
|
"[%s] ✗ Email FAILED\n" .
|
||||||
|
" Error: %s\n" .
|
||||||
|
" ------------------------\n\n",
|
||||||
|
$timestamp,
|
||||||
|
$errorMessage
|
||||||
|
);
|
||||||
|
|
||||||
|
file_put_contents($this->logFile, $logEntry, FILE_APPEND);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Address[] $addresses
|
||||||
|
*/
|
||||||
|
private function formatAddresses(array $addresses): string
|
||||||
|
{
|
||||||
|
return implode(', ', array_map(
|
||||||
|
fn(Address $addr) => sprintf('%s <%s>', $addr->getName(), $addr->getAddress()),
|
||||||
|
$addresses
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
203
src/Service/PasswordSetupService.php
Normal file
203
src/Service/PasswordSetupService.php
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Entity\User;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\Mailer\MailerInterface;
|
||||||
|
use Symfony\Component\Mime\Email;
|
||||||
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
|
|
||||||
|
class PasswordSetupService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private EntityManagerInterface $entityManager,
|
||||||
|
private MailerInterface $mailer,
|
||||||
|
private UrlGeneratorInterface $urlGenerator,
|
||||||
|
private string $appUrl,
|
||||||
|
private string $projectDir
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a secure token and send setup email
|
||||||
|
*/
|
||||||
|
public function sendPasswordSetupEmail(User $user): void
|
||||||
|
{
|
||||||
|
$logFile = $this->projectDir . '/var/log/mail.log';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Generate secure random token
|
||||||
|
$token = bin2hex(random_bytes(32));
|
||||||
|
|
||||||
|
// Set token and expiration (24 hours)
|
||||||
|
$user->setPasswordSetupToken($token);
|
||||||
|
$user->setPasswordSetupTokenExpiresAt(
|
||||||
|
new \DateTimeImmutable('+24 hours')
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
// Generate setup URL
|
||||||
|
$setupUrl = $this->appUrl . '/password-setup/' . $token;
|
||||||
|
|
||||||
|
// Send email
|
||||||
|
$email = (new Email())
|
||||||
|
->from('noreply@mycrm.local')
|
||||||
|
->to($user->getEmail())
|
||||||
|
->subject('Willkommen bei myCRM - Passwort einrichten')
|
||||||
|
->html($this->getEmailTemplate($user, $setupUrl));
|
||||||
|
|
||||||
|
$this->mailer->send($email);
|
||||||
|
|
||||||
|
// Log success
|
||||||
|
$logEntry = sprintf(
|
||||||
|
"[%s] Password setup email queued successfully\n" .
|
||||||
|
" User: %s %s (%s)\n" .
|
||||||
|
" Token expires: %s\n" .
|
||||||
|
" Setup URL: %s\n" .
|
||||||
|
" ------------------------\n\n",
|
||||||
|
date('Y-m-d H:i:s'),
|
||||||
|
$user->getFirstName(),
|
||||||
|
$user->getLastName(),
|
||||||
|
$user->getEmail(),
|
||||||
|
$user->getPasswordSetupTokenExpiresAt()->format('Y-m-d H:i:s'),
|
||||||
|
$setupUrl
|
||||||
|
);
|
||||||
|
file_put_contents($logFile, $logEntry, FILE_APPEND);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Log error
|
||||||
|
$logEntry = sprintf(
|
||||||
|
"[%s] ✗ Failed to send password setup email\n" .
|
||||||
|
" User: %s %s (%s)\n" .
|
||||||
|
" Error: %s\n" .
|
||||||
|
" ------------------------\n\n",
|
||||||
|
date('Y-m-d H:i:s'),
|
||||||
|
$user->getFirstName(),
|
||||||
|
$user->getLastName(),
|
||||||
|
$user->getEmail(),
|
||||||
|
$e->getMessage()
|
||||||
|
);
|
||||||
|
file_put_contents($logFile, $logEntry, FILE_APPEND);
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate token and return user
|
||||||
|
*/
|
||||||
|
public function getUserByToken(string $token): ?User
|
||||||
|
{
|
||||||
|
$user = $this->entityManager->getRepository(User::class)
|
||||||
|
->findOneBy(['passwordSetupToken' => $token]);
|
||||||
|
|
||||||
|
if (!$user || !$user->isPasswordSetupTokenValid()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set password and clear token
|
||||||
|
*/
|
||||||
|
public function setPasswordFromToken(string $token, string $plainPassword): bool
|
||||||
|
{
|
||||||
|
$user = $this->getUserByToken($token);
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password will be hashed by UserStateProcessor
|
||||||
|
$user->setPlainPassword($plainPassword);
|
||||||
|
$user->setPasswordSetupToken(null);
|
||||||
|
$user->setPasswordSetupTokenExpiresAt(null);
|
||||||
|
$user->setIsActive(true); // Activate user when password is set
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getEmailTemplate(User $user, string $setupUrl): string
|
||||||
|
{
|
||||||
|
return <<<HTML
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 30px;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
background: #f9f9f9;
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 0 0 8px 8px;
|
||||||
|
}
|
||||||
|
.button {
|
||||||
|
display: inline-block;
|
||||||
|
background: #2563eb;
|
||||||
|
color: white;
|
||||||
|
padding: 12px 30px;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
margin-top: 30px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>📊 Willkommen bei myCRM</h1>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<p>Hallo {$user->getFirstName()},</p>
|
||||||
|
|
||||||
|
<p>Ihr Administrator hat einen Account für Sie in myCRM erstellt.</p>
|
||||||
|
|
||||||
|
<p><strong>E-Mail:</strong> {$user->getEmail()}</p>
|
||||||
|
|
||||||
|
<p>Bitte klicken Sie auf den folgenden Link, um Ihr Passwort einzurichten und Ihren Account zu aktivieren:</p>
|
||||||
|
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<a href="{$setupUrl}" class="button">Passwort einrichten</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Oder kopieren Sie diesen Link in Ihren Browser:</p>
|
||||||
|
<p style="word-break: break-all; background: white; padding: 10px; border-radius: 4px;">
|
||||||
|
{$setupUrl}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p><strong>Wichtig:</strong> Dieser Link ist 24 Stunden lang gültig.</p>
|
||||||
|
|
||||||
|
<p>Sollten Sie Probleme haben, wenden Sie sich bitte an Ihren Administrator.</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>Dies ist eine automatisch generierte E-Mail. Bitte antworten Sie nicht auf diese Nachricht.</p>
|
||||||
|
<p>© " . date('Y') . " myCRM. Alle Rechte vorbehalten.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
HTML;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,13 +5,15 @@ namespace App\State;
|
|||||||
use ApiPlatform\Metadata\Operation;
|
use ApiPlatform\Metadata\Operation;
|
||||||
use ApiPlatform\State\ProcessorInterface;
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
|
use App\Service\PasswordSetupService;
|
||||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||||
|
|
||||||
class UserPasswordHasher implements ProcessorInterface
|
class UserPasswordHasher implements ProcessorInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private ProcessorInterface $processor,
|
private ProcessorInterface $processor,
|
||||||
private UserPasswordHasherInterface $passwordHasher
|
private UserPasswordHasherInterface $passwordHasher,
|
||||||
|
private PasswordSetupService $passwordSetupService
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -21,16 +23,37 @@ class UserPasswordHasher implements ProcessorInterface
|
|||||||
return $this->processor->process($data, $operation, $uriVariables, $context);
|
return $this->processor->process($data, $operation, $uriVariables, $context);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$isNewUser = !$data->getId();
|
||||||
|
$hasPlainPassword = !empty($data->getPlainPassword());
|
||||||
|
|
||||||
// Hash plain password if provided
|
// Hash plain password if provided
|
||||||
if ($data->getPlainPassword()) {
|
if ($hasPlainPassword) {
|
||||||
$hashedPassword = $this->passwordHasher->hashPassword(
|
$hashedPassword = $this->passwordHasher->hashPassword(
|
||||||
$data,
|
$data,
|
||||||
$data->getPlainPassword()
|
$data->getPlainPassword()
|
||||||
);
|
);
|
||||||
$data->setPassword($hashedPassword);
|
$data->setPassword($hashedPassword);
|
||||||
$data->eraseCredentials();
|
$data->eraseCredentials();
|
||||||
|
} elseif ($isNewUser) {
|
||||||
|
// New user without password - set temporary random password
|
||||||
|
$tempPassword = bin2hex(random_bytes(16));
|
||||||
|
$hashedPassword = $this->passwordHasher->hashPassword($data, $tempPassword);
|
||||||
|
$data->setPassword($hashedPassword);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->processor->process($data, $operation, $uriVariables, $context);
|
// Process the user
|
||||||
|
$result = $this->processor->process($data, $operation, $uriVariables, $context);
|
||||||
|
|
||||||
|
// Send password setup email for new users without password
|
||||||
|
if ($isNewUser && !$hasPlainPassword) {
|
||||||
|
try {
|
||||||
|
$this->passwordSetupService->sendPasswordSetupEmail($data);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Log error but don't fail user creation
|
||||||
|
error_log('Failed to send password setup email: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -159,6 +159,13 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if app.request.query.get('password_set') %}
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<strong>✓ Passwort erfolgreich eingerichtet!</strong><br>
|
||||||
|
Sie können sich jetzt mit Ihrer E-Mail-Adresse und dem neu gesetzten Passwort anmelden.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if app.user %}
|
{% if app.user %}
|
||||||
<div class="alert alert-info">
|
<div class="alert alert-info">
|
||||||
Sie sind bereits angemeldet als <strong>{{ app.user.userIdentifier }}</strong>.
|
Sie sind bereits angemeldet als <strong>{{ app.user.userIdentifier }}</strong>.
|
||||||
|
|||||||
176
templates/security/password_setup.html.twig
Normal file
176
templates/security/password_setup.html.twig
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Passwort einrichten - myCRM</title>
|
||||||
|
{{ encore_entry_link_tags('app') }}
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.setup-container {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||||
|
padding: 3rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 450px;
|
||||||
|
}
|
||||||
|
.setup-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.setup-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
color: #2563eb;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.setup-header p {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.user-info {
|
||||||
|
background: #f3f4f6;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.user-info p {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
.user-info strong {
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #374151;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.form-control {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
.form-control:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #2563eb;
|
||||||
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||||
|
}
|
||||||
|
.btn-setup {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.875rem;
|
||||||
|
background: #2563eb;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.btn-setup:hover {
|
||||||
|
background: #1d4ed8;
|
||||||
|
}
|
||||||
|
.btn-setup:active {
|
||||||
|
background: #1e40af;
|
||||||
|
}
|
||||||
|
.alert {
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.alert-danger {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #991b1b;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
}
|
||||||
|
.alert-info {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1e40af;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
}
|
||||||
|
.password-hint {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="setup-container">
|
||||||
|
<div class="setup-header">
|
||||||
|
<h1>📊 myCRM</h1>
|
||||||
|
<p>Passwort einrichten</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="user-info">
|
||||||
|
<p><strong>Willkommen, {{ user.firstName }} {{ user.lastName }}!</strong></p>
|
||||||
|
<p>E-Mail: {{ user.email }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="alert alert-info">
|
||||||
|
Bitte wählen Sie ein sicheres Passwort für Ihren Account.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Neues Passwort</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
id="password"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="Mindestens {{ min_length }} Zeichen"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
minlength="{{ min_length }}"
|
||||||
|
>
|
||||||
|
<div class="password-hint">
|
||||||
|
Mindestens {{ min_length }} Zeichen erforderlich
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password_confirm">Passwort bestätigen</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="password_confirm"
|
||||||
|
id="password_confirm"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="Passwort wiederholen"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn-setup" type="submit">
|
||||||
|
Passwort speichern und Account aktivieren
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
70
templates/security/password_setup_invalid.html.twig
Normal file
70
templates/security/password_setup_invalid.html.twig
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Ungültiger Link - myCRM</title>
|
||||||
|
{{ encore_entry_link_tags('app') }}
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.error-container {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||||
|
padding: 3rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 450px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.error-icon {
|
||||||
|
font-size: 4rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #dc2626;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
color: #6b7280;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.btn-login {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.875rem 2rem;
|
||||||
|
background: #2563eb;
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.btn-login:hover {
|
||||||
|
background: #1d4ed8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="error-container">
|
||||||
|
<div class="error-icon">⚠️</div>
|
||||||
|
<h1>Link ungültig oder abgelaufen</h1>
|
||||||
|
<p>
|
||||||
|
Der Link zum Einrichten Ihres Passworts ist ungültig oder bereits abgelaufen.
|
||||||
|
Links sind 24 Stunden lang gültig.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Bitte wenden Sie sich an Ihren Administrator, um einen neuen Link anzufordern.
|
||||||
|
</p>
|
||||||
|
<a href="{{ path('app_login') }}" class="btn-login">Zur Anmeldeseite</a>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
x
Reference in New Issue
Block a user