Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions etc/db-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,12 @@
default_value: false
public: false
description: Enable to skip the login page when using IP authentication.
- name: password_change_roles
type: array_val
default_value:
- admin
public: true
description: Roles that are allowed to change their password. Note that password change can also be enabled per team category.
- category: External systems
description: Miscellaneous configuration options.
items:
Expand Down
36 changes: 36 additions & 0 deletions webapp/migrations/Version20260118153404.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?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 Version20260118153404 extends AbstractMigration
{
public function getDescription(): string
{
return 'add allow_password_change to team_category';
}

public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE team_category ADD allow_password_change TINYINT(1) DEFAULT 0 NOT NULL COMMENT \'Are teams in this category allowed to change their own password?\'');
}

public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE team_category DROP allow_password_change');
}

public function isTransactional(): bool
{
return false;
}
}
60 changes: 60 additions & 0 deletions webapp/src/Controller/ProfileController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php declare(strict_types=1);

namespace App\Controller;

use App\Form\Type\ChangePasswordType;
use App\Service\ConfigurationService;
use App\Service\DOMJudgeService;
use App\Service\EventLogService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

#[Route(path: '/profile')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class ProfileController extends BaseController
{
public function __construct(
EntityManagerInterface $em,
DOMJudgeService $dj,
EventLogService $eventLogService,
KernelInterface $kernel,
) {
parent::__construct($em, $eventLogService, $dj, $kernel);
}

#[Route(path: '', name: 'profile_index')]
public function changePasswordAction(Request $request, UserPasswordHasherInterface $passwordHasher): Response

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we allow these changes, they might deserve an audit log message.

{
$isJury = $this->isGranted('ROLE_JURY') || $this->isGranted('ROLE_ADMIN') || $this->isGranted('ROLE_BALLOON');
$redirectRoute = $isJury ? 'jury_index' : 'team_index';

if (!$this->dj->canChangePassword()) {
throw new AccessDeniedHttpException('You are not allowed to change your password.');
}

$user = $this->dj->getUser();

Check failure on line 42 in webapp/src/Controller/ProfileController.php

View workflow job for this annotation

GitHub Actions / phpstan

Call to an undefined method App\Service\DOMJudgeService::getUser().
$form = $this->createForm(ChangePasswordType::class);
$form->handleRequest($request);

if ($form->isSubmitted() && $form->isValid()) {
$newPassword = $form->get('newPassword')->getData();

$user->setPassword($passwordHasher->hashPassword($user, $newPassword));
$this->saveEntity($user, $user->getUserid(), false);
$this->addFlash('success', 'Password changed successfully.');
return $this->redirectToRoute($redirectRoute);
}

return $this->render('profile/change_password.html.twig', [
'form' => $form,
'redirect_route' => $redirectRoute,
]);
}
}
18 changes: 18 additions & 0 deletions webapp/src/Entity/TeamCategory.php
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,13 @@ class TeamCategory extends BaseApiEntity implements
#[Serializer\Groups([ARC::GROUP_NONSTRICT])]
private bool $allow_self_registration = false;

#[ORM\Column(options: [
'comment' => 'Are teams in this category allowed to change their own password?',
'default' => 0,
])]
#[Serializer\Groups([ARC::GROUP_NONSTRICT])]
private bool $allow_password_change = false;

#[ORM\Column(
nullable: true,
options: ['comment' => 'CSS class to apply to scoreboard rows (only for TYPE_CSS_CLASS)']
Expand Down Expand Up @@ -264,6 +271,17 @@ public function getAllowSelfRegistration(): bool
return $this->allow_self_registration;
}

public function setAllowPasswordChange(bool $allowPasswordChange): TeamCategory
{
$this->allow_password_change = $allowPasswordChange;
return $this;
}

public function getAllowPasswordChange(): bool
{
return $this->allow_password_change;
}


