From 60dfbcf8b3a80e86ff815c99f933a9bc99f44030 Mon Sep 17 00:00:00 2001 From: saukif Date: Fri, 30 Oct 2026 22:27:19 +0530 Subject: [PATCH] Add optional secret decryptor for Google Authenticator and TOTP, see #150 Lets an application store the authenticator secret encrypted. The new secret_decryptor option names a service implementing SecretDecryptorInterface, which the TOTP factories call before using the secret. Without the option, nothing changes. --- doc/configuration.rst | 2 + doc/providers/google.rst | 37 +++++++++++++++++ doc/providers/totp.rst | 37 +++++++++++++++++ .../DependencyInjection/Configuration.php | 2 + .../SchebTwoFactorExtension.php | 8 ++++ .../config/two_factor_provider_google.php | 1 + .../config/two_factor_provider_totp.php | 1 + .../Provider/SecretDecryptorInterface.php | 16 ++++++++ .../Provider/Google/GoogleTotpFactory.php | 6 +++ .../TwoFactor/Provider/Totp/TotpFactory.php | 6 +++ .../SchebTwoFactorExtensionTest.php | 40 +++++++++++++++++++ .../Provider/Google/GoogleTotpFactoryTest.php | 18 +++++++++ .../Provider/Totp/TotpFactoryTest.php | 18 +++++++++ 13 files changed, 192 insertions(+) create mode 100644 src/bundle/Security/TwoFactor/Provider/SecretDecryptorInterface.php diff --git a/doc/configuration.rst b/doc/configuration.rst index 04713a5c..916c79c5 100644 --- a/doc/configuration.rst +++ b/doc/configuration.rst @@ -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: @@ -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. diff --git a/doc/providers/google.rst b/doc/providers/google.rst index 180c2af7..29cbaeb0 100644 --- a/doc/providers/google.rst +++ b/doc/providers/google.rst @@ -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 ----------------------------------- @@ -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 + + 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 + + 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') @@ -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() diff --git a/src/bundle/DependencyInjection/SchebTwoFactorExtension.php b/src/bundle/DependencyInjection/SchebTwoFactorExtension.php index 15fd5c6f..6faa2922 100644 --- a/src/bundle/DependencyInjection/SchebTwoFactorExtension.php +++ b/src/bundle/DependencyInjection/SchebTwoFactorExtension.php @@ -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; } @@ -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; } diff --git a/src/bundle/Resources/config/two_factor_provider_google.php b/src/bundle/Resources/config/two_factor_provider_google.php index 4b1bbd2f..4a656942 100644 --- a/src/bundle/Resources/config/two_factor_provider_google.php +++ b/src/bundle/Resources/config/two_factor_provider_google.php @@ -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) diff --git a/src/bundle/Resources/config/two_factor_provider_totp.php b/src/bundle/Resources/config/two_factor_provider_totp.php index ee702135..7bae8f3e 100644 --- a/src/bundle/Resources/config/two_factor_provider_totp.php +++ b/src/bundle/Resources/config/two_factor_provider_totp.php @@ -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) diff --git a/src/bundle/Security/TwoFactor/Provider/SecretDecryptorInterface.php b/src/bundle/Security/TwoFactor/Provider/SecretDecryptorInterface.php new file mode 100644 index 00000000..1fc18400 --- /dev/null +++ b/src/bundle/Security/TwoFactor/Provider/SecretDecryptorInterface.php @@ -0,0 +1,16 @@ +secretDecryptor) { + $secret = $this->secretDecryptor->decrypt($secret); + } + /** @psalm-suppress ArgumentTypeCoercion */ $totp = TOTP::create($secret, 30, 'sha1', $this->digits, clock: $this->clock); diff --git a/src/totp/Security/TwoFactor/Provider/Totp/TotpFactory.php b/src/totp/Security/TwoFactor/Provider/Totp/TotpFactory.php index fa2645b1..d42486f0 100644 --- a/src/totp/Security/TwoFactor/Provider/Totp/TotpFactory.php +++ b/src/totp/Security/TwoFactor/Provider/Totp/TotpFactory.php @@ -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; /** @@ -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, ) { } @@ -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, diff --git a/tests/DependencyInjection/SchebTwoFactorExtensionTest.php b/tests/DependencyInjection/SchebTwoFactorExtensionTest.php index 97bbfce1..56e9f2bf 100644 --- a/tests/DependencyInjection/SchebTwoFactorExtensionTest.php +++ b/tests/DependencyInjection/SchebTwoFactorExtensionTest.php @@ -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 { @@ -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 { @@ -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: @@ -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(); diff --git a/tests/Security/TwoFactor/Provider/Google/GoogleTotpFactoryTest.php b/tests/Security/TwoFactor/Provider/Google/GoogleTotpFactoryTest.php index 97b4a68f..1be53774 100644 --- a/tests/Security/TwoFactor/Provider/Google/GoogleTotpFactoryTest.php +++ b/tests/Security/TwoFactor/Provider/Google/GoogleTotpFactoryTest.php @@ -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; @@ -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 diff --git a/tests/Security/TwoFactor/Provider/Totp/TotpFactoryTest.php b/tests/Security/TwoFactor/Provider/Totp/TotpFactoryTest.php index e5936530..61898d1e 100644 --- a/tests/Security/TwoFactor/Provider/Totp/TotpFactoryTest.php +++ b/tests/Security/TwoFactor/Provider/Totp/TotpFactoryTest.php @@ -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; @@ -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; @@ -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 $customParameters */