Quite some work done here.

This commit is contained in:
Frank
2026-01-03 22:12:51 +01:00
parent af61a3b920
commit 5b6bfaf5ad
29 changed files with 658 additions and 41 deletions

0
src/Tech/.gitkeep Normal file
View File

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Tech\Controller;
use App\Tech\Repository\UserRepository;
use App\Tech\Service\EmailVerifier;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use SymfonyCasts\Bundle\VerifyEmail\Exception\VerifyEmailExceptionInterface;
class ActivationController extends AbstractController
{
#[Route('/verify/email', name: 'app_verify_email')]
public function verifyUserEmail(Request $request, UserRepository $userRepository, EmailVerifier $emailVerifier): Response
{
$id = $request->query->get('id');
if (null === $id) {
return $this->redirectToRoute('app_register');
}
$user = $userRepository->find($id);
if (null === $user) {
return $this->redirectToRoute('app_register');
}
// validate email confirmation link, sets User::isVerified=true and persists
try {
$emailVerifier->handleEmailConfirmation($request, $user);
} catch (VerifyEmailExceptionInterface $exception) {
$this->addFlash('error', $exception->getReason());
return $this->redirectToRoute('app_register');
}
$this->addFlash('success', 'Your email address has been verified. You can now log in.');
return $this->redirectToRoute('app_login');
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Tech\Controller;
use App\Tech\Entity\User;
use App\Tech\Form\RegistrationFormType;
use App\Tech\Service\EmailVerifier;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
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\Annotation\Route;
class RegistrationController extends AbstractController
{
#[Route('/register', name: 'app_register')]
public function register(Request $request, UserPasswordHasherInterface $userPasswordHasher, EntityManagerInterface $entityManager, EmailVerifier $emailVerifier): Response
{
$user = new User();
$form = $this->createForm(RegistrationFormType::class, $user);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// encode the plain password
$user->setPassword(
$userPasswordHasher->hashPassword(
$user,
$form->get('plainPassword')->getData()
)
);
$entityManager->persist($user);
$entityManager->flush();
// generate a signed url and email it to the user
$emailVerifier->sendEmailConfirmation('app_verify_email', $user,
(new TemplatedEmail())
->from('noreply@escapepage.dev')
->to($user->getEmail())
->subject('Please Confirm your Email')
->htmlTemplate('tech/registration/confirmation_email.html.twig')
);
$this->addFlash('success', 'A confirmation email has been sent to your email address.');
return $this->redirectToRoute('website_home');
}
return $this->render('tech/registration/register.html.twig', [
'registrationForm' => $form->createView(),
]);
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Tech\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
class SecurityController extends AbstractController
{
#[Route(path: '/login', name: 'app_login')]
public function login(AuthenticationUtils $authenticationUtils): Response
{
// if ($this->getUser()) {
// return $this->redirectToRoute('target_path');
// }
// get the login error if there is one
$error = $authenticationUtils->getLastAuthenticationError();
// last username entered by the user
$lastUsername = $authenticationUtils->getLastUsername();
return $this->render('tech/security/login.html.twig', ['last_username' => $lastUsername, 'error' => $error]);
}
#[Route(path: '/logout', name: 'app_logout')]
public function logout(): void
{
throw new \LogicException('This method can be blank - it will be intercepted by the logout key on your firewall.');
}
}

124
src/Tech/Entity/User.php Normal file
View File

@@ -0,0 +1,124 @@
<?php
namespace App\Tech\Entity;
use App\Tech\Repository\UserRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: '`user`')]
#[ORM\UniqueConstraint(name: 'UNIQ_IDENTIFIER_EMAIL', fields: ['email'])]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 180)]
private ?string $email = null;
/**
* @var list<string> The user roles
*/
#[ORM\Column]
private array $roles = [];
/**
* @var string The hashed password
*/
#[ORM\Column]
private ?string $password = null;
#[ORM\Column(type: 'boolean')]
private bool $isVerified = false;
public function getId(): ?int
{
return $this->id;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(string $email): static
{
$this->email = $email;
return $this;
}
/**
* A visual identifier that represents this user.
*
* @see UserInterface
*/
public function getUserIdentifier(): string
{
return (string) $this->email;
}
/**
* @see UserInterface
*
* @return list<string>
*/
public function getRoles(): array
{
$roles = $this->roles;
// guarantee every user at least has ROLE_USER
$roles[] = 'ROLE_USER';
return array_unique($roles);
}
/**
* @param list<string> $roles
*/
public function setRoles(array $roles): static
{
$this->roles = $roles;
return $this;
}
/**
* @see PasswordAuthenticatedUserInterface
*/
public function getPassword(): ?string
{
return $this->password;
}
public function setPassword(string $password): static
{
$this->password = $password;
return $this;
}
/**
* @see UserInterface
*/
public function eraseCredentials(): void
{
// If you store any temporary, sensitive data on the user, clear it here
// $this->plainPassword = null;
}
public function isVerified(): bool
{
return $this->isVerified;
}
public function setIsVerified(bool $isVerified): static
{
$this->isVerified = $isVerified;
return $this;
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Tech\Form;
use App\Tech\Entity\User;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
class RegistrationFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('email', EmailType::class)
->add('plainPassword', RepeatedType::class, [
'type' => PasswordType::class,
'mapped' => false,
'attr' => ['autocomplete' => 'new-password'],
'first_options' => ['label' => 'Password'],
'second_options' => ['label' => 'Repeat Password'],
'constraints' => [
new NotBlank([
'message' => 'Please enter a password',
]),
new Length([
'min' => 6,
'minMessage' => 'Your password should be at least {{ limit }} characters',
// max length allowed by Symfony for security reasons
'max' => 4096,
]),
],
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => User::class,
]);
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Tech\Repository;
use App\Tech\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
/**
* @extends ServiceEntityRepository<User>
*/
class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, User::class);
}
/**
* Used to upgrade (rehash) the user's password automatically over time.
*/
public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
{
if (!$user instanceof User) {
throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', $user::class));
}
$user->setPassword($newHashedPassword);
$this->getEntityManager()->persist($user);
$this->getEntityManager()->flush();
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Tech\Service;
use App\Tech\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\HttpFoundation\Request;
use SymfonyCasts\Bundle\VerifyEmail\Exception\VerifyEmailExceptionInterface;
use SymfonyCasts\Bundle\VerifyEmail\VerifyEmailHelperInterface;
class EmailVerifier
{
public function __construct(
private VerifyEmailHelperInterface $verifyEmailHelper,
private MailerInterface $mailer,
private EntityManagerInterface $entityManager
) {
}
public function sendEmailConfirmation(string $verifyEmailRouteName, User $user, TemplatedEmail $email): void
{
$signatureComponents = $this->verifyEmailHelper->generateSignature(
$verifyEmailRouteName,
(string) $user->getId(),
$user->getEmail(),
['id' => $user->getId()]
);
$context = $email->getContext();
$context['signedUrl'] = $signatureComponents->getSignedUrl();
$context['expiresAtMessageKey'] = $signatureComponents->getExpirationMessageKey();
$context['expiresAtMessageData'] = $signatureComponents->getExpirationMessageData();
$email->context($context);
$this->mailer->send($email);
}
/**
* @throws VerifyEmailExceptionInterface
*/
public function handleEmailConfirmation(Request $request, User $user): void
{
$this->verifyEmailHelper->validateEmailConfirmation($request->getUri(), (string) $user->getId(), $user->getEmail());
$user->setIsVerified(true);
$this->entityManager->persist($user);
$this->entityManager->flush();
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Tech\Service;
use App\Tech\Entity\User;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Core\User\UserCheckerInterface;
use Symfony\Component\Security\Core\User\UserInterface;
class UserChecker implements UserCheckerInterface
{
public function checkPreAuth(UserInterface $user): void
{
if (!$user instanceof User) {
return;
}
if (!$user->isVerified()) {
throw new CustomUserMessageAuthenticationException('Your email address is not verified.');
}
}
public function checkPostAuth(UserInterface $user): void
{
}
}