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
2 changes: 2 additions & 0 deletions doc/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ Bundle Configuration
leeway: 0 # Acceptable time drift in seconds, must be less or equal than 30 seconds
template: security/2fa_form.html.twig # Template used to render the authentication form
form_renderer: acme.custom_form_renderer # Use a custom form renderer service
secret_decryptor: acme.secret_decryptor # Service to decrypt a secret code that is stored encrypted

# TOTP authentication config
totp:
Expand All @@ -61,6 +62,7 @@ Bundle Configuration
image: 'https://my-service/img/logo.png'
template: security/2fa_form.html.twig # Template used to render the authentication form
form_renderer: acme.custom_form_renderer # Use a custom form renderer service
secret_decryptor: acme.secret_decryptor # Service to decrypt a secret code that is stored encrypted

# The service which is used to persist data in the user object. By default Doctrine is used. If your entity is
# managed by something else (e.g. an API), you have to implement a custom persister.
Expand Down
37 changes: 37 additions & 0 deletions doc/providers/google.rst
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ Configuration Reference
digits: 6 # Number of digits in authentication code
leeway: 0 # Acceptable time drift in seconds, must be less or equal than 30 seconds
template: security/2fa_form.html.twig # Template used to render the authentication form
secret_decryptor: acme.secret_decryptor # Service to decrypt a secret code that is stored encrypted

Custom Authentication Form Template
-----------------------------------
Expand Down Expand Up @@ -208,6 +209,42 @@ is also possible.

$secret = $container->get("scheb_two_factor.security.google_authenticator")->generateSecret();

Storing the Secret Code Encrypted
---------------------------------

By default, the bundle expects the secret code in the user entity as plain text. If you want to store it encrypted,
encrypt the secret yourself before you persist it. Then tell the bundle how to decrypt it.

Create a class implementing ``Scheb\TwoFactorBundle\Security\TwoFactor\Provider\SecretDecryptorInterface``:

.. code-block:: php

<?php

namespace Acme\Demo\Security;

use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\SecretDecryptorInterface;

class MySecretDecryptor implements SecretDecryptorInterface
{
public function decrypt(string $secret): string
{
// Return the plain Base32 secret
}
}

Then register it as a service and update your configuration:

.. code-block:: yaml

# config/packages/scheb_2fa.yaml
scheb_two_factor:
google:
secret_decryptor: acme.secret_decryptor

The bundle decrypts the secret every time it checks a code or creates the QR code content. ``generateSecret()`` still
returns a plain secret, so encrypt it before you store it.

QR Codes
--------

Expand Down
37 changes: 37 additions & 0 deletions doc/providers/totp.rst
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ Configuration Options
parameters: # Additional parameters added in the QR code
image: 'https://my-service/img/logo.png'
template: security/2fa_form.html.twig # Template used to render the authentication form
secret_decryptor: acme.secret_decryptor # Service to decrypt a secret code that is stored encrypted

Additional parameter
--------------------
Expand Down Expand Up @@ -228,6 +229,42 @@ also possible.

$secret = $container->get("scheb_two_factor.security.totp_authenticator")->generateSecret();

Storing the Secret Code Encrypted
---------------------------------

By default, the bundle expects the secret code in the user entity as plain text. If you want to store it encrypted,
encrypt the secret yourself before you persist it. Then tell the bundle how to decrypt it.

Create a class implementing ``Scheb\TwoFactorBundle\Security\TwoFactor\Provider\SecretDecryptorInterface``:

.. code-block:: php

<?php

namespace Acme\Demo\Security;

use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\SecretDecryptorInterface;

class MySecretDecryptor implements SecretDecryptorInterface
{
public function decrypt(string $secret): string
{
// Return the plain Base32 secret
}
}

Then register it as a service and update your configuration:

.. code-block:: yaml

# config/packages/scheb_2fa.yaml
scheb_two_factor:
totp:
secret_decryptor: acme.secret_decryptor

The bundle decrypts the secret every time it checks a code or creates the QR code content. ``generateSecret()`` still
returns a plain secret, so encrypt it before you store it.

