Skip to content

Commit

Permalink
Allow to edit the password with a token
Browse files Browse the repository at this point in the history
  • Loading branch information
marien-probesys committed Aug 16, 2024
1 parent 414bbe5 commit 4e7cb7a
Show file tree
Hide file tree
Showing 10 changed files with 342 additions and 1 deletion.
1 change: 1 addition & 0 deletions config/packages/security.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ security:
- { 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
51 changes: 51 additions & 0 deletions src/Controller/PasswordsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
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;
Expand Down Expand Up @@ -52,4 +53,54 @@ public function reset(
'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,
]);
}
}
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',
]);
}
}
16 changes: 16 additions & 0 deletions src/Repository/UserRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,22 @@ public function findByOrganizationIds(array $orgaIds, string $roleType = 'any'):
return $query->getResult();
}

public function findOneByResetPasswordToken(string $token): ?User
{
$entityManager = $this->getEntityManager();

$query = $entityManager->createQuery(<<<SQL
SELECT u
FROM App\Entity\User u
INNER JOIN u.resetPasswordToken t
WHERE t.value = :token
SQL);

$query->setParameter('token', $token);

return $query->getOneOrNullResult();
}

/**
* Used to upgrade (rehash) the user's password automatically over time.
*/
Expand Down
2 changes: 1 addition & 1 deletion templates/emails/reset_password.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
</p>

<p>
<a href="#">
<a href="{{ url('edit password', { token: token.value }) }}">
{{ 'emails.reset_password.reset' | trans }}
</a>
</p>
Expand Down
8 changes: 8 additions & 0 deletions templates/login/new.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,14 @@
{{ 'login.title' | trans }}
</h1>

{% if app.flashes('password_changed') %}
{{ include('alerts/_alert.html.twig', {
type: 'success',
title: 'login.password_changed.title' | trans,
message: 'login.password_changed.message' | trans,
}, with_context = false) }}
{% endif %}

<form action="{{ path('login') }}" method="post" class="flow flow--large" data-turbo="false">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">

Expand Down
89 changes: 89 additions & 0 deletions templates/passwords/edit.html.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
{#
# This file is part of Bileto.
# Copyright 2022-2024 Probesys
# SPDX-License-Identifier: AGPL-3.0-or-later
#}

{% extends 'base.html.twig' %}

{% set currentPage = 'edit password' %}

{% block title %}{{ 'passwords.edit.title' | trans }}{% endblock %}

{% block breadcrumb %}
<nav class="layout__breadcrumb" aria-label="{{ 'layout.breadcrumb' | trans }}">
<a href="{{ path('home') }}">
{{ 'layout.home' | trans }}
</a>

<span aria-current="page">
{{ 'passwords.edit.title' | trans }}
</span>
</nav>
{% endblock %}

{% block body %}
<main class="layout__body">
<div class="flow flow--large text--center">
<h1>{{ 'passwords.edit.title' | trans }}</h1>

<p class="wrapper wrapper--text wrapper--center">
{{ 'passwords.edit.short' | trans({ email: user.email }) | raw }}
</p>
</div>

<div class="panel">
<div class="wrapper wrapper--text wrapper--center flow flow--large">
{{ form_start(form, { attr: {
'class': 'form--standard',
}}) }}
{{ form_errors(form) }}

<div class="flow flow--small">
<label for="{{ field_id(form.plainPassword) }}">
{{ 'passwords.edit.form.password' | trans }}
</label>

{{ form_errors(form.plainPassword) }}

<div class="input-container" data-controller="password">
<input
type="password"
id="{{ field_id(form.plainPassword) }}"
name="{{ field_name(form.plainPassword) }}"
value="{{ field_value(form.plainPassword) }}"
required
autocomplete="new-password"
{% if field_has_errors(form.plainPassword) %}
aria-invalid="true"
aria-errormessage="{{ field_id(form.plainPassword, 'error') }}"
{% endif %}
data-password-target="input"
/>

<button
class="button--icon"
type="button"
role="switch"
data-action="password#toggle"
data-password-target="button"
>
{{ icon('eye') }}
{{ icon('eye-slash') }}
<span class="sr-only">
{{ 'forms.show_password' | trans }}
</span>
</button>
</div>
</div>

<div class="form__actions">
<button class="button--primary" type="submit">
{{ 'passwords.edit.form.submit' | trans }}
</button>
</div>
{{ form_end(form) }}
</div>
</div>
</main>
{% endblock %}
130 changes: 130 additions & 0 deletions tests/Controller/PasswordsControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@

use App\Tests;
use App\Tests\Factory;
use App\Utils;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Zenstruck\Foundry\Test\Factories;
use Zenstruck\Foundry\Test\ResetDatabase;
Expand Down Expand Up @@ -149,4 +151,132 @@ public function testPostResetFailsIfCsrfTokenIsInvalid(): void
$this->assertNull($resetPasswordToken);
$this->assertEmailCount(0);
}

