Skip to content

Commit

Permalink
2FA for admin #722
Browse files Browse the repository at this point in the history
  • Loading branch information
numew committed Jun 6, 2024
1 parent 03adf35 commit 17a1b50
Show file tree
Hide file tree
Showing 12 changed files with 319 additions and 4 deletions.
2 changes: 2 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
"nelmio/cors-bundle": "^2.2",
"phpdocumentor/reflection-docblock": "^5.3",
"phpstan/phpdoc-parser": "^1.2",
"scheb/2fa-bundle": "^6.12",
"scheb/2fa-email": "^6.12",
"scienta/doctrine-json-functions": "^5.0",
"sentry/sentry-symfony": "^4.3",
"symfony/apache-pack": "^1.0",
Expand Down
119 changes: 118 additions & 1 deletion composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions config/bundles.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@
League\FlysystemBundle\FlysystemBundle::class => ['all' => true],
Sentry\SentryBundle\SentryBundle::class => ['prod' => true],
DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true],
Scheb\TwoFactorBundle\SchebTwoFactorBundle::class => ['all' => true],
];
8 changes: 8 additions & 0 deletions config/packages/scheb_2fa.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# See the configuration reference at https://symfony.com/bundles/SchebTwoFactorBundle/6.x/configuration.html
scheb_two_factor:
security_tokens:
- Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken
- Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken
email:
enabled: true
mailer: App\Service\Mailer\AuthCodeMailer
13 changes: 11 additions & 2 deletions config/packages/security.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ security:
lazy: true
provider: app_user_provider
custom_authenticator: App\Security\BackOfficeAuthenticator
two_factor:
auth_form_path: 2fa_login
check_path: 2fa_login_check
logout:
path: app_logout
# where to redirect after logout
Expand All @@ -44,7 +47,13 @@ security:
# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
access_control:
- { path: ^/bo, roles: ROLE_USER_PARTNER }
# This makes the logout route accessible during two-factor authentication. Allows the user to
# cancel two-factor authentication, if they need to.
- { path: ^/logout, role: PUBLIC_ACCESS }
# This ensures that the form can only be accessed when two-factor authentication is in progress.
- { path: ^/2fa, role: IS_AUTHENTICATED_2FA_IN_PROGRESS }
# Other rules may follow here...
- { path: ^/bo, roles: ROLE_USER_PARTNER }

when@prod:
security:
Expand All @@ -65,4 +74,4 @@ when@test:
algorithm: auto
cost: 4 # Lowest possible value for bcrypt
time_cost: 3 # Lowest possible value for argon
memory_cost: 10 # Lowest possible value for argon
memory_cost: 10 # Lowest possible value for argon
7 changes: 7 additions & 0 deletions config/routes/scheb_2fa.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
2fa_login:
path: /2fa
defaults:
_controller: "scheb_two_factor.form_controller::form"

2fa_login_check:
path: /2fa_check
26 changes: 26 additions & 0 deletions migrations/Version20240605094114.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20240605094114 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add auth_code column to user table';
}

public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE user ADD auth_code VARCHAR(255) DEFAULT NULL');
}

public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE user DROP auth_code');
}
}
30 changes: 29 additions & 1 deletion src/Entity/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Scheb\TwoFactorBundle\Model\Email\TwoFactorInterface;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
Expand All @@ -18,7 +19,7 @@
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[UniqueEntity('email', message: '{{ value }} existe déja, merci de saisir un nouvel e-mail')]
#[ORM\HasLifecycleCallbacks()]
class User implements UserInterface, PasswordAuthenticatedUserInterface
class User implements UserInterface, PasswordAuthenticatedUserInterface, TwoFactorInterface
{
use TimestampableTrait;

Expand Down Expand Up @@ -141,6 +142,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $anonymizedAt = null;

#[ORM\Column(type: 'string', nullable: true)]
private ?string $authCode;

public function __construct()
{
$this->suivis = new ArrayCollection();
Expand Down Expand Up @@ -570,4 +574,28 @@ public function anonymize(): static

return $this;
}

public function isEmailAuthEnabled(): bool
{
return \in_array('ROLE_ADMIN', $this->getRoles());
}

public function getEmailAuthRecipient(): string
{
return $this->email;
}

public function getEmailAuthCode(): string
{
if (null === $this->authCode) {
throw new \LogicException('The email authentication code was not set');
}

return $this->authCode;
}

public function setEmailAuthCode(string $authCode): void
{
$this->authCode = $authCode;
}
}
30 changes: 30 additions & 0 deletions src/Service/Mailer/AuthCodeMailer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

