Skip to content

Commit

Permalink
new: Allow to reset passwords
Browse files Browse the repository at this point in the history
  • Loading branch information
marien-probesys committed Aug 16, 2024
2 parents fa4b5a2 + 4e7cb7a commit 6e4e8bb
Show file tree
Hide file tree
Showing 21 changed files with 1,103 additions and 0 deletions.
2 changes: 2 additions & 0 deletions config/packages/security.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ security:
access_control:
- { path: ^/about, role: PUBLIC_ACCESS }
- { path: ^/login, role: PUBLIC_ACCESS }
- { path: ^/passwords/reset, role: PUBLIC_ACCESS }
- { path: ^/passwords/(\w+)/edit, role: PUBLIC_ACCESS }
- { path: ^/session/locale, role: PUBLIC_ACCESS }
- { path: ^/app.manifest, role: PUBLIC_ACCESS }
- { path: ^/, role: ROLE_USER }
Expand Down
66 changes: 66 additions & 0 deletions migrations/Version20240814123823AddToken.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

// This file is part of Bileto.
// Copyright 2022-2024 Probesys
// SPDX-License-Identifier: AGPL-3.0-or-later

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Platforms\MariaDBPlatform;
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

// phpcs:disable Generic.Files.LineLength
final class Version20240814123823AddToken extends AbstractMigration
{
public function getDescription(): string
{
return 'Add the token table and the users.resetPasswordToken column';
}

public function up(Schema $schema): void
{
$dbPlatform = $this->connection->getDatabasePlatform();
if ($dbPlatform instanceof PostgreSQLPlatform) {
$this->addSql('CREATE TABLE token (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, value VARCHAR(250) NOT NULL, expired_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, description VARCHAR(250) NOT NULL, created_by_id INT DEFAULT NULL, updated_by_id INT DEFAULT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE UNIQUE INDEX UNIQ_5F37A13B1D775834 ON token (value)');
$this->addSql('CREATE INDEX IDX_5F37A13BB03A8386 ON token (created_by_id)');
$this->addSql('CREATE INDEX IDX_5F37A13B896DBBDE ON token (updated_by_id)');
$this->addSql('ALTER TABLE token ADD CONSTRAINT FK_5F37A13BB03A8386 FOREIGN KEY (created_by_id) REFERENCES "users" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE token ADD CONSTRAINT FK_5F37A13B896DBBDE FOREIGN KEY (updated_by_id) REFERENCES "users" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE users ADD reset_password_token_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE users ADD CONSTRAINT FK_1483A5E92C3F5AA9 FOREIGN KEY (reset_password_token_id) REFERENCES token (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE UNIQUE INDEX UNIQ_1483A5E92C3F5AA9 ON users (reset_password_token_id)');
} elseif ($dbPlatform instanceof MariaDBPlatform) {
$this->addSql('CREATE TABLE token (id INT AUTO_INCREMENT NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, value VARCHAR(250) NOT NULL, expired_at DATETIME NOT NULL, description VARCHAR(250) NOT NULL, created_by_id INT DEFAULT NULL, updated_by_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_5F37A13B1D775834 (value), INDEX IDX_5F37A13BB03A8386 (created_by_id), INDEX IDX_5F37A13B896DBBDE (updated_by_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`');
$this->addSql('ALTER TABLE token ADD CONSTRAINT FK_5F37A13BB03A8386 FOREIGN KEY (created_by_id) REFERENCES `users` (id)');
$this->addSql('ALTER TABLE token ADD CONSTRAINT FK_5F37A13B896DBBDE FOREIGN KEY (updated_by_id) REFERENCES `users` (id)');
$this->addSql('ALTER TABLE users ADD reset_password_token_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE users ADD CONSTRAINT FK_1483A5E92C3F5AA9 FOREIGN KEY (reset_password_token_id) REFERENCES token (id) ON DELETE SET NULL');
$this->addSql('CREATE UNIQUE INDEX UNIQ_1483A5E92C3F5AA9 ON users (reset_password_token_id)');
}
}

public function down(Schema $schema): void
{
$dbPlatform = $this->connection->getDatabasePlatform();
if ($dbPlatform instanceof PostgreSQLPlatform) {
$this->addSql('ALTER TABLE token DROP CONSTRAINT FK_5F37A13BB03A8386');
$this->addSql('ALTER TABLE token DROP CONSTRAINT FK_5F37A13B896DBBDE');
$this->addSql('DROP TABLE token');
$this->addSql('ALTER TABLE "users" DROP CONSTRAINT FK_1483A5E92C3F5AA9');
$this->addSql('DROP INDEX UNIQ_1483A5E92C3F5AA9');
$this->addSql('ALTER TABLE "users" DROP reset_password_token_id');
} elseif ($dbPlatform instanceof MariaDBPlatform) {
$this->addSql('ALTER TABLE token DROP FOREIGN KEY FK_5F37A13BB03A8386');
$this->addSql('ALTER TABLE token DROP FOREIGN KEY FK_5F37A13B896DBBDE');
$this->addSql('DROP TABLE token');
$this->addSql('ALTER TABLE `users` DROP FOREIGN KEY FK_1483A5E92C3F5AA9');
$this->addSql('DROP INDEX UNIQ_1483A5E92C3F5AA9 ON `users`');
$this->addSql('ALTER TABLE `users` DROP reset_password_token_id');
}
}
}
106 changes: 106 additions & 0 deletions src/Controller/PasswordsController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<?php