public function testGetEditRendersCorrectly(): void
{
$client = static::createClient();
$token = Factory\TokenFactory::createOne([
'expiredAt' => Utils\Time::fromNow(2, 'hours'),
]);
$user = Factory\UserFactory::createOne([
'resetPasswordToken' => $token,
'ldapIdentifier' => null,
]);

$client->request(Request::METHOD_GET, "/passwords/{$token->getValue()}/edit");

$this->assertResponseIsSuccessful();
$this->assertSelectorTextContains('h1', 'Changing password');
}

public function testGetEditFailsIfTokenIsNotAssociatedToAUser(): void
{
$this->expectException(NotFoundHttpException::class);

$client = static::createClient();
$token = Factory\TokenFactory::createOne([
'expiredAt' => Utils\Time::fromNow(2, 'hours'),
]);
$user = Factory\UserFactory::createOne([
'resetPasswordToken' => null,
'ldapIdentifier' => null,
]);

$client->catchExceptions(false);
$client->request(Request::METHOD_GET, "/passwords/{$token->getValue()}/edit");
}

public function testGetEditFailsIfTokenIsExpired(): void
{
$this->expectException(NotFoundHttpException::class);

$client = static::createClient();
$token = Factory\TokenFactory::createOne([
'expiredAt' => Utils\Time::ago(2, 'hours'),
]);
$user = Factory\UserFactory::createOne([
'resetPasswordToken' => $token,
'ldapIdentifier' => null,
]);

$client->catchExceptions(false);
$client->request(Request::METHOD_GET, "/passwords/{$token->getValue()}/edit");
}

public function testGetEditFailsIfUserIsManagedByLdap(): void
{
$this->expectException(NotFoundHttpException::class);

$client = static::createClient();
$token = Factory\TokenFactory::createOne([
'expiredAt' => Utils\Time::fromNow(2, 'hours'),
]);
$user = Factory\UserFactory::createOne([
'resetPasswordToken' => $token,
'ldapIdentifier' => 'alix',
]);

$client->catchExceptions(false);
$client->request(Request::METHOD_GET, "/passwords/{$token->getValue()}/edit");
}

public function testPostEditChangesThePasswordAndRedirects(): void
{
$client = static::createClient();
/** @var \Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface */
$passwordHasher = self::getContainer()->get('security.user_password_hasher');
$token = Factory\TokenFactory::createOne([
'expiredAt' => Utils\Time::fromNow(2, 'hours'),
]);
$initialPassword = 'a password';
$newPassword = 'secret';
$user = Factory\UserFactory::createOne([
'resetPasswordToken' => $token,
'ldapIdentifier' => null,
'password' => $initialPassword,
]);

$client->request(Request::METHOD_POST, "/passwords/{$token->getValue()}/edit", [
'edit_password' => [
'_token' => $this->generateCsrfToken($client, 'edit password'),
'plainPassword' => $newPassword,
],
]);

$this->assertResponseRedirects('/login', 302);
$user->_refresh();
$this->assertFalse($passwordHasher->isPasswordValid($user->_real(), $initialPassword));
$this->assertTrue($passwordHasher->isPasswordValid($user->_real(), $newPassword));
Factory\TokenFactory::assert()->notExists(['id' => $token->getId()]);
}

public function testPostEditFailsIfCsrfTokenIsInvalid(): void
{
$client = static::createClient();
/** @var \Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface */
$passwordHasher = self::getContainer()->get('security.user_password_hasher');
$token = Factory\TokenFactory::createOne([
'expiredAt' => Utils\Time::fromNow(2, 'hours'),
]);
$initialPassword = 'a password';
$newPassword = 'secret';
$user = Factory\UserFactory::createOne([
'resetPasswordToken' => $token,
'ldapIdentifier' => null,
'password' => $initialPassword,
]);

$client->request(Request::METHOD_POST, "/passwords/{$token->getValue()}/edit", [
'edit_password' => [
'_token' => 'not a token',
'plainPassword' => $newPassword,
],
]);

$this->assertSelectorTextContains('#edit_password-error', 'The security token is invalid');
$user->_refresh();
$this->assertTrue($passwordHasher->isPasswordValid($user->_real(), $initialPassword));
$this->assertFalse($passwordHasher->isPasswordValid($user->_real(), $newPassword));
Factory\TokenFactory::assert()->exists(['id' => $token->getId()]);
}
}
Loading

0 comments on commit 4e7cb7a

Please sign in to comment.