QR Codes
--------

Expand Down
2 changes: 2 additions & 0 deletions src/bundle/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ private function addTotpConfiguration(ArrayNodeDefinition $rootNode): void
->scalarNode('enabled')->defaultValue(false)->end()
->scalarNode('form_renderer')->defaultNull()->end()
->scalarNode('issuer')->defaultNull()->end()
->scalarNode('secret_decryptor')->defaultNull()->end()
->scalarNode('server_name')->defaultNull()->end()
->integerNode('leeway')->defaultValue(0)->min(0)->end()
->arrayNode('parameters')
Expand Down Expand Up @@ -214,6 +215,7 @@ private function addGoogleAuthenticatorConfiguration(ArrayNodeDefinition $rootNo
->scalarNode('enabled')->defaultValue(false)->end()
->scalarNode('form_renderer')->defaultNull()->end()
->scalarNode('issuer')->defaultNull()->end()
->scalarNode('secret_decryptor')->defaultNull()->end()
->scalarNode('server_name')->defaultNull()->end()
->scalarNode('template')->defaultValue('@SchebTwoFactor/Authentication/form.html.twig')->end()
->integerNode('digits')->defaultValue(6)->min(1)->end()
Expand Down
8 changes: 8 additions & 0 deletions src/bundle/DependencyInjection/SchebTwoFactorExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,10 @@ private function configureGoogleAuthenticationProvider(ContainerBuilder $contain
$container->setParameter('scheb_two_factor.google.digits', $config['google']['digits']);
$container->setParameter('scheb_two_factor.google.leeway', $config['google']['leeway']);

if (null !== $config['google']['secret_decryptor']) {
$container->setAlias('scheb_two_factor.security.google.secret_decryptor', $config['google']['secret_decryptor']);
}

if (null === $config['google']['form_renderer']) {
return;
}
Expand All @@ -228,6 +232,10 @@ private function configureTotpAuthenticationProvider(ContainerBuilder $container
$container->setParameter('scheb_two_factor.totp.template', $config['totp']['template']);
$container->setParameter('scheb_two_factor.totp.leeway', $config['totp']['leeway']);

if (null !== $config['totp']['secret_decryptor']) {
$container->setAlias('scheb_two_factor.security.totp.secret_decryptor', $config['totp']['secret_decryptor']);
}

if (null === $config['totp']['form_renderer']) {
return;
}
Expand Down
1 change: 1 addition & 0 deletions src/bundle/Resources/config/two_factor_provider_google.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
'%scheb_two_factor.google.issuer%',
'%scheb_two_factor.google.digits%',
new ReferenceConfigurator('clock')->nullOnInvalid(),
service('scheb_two_factor.security.google.secret_decryptor')->nullOnInvalid(),
])

->set('scheb_two_factor.security.google_authenticator', GoogleAuthenticator::class)
Expand Down
1 change: 1 addition & 0 deletions src/bundle/Resources/config/two_factor_provider_totp.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
'%scheb_two_factor.totp.issuer%',
'%scheb_two_factor.totp.parameters%',
new ReferenceConfigurator('clock')->nullOnInvalid(),
service('scheb_two_factor.security.totp.secret_decryptor')->nullOnInvalid(),
])

->set('scheb_two_factor.security.totp_authenticator', TotpAuthenticator::class)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace Scheb\TwoFactorBundle\Security\TwoFactor\Provider;