// This file is part of Bileto.
// Copyright 2022-2024 Probesys
// SPDX-License-Identifier: AGPL-3.0-or-later

namespace App\Controller;

use App\Entity;
use App\Form;
use App\Message;
use App\Repository;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Annotation\Route;

class PasswordsController extends BaseController
{
#[Route('/passwords/reset', name: 'reset password')]
public function reset(
Request $request,
Repository\UserRepository $userRepository,
MessageBusInterface $bus,
): Response {
$sent = $request->query->getBoolean('sent');

$form = $this->createNamedForm('reset_password', Form\Password\ResetForm::class);
$form->handleRequest($request);

if ($form->isSubmitted() && $form->isValid()) {
$data = $form->getData();
$user = $data['user'];

$token = Entity\Token::create(
2,
'hours',
length: 20,
description: "Reset password token for {$user->getEmail()}"
);
$user->setResetPasswordToken($token);

$userRepository->save($user, true);

$bus->dispatch(new Message\SendResetPasswordEmail($user->getId()));

return $this->redirectToRoute('reset password', ['sent' => true]);
}

return $this->render('passwords/reset.html.twig', [
'form' => $form,
'emailSent' => $sent,
]);
}

#[Route('/passwords/{token}/edit', name: 'edit password')]
public function edit(
string $token,
Request $request,
Repository\TokenRepository $tokenRepository,
Repository\UserRepository $userRepository,
#[Autowire(env: 'bool:LDAP_ENABLED')]
bool $ldapEnabled,
): Response {
$user = $userRepository->findOneByResetPasswordToken($token);

if (!$user) {
throw $this->createNotFoundException('The token does not exist.');
}

$resetPasswordToken = $user->getResetPasswordToken();

if (!$resetPasswordToken || !$resetPasswordToken->isValid()) {
throw $this->createNotFoundException('The token does not exist.');
}

$managedByLdap = $ldapEnabled && $user->getAuthType() === 'ldap';

if ($managedByLdap) {
throw $this->createNotFoundException('The user is managed by LDAP.');
}

$form = $this->createNamedForm('edit_password', Form\Password\EditForm::class, $user);
$form->handleRequest($request);

if ($form->isSubmitted() && $form->isValid()) {
$user = $form->getData();

$user->setResetPasswordToken(null);

$userRepository->save($user, true);

$tokenRepository->remove($resetPasswordToken, true);

$this->addFlash('password_changed', true);

return $this->redirectToRoute('login');
}

return $this->render('passwords/edit.html.twig', [
'form' => $form,
'user' => $user,
]);
}
}
115 changes: 115 additions & 0 deletions src/Entity/Token.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<?php