public function hasType(int $type): bool
{
Expand Down
66 changes: 66 additions & 0 deletions webapp/src/Form/Type/ChangePasswordType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php declare(strict_types=1);

namespace App\Form\Type;

use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Security\Core\Validator\Constraints\UserPassword;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;

class ChangePasswordType extends AbstractType
{
public function __construct(
#[Autowire(param: 'min_password_length')]
private readonly int $minimumPasswordLength
) {
}

public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('currentPassword', PasswordType::class, [
'label' => 'Current password',
'mapped' => false,
'constraints' => [
new NotBlank(),
new UserPassword(),
],
'attr' => [
'autocomplete' => 'current-password',
],
]);
$builder->add('newPassword', RepeatedType::class, [
'type' => PasswordType::class,
'invalid_message' => 'The password fields must match.',
'mapped' => false,
'first_options' => [
'label' => 'New password',
'help' => sprintf('Minimum length: %d characters', $this->minimumPasswordLength),
'attr' => [
'autocomplete' => 'new-password',
'minlength' => $this->minimumPasswordLength,
],
],
'second_options' => [
'label' => 'Repeat new password',
'attr' => [
'autocomplete' => 'new-password',
'minlength' => $this->minimumPasswordLength,
],
],
'constraints' => [
new NotBlank(),
new Length([
'min' => $this->minimumPasswordLength,
]),
],
]);
$builder->add('save', SubmitType::class, [
'label' => 'Change password',
]);
}
}
9 changes: 9 additions & 0 deletions webapp/src/Form/Type/TeamCategoryType.php
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,15 @@ public function buildForm(FormBuilderInterface $builder, array $options): void
'data-off' => 'No',
],
]);
$builder->add('allow_password_change', ChoiceType::class, [
'label' => 'Allow password change',
'expanded' => true,
'choices' => [
'Yes' => true,
'No' => false,
],
'help' => 'Allow users in this category to change their own password.',
]);
$builder->add('save', SubmitType::class);
}

Expand Down
26 changes: 26 additions & 0 deletions webapp/src/Service/DOMJudgeService.php
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,32 @@
return $this->requestStack->getCurrentRequest()->cookies->get($cookieName);
}

public function canChangePassword(): bool
{
$user = $this->getUser();

Check failure on line 265 in webapp/src/Service/DOMJudgeService.php

View workflow job for this annotation

GitHub Actions / phpstan

Call to an undefined method App\Service\DOMJudgeService::getUser().
if (!$user) {
return false;
}

$roles = $user->getRoleList();
$allowedRoles = $this->config->get('password_change_roles');
foreach ($roles as $role) {
if (in_array($role, $allowedRoles)) {
return true;
}
}

if ($team = $user->getTeam()) {
foreach ($team->getCategories() as $category) {
if ($category->getAllowPasswordChange()) {
return true;
}
}
}

return false;
}

public function setCookie(
string $cookieName,
string $value = '',
Expand Down
1 change: 1 addition & 0 deletions webapp/src/Twig/TwigGlobalsExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ public function getGlobals(): array
'side-by-side' => ["name" => "Side-by-side"],
'inline' => ["name" => "Inline"],
],
'can_change_password' => $this->dj->canChangePassword(),
];
}
}
4 changes: 4 additions & 0 deletions webapp/templates/jury/menu.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,10 @@
</a>
</div>

{% if can_change_password %}
<a class="dropdown-item" href="{{ path('profile_index') }}"><i class="fas fa-key fa-fw"></i> Change password</a>
{% endif %}

<a class="dropdown-item" href="{{ path('logout') }}"><i class="fas fa-sign-out-alt fa-fw"></i>Logout </a>
</div>
</li>
Expand Down
5 changes: 5 additions & 0 deletions webapp/templates/partials/menu_login_logout_button.html.twig
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
{% if is_granted('IS_AUTHENTICATED_FULLY') %}
{% if can_change_password %}
<a class="btn btn-info btn-sm me-2" href="{{ path('profile_index') }}">
<i class="fas fa-key"></i> Change password
</a>
{% endif %}
<a class="btn btn-info btn-sm me-2" href="{{ path('logout') }}"
{% if confirmLogout is defined %}
onclick="return confirmLogout();"
Expand Down
20 changes: 20 additions & 0 deletions webapp/templates/profile/change_password.html.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{% extends is_granted('ROLE_JURY') or is_granted('ROLE_BALLOON') or is_granted('ROLE_ADMIN') ? 'jury/base.html.twig' : 'team/base.html.twig' %}

{% block title %}Change password - {{ parent() }}{% endblock %}

{% block content %}
<div class="container">
<h1 class="mt-3">Change password</h1>

<div class="row">
<div class="col-md-6">
{{ form_start(form) }}
{{ form_row(form.currentPassword) }}
{{ form_row(form.newPassword) }}
{{ form_widget(form.save, {'attr': {'class': 'btn btn-primary'}}) }}
<a href="{{ path(redirect_route) }}" class="btn btn-secondary">Cancel</a>
{{ form_end(form) }}
</div>
</div>
</div>
{% endblock %}
Loading