/**
* Decrypts a TOTP secret that the application stores encrypted.
*/
interface SecretDecryptorInterface
{
/**
* Return the Base32 encoded secret from the encrypted value that is stored with the user.
*/
public function decrypt(string $secret): string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Psr\Clock\ClockInterface;
use Scheb\TwoFactorBundle\Model\Google\TwoFactorInterface;
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Exception\TwoFactorProviderLogicException;
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\SecretDecryptorInterface;
use function strlen;

/**
Expand All @@ -21,6 +22,7 @@ public function __construct(
private readonly string|null $issuer,
private readonly int $digits,
private readonly ClockInterface|null $clock = null,
private readonly SecretDecryptorInterface|null $secretDecryptor = null,
) {
}

Expand All @@ -31,6 +33,10 @@ public function createTotpForUser(TwoFactorInterface $user): TOTPInterface
throw new TwoFactorProviderLogicException('Cannot initialize TOTP, no secret code provided.');
}

if (null !== $this->secretDecryptor) {
$secret = $this->secretDecryptor->decrypt($secret);
}

/** @psalm-suppress ArgumentTypeCoercion */
$totp = TOTP::create($secret, 30, 'sha1', $this->digits, clock: $this->clock);

Expand Down
6 changes: 6 additions & 0 deletions src/totp/Security/TwoFactor/Provider/Totp/TotpFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Psr\Clock\ClockInterface;
use Scheb\TwoFactorBundle\Model\Totp\TwoFactorInterface;
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Exception\TwoFactorProviderLogicException;
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\SecretDecryptorInterface;
use function strlen;

/**
Expand All @@ -24,6 +25,7 @@ public function __construct(
private readonly string|null $issuer,
private readonly array $customParameters,
private readonly ClockInterface|null $clock = null,
private readonly SecretDecryptorInterface|null $secretDecryptor = null,
) {
}

Expand All @@ -39,6 +41,10 @@ public function createTotpForUser(TwoFactorInterface $user): TOTPInterface
throw new TwoFactorProviderLogicException('Cannot initialize TOTP, no secret code provided.');
}

if (null !== $this->secretDecryptor) {
$secret = $this->secretDecryptor->decrypt($secret);
}

/** @psalm-suppress ArgumentTypeCoercion */
$totp = TOTP::create(
$secret,
Expand Down
40 changes: 40 additions & 0 deletions tests/DependencyInjection/SchebTwoFactorExtensionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,25 @@ public function load_customGoogleAuthFormRenderer_hasCustomAlias(): void
$this->assertHasAlias('scheb_two_factor.security.google.form_renderer', 'acme_test.google_form_renderer');
}

#[Test]
public function load_noGoogleAuthSecretDecryptor_hasNoSecretDecryptorAlias(): void
{
$config = $this->getEmptyConfig();
$config['google']['enabled'] = true;
$this->extension->load([$config], $this->container);

$this->assertNotHasAlias('scheb_two_factor.security.google.secret_decryptor');
}

#[Test]
public function load_customGoogleAuthSecretDecryptor_hasCustomAlias(): void
{
$config = $this->getFullConfig();
$this->extension->load([$config], $this->container);

$this->assertHasAlias('scheb_two_factor.security.google.secret_decryptor', 'acme_test.google_secret_decryptor');
}

#[Test]
public function load_totpAuthEnabled_loadTotpFactoryServices(): void
{
Expand Down Expand Up @@ -259,6 +278,25 @@ public function load_customTotpFormRenderer_hasCustomAlias(): void
$this->assertHasAlias('scheb_two_factor.security.totp.form_renderer', 'acme_test.totp_form_renderer');
}

#[Test]
public function load_noTotpSecretDecryptor_hasNoSecretDecryptorAlias(): void
{
$config = $this->getEmptyConfig();
$config['totp']['enabled'] = true;
$this->extension->load([$config], $this->container);

$this->assertNotHasAlias('scheb_two_factor.security.totp.secret_decryptor');
}

#[Test]
public function load_customTotpSecretDecryptor_hasCustomAlias(): void
{
$config = $this->getFullConfig();
$this->extension->load([$config], $this->container);

$this->assertHasAlias('scheb_two_factor.security.totp.secret_decryptor', 'acme_test.totp_secret_decryptor');
}

#[Test]
public function load_emailAuthEnabled_loadEmailServices(): void
{
Expand Down Expand Up @@ -602,6 +640,7 @@ private function getFullConfig(): array
server_name: Server Name Google
template: AcmeTestBundle:Authentication:googleForm.html.twig
form_renderer: acme_test.google_form_renderer
secret_decryptor: acme_test.google_secret_decryptor
digits: 8
leeway: 20
totp:
Expand All @@ -613,6 +652,7 @@ private function getFullConfig(): array
image: http://foo/bar.png
template: AcmeTestBundle:Authentication:totpForm.html.twig
form_renderer: acme_test.totp_form_renderer
secret_decryptor: acme_test.totp_secret_decryptor
EOF;
$parser = new Parser();

Expand Down
18 changes: 18 additions & 0 deletions tests/Security/TwoFactor/Provider/Google/GoogleTotpFactoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@
use Scheb\TwoFactorBundle\Model\Google\TwoFactorInterface;
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Exception\TwoFactorProviderLogicException;
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Google\GoogleTotpFactory;
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\SecretDecryptorInterface;
use Scheb\TwoFactorBundle\Tests\TestCase;

class GoogleTotpFactoryTest extends TestCase
{
private const string USER_NAME = 'User Name';
private const string SECRET = 'SECRET';
private const string DECRYPTED_SECRET = 'DECRYPTEDSECRET';
private const string ISSUER = 'Issuer Name';
private const string SERVER = 'Server';
private const int CUSTOM_DIGITS = 8;
Expand Down Expand Up @@ -68,6 +70,22 @@ public function createTotpForUser_factoryCalled_returnTotpObject(): void
$this->assertEquals(self::SECRET, $returnValue->getSecret());
}

#[Test]
public function createTotpForUser_secretDecryptorGiven_returnTotpWithDecryptedSecret(): void
{
$user = $this->createUserMock();
$secretDecryptor = $this->createMock(SecretDecryptorInterface::class);
$secretDecryptor
->expects($this->once())
->method('decrypt')
->with(self::SECRET)
->willReturn(self::DECRYPTED_SECRET);

$returnValue = (new GoogleTotpFactory(self::SERVER, self::ISSUER, self::CUSTOM_DIGITS, secretDecryptor: $secretDecryptor))->createTotpForUser($user);

$this->assertEquals(self::DECRYPTED_SECRET, $returnValue->getSecret());
}

#[Test]
#[DataProvider('provideHostnameAndIssuer')]
public function getProvisioningUri_hostnameAndIssuerGiven_returnProvisioningUri(string|null $username, string|null $hostname, string|null $issuer, int $digits, string $expectedUrl): void
Expand Down
18 changes: 18 additions & 0 deletions tests/Security/TwoFactor/Provider/Totp/TotpFactoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use Scheb\TwoFactorBundle\Model\Totp\TotpConfiguration;
use Scheb\TwoFactorBundle\Model\Totp\TwoFactorInterface;
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Exception\TwoFactorProviderLogicException;
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\SecretDecryptorInterface;
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Totp\TotpFactory;
use Scheb\TwoFactorBundle\Tests\TestCase;

Expand All @@ -25,6 +26,7 @@ class TotpFactoryTest extends TestCase

private const string USER_NAME = 'User Name';
private const string SECRET = 'SECRET';
private const string DECRYPTED_SECRET = 'DECRYPTEDSECRET';
private const int PERIOD = 20;
private const int DIGITS = 8;
private const string ALGORITHM = TotpConfiguration::ALGORITHM_SHA256;
Expand Down Expand Up @@ -81,6 +83,22 @@ public function createTotpForUser_factoryCalled_returnTotpObject(): void
$this->assertEquals(self::CUSTOM_PARAMETER_VALUE, $returnValue->getParameter(self::CUSTOM_PARAMETER_NAME));
}

#[Test]
public function createTotpForUser_secretDecryptorGiven_returnTotpWithDecryptedSecret(): void
{
$user = $this->createUserMock();
$secretDecryptor = $this->createMock(SecretDecryptorInterface::class);
$secretDecryptor
->expects($this->once())
->method('decrypt')
->with(self::SECRET)
->willReturn(self::DECRYPTED_SECRET);

$returnValue = (new TotpFactory(self::SERVER, self::ISSUER, self::CUSTOM_PARAMETERS, secretDecryptor: $secretDecryptor))->createTotpForUser($user);

$this->assertEquals(self::DECRYPTED_SECRET, $returnValue->getSecret());
}

/**
* @param array<mixed> $customParameters
*/
Expand Down
Loading