// This file is part of Bileto.
// Copyright 2022-2024 Probesys
// SPDX-License-Identifier: AGPL-3.0-or-later

namespace App\Entity;

use App\ActivityMonitor;
use App\Repository;
use App\Utils;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Translation\TranslatableMessage;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;

#[ORM\Entity(repositoryClass: Repository\TokenRepository::class)]
#[UniqueEntity(
fields: 'value',
message: new TranslatableMessage('token.value.already_used', [], 'errors'),
)]
class Token implements ActivityMonitor\MonitorableEntityInterface
{
use ActivityMonitor\MonitorableEntityTrait;

#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;

#[ORM\Column(type: Types::DATETIMETZ_IMMUTABLE)]
private ?\DateTimeImmutable $createdAt = null;

#[ORM\ManyToOne]
private ?User $createdBy = null;

#[ORM\Column(type: Types::DATETIMETZ_IMMUTABLE)]
private ?\DateTimeImmutable $updatedAt = null;

#[ORM\ManyToOne]
private ?User $updatedBy = null;

#[ORM\Column(length: 250, unique: true)]
private ?string $value = null;

#[ORM\Column(type: Types::DATETIMETZ_IMMUTABLE)]
private ?\DateTimeImmutable $expiredAt = null;

#[ORM\Column(length: 250)]
private ?string $description = null;

public static function create(int $number, string $unit, int $length = 20, string $description = ''): self
{
$token = new self();

$value = Utils\Random::hex($length);
$token->setValue($value);

$expiredAt = Utils\Time::fromNow($number, $unit);
$token->setExpiredAt($expiredAt);

$token->setDescription($description);

return $token;
}

public function __construct()
{
$this->value = '';
$this->description = '';
$this->expiredAt = Utils\Time::now();
}

public function getValue(): ?string
{
return $this->value;
}

public function setValue(string $value): static
{
$this->value = $value;

return $this;
}

public function getExpiredAt(): ?\DateTimeImmutable
{
return $this->expiredAt;
}

public function setExpiredAt(\DateTimeImmutable $expiredAt): static
{
$this->expiredAt = $expiredAt;

return $this;
}

public function isValid(): bool
{
return Utils\Time::now() < $this->expiredAt;
}

public function getDescription(): ?string
{
return $this->description;
}

public function setDescription(string $description): static
{
$this->description = $description;

return $this;
}
}
16 changes: 16 additions & 0 deletions src/Entity/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ class User implements
#[ORM\ManyToMany(targetEntity: Team::class, mappedBy: 'agents')]
private Collection $teams;

#[ORM\OneToOne(cascade: ['persist', 'remove'])]
#[ORM\JoinColumn(onDelete: 'SET NULL')]
private ?Token $resetPasswordToken = null;

public function __construct()
{
$this->password = '';
Expand Down Expand Up @@ -301,4 +305,16 @@ public function getTeams(): Collection
{
return $this->teams;
}

public function getResetPasswordToken(): ?Token
{
return $this->resetPasswordToken;
}

public function setResetPasswordToken(?Token $resetPasswordToken): static
{
$this->resetPasswordToken = $resetPasswordToken;

return $this;
}
}
34 changes: 34 additions & 0 deletions src/Form/Password/EditForm.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

// This file is part of Bileto.
// Copyright 2022-2024 Probesys
// SPDX-License-Identifier: AGPL-3.0-or-later

namespace App\Form\Password;

use App\Entity;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class EditForm extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('plainPassword', Type\PasswordType::class, [
'empty_data' => '',
'hash_property_path' => 'password',
'mapped' => false,
]);
}

public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Entity\User::class,
'csrf_token_id' => 'edit password',
'csrf_message' => 'csrf.invalid',
]);
}
}
Loading

0 comments on commit 6e4e8bb

Please sign in to comment.