namespace App\Service\Mailer;

use Scheb\TwoFactorBundle\Mailer\AuthCodeMailerInterface;
use Scheb\TwoFactorBundle\Model\Email\TwoFactorInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;

class AuthCodeMailer implements AuthCodeMailerInterface
{
private $mailer;

public function __construct(MailerInterface $mailer)
{
$this->mailer = $mailer;
}

public function sendAuthCode(TwoFactorInterface $user): void
{
$authCode = $user->getEmailAuthCode();

$this->mailer->send((new Email())
->from('ne-pas-repondre@histologe.beta.gouv.fr')
->to($user->getEmailAuthRecipient())
->subject('Code de vérification')
->text("Votre code de vérification est : $authCode")
);
}
}
13 changes: 13 additions & 0 deletions symfony.lock
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,19 @@
"ralouphie/getallheaders": {
"version": "3.0.3"
},
"scheb/2fa-bundle": {
"version": "6.12",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.0",
"ref": "1e6f68089146853a790b5da9946fc5974f6fcd49"
},
"files": [
"config/packages/scheb_2fa.yaml",
"config/routes/scheb_2fa.yaml"
]
},
"scienta/doctrine-json-functions": {
"version": "5.0.0"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
{% extends 'base.html.twig' %}

{% block title %}Authentification à deux facteurs{% endblock %}

{% block body %}
<main class="fr-container fr-py-5w">
<section class="fr-grid-row">

<div class="fr-col-md-12">
<h1>Authentification à deux facteurs</h1>
{# Authentication errors #}
{% if authenticationError %}
<div class="fr-alert fr-alert--error fr-alert--sm fr-mb-3w">
<p>{{ authenticationError|trans(authenticationErrorData, 'SchebTwoFactorBundle') }}</p>
</div>
{% endif %}

{# Let the user select the authentication method #}
{% if availableTwoFactorProviders|length > 1 %}
<p>{{ "choose_provider"|trans({}, 'SchebTwoFactorBundle') }}:
{% for provider in availableTwoFactorProviders %}
<a href="{{ path("2fa_login", {"preferProvider": provider}) }}">{{ provider }}</a>
{% endfor %}
</p>
{% endif %}
</div>

<div class="fr-col-md-6">
{# The form to enter the authentication code #}
<form class="form" action="{{ checkPathUrl ? checkPathUrl: path(checkPathRoute) }}" method="post">

<div class="fr-input-group">
<label class="fr-label" for="_auth_code">{{ "auth_code"|trans({}, 'SchebTwoFactorBundle') }} {{ twoFactorProvider }}</label>
<input class="fr-input" id="_auth_code" type="text" name="{{ authCodeParameterName }}" autocomplete="one-time-code" autofocus inputmode="numeric" pattern="[0-9]*"/>
</div>

{% if displayTrustedOption %}
<p class="widget">
<label for="_trusted"><input id="_trusted" type="checkbox" name="{{ trustedParameterName }}"/>
{{ "trusted"|trans({}, 'SchebTwoFactorBundle') }}</label>
</p>
{% endif %}
{% if isCsrfProtectionEnabled %}
<input type="hidden" name="{{ csrfParameterName }}" value="{{ csrf_token(csrfTokenId) }}">
{% endif %}
<div class="fr-form-group">
<button type="submit" class="fr-btn fr-icon-checkbox-circle-fill fr-btn--icon-right">
Connexion
</button>
</div>
{# The logout link gives the user a way out if they can't complete two-factor authentication #}
<div class="fr-form-group fr-mt-5v">
<a href="{{ logoutPath }}">{{ "cancel"|trans({}, 'SchebTwoFactorBundle') }}</a>
</div>
</form>

</div>
</section>
</main>
{% endblock %}
Loading

0 comments on commit 17a1b50

Please sign in to comment.