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
1 change: 1 addition & 0 deletions src/bundle/Resources/config/security.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
abstract_arg('Authentication required handler'),
service('event_dispatcher'),
service('logger')->nullOnInvalid(),
service('scheb_two_factor.provider_registry'),
])

->set('scheb_two_factor.security.authentication.trust_resolver', AuthenticationTrustResolver::class)
Expand Down
35 changes: 35 additions & 0 deletions src/bundle/Security/Authentication/AuthenticationTrustResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Scheb\TwoFactorBundle\Security\Authentication\Token\TwoFactorTokenInterface;
use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolverInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use function method_exists;

/**
* @final
Expand All @@ -32,6 +33,40 @@ public function isAuthenticated(TokenInterface|null $token = null): bool
return $this->decoratedTrustResolver->isAuthenticated($token);
}

/**
* Declared on the interface since Symfony 8.2, where not implementing it is deprecated.
*/
public function isAuthenticatedRecently(TokenInterface|null $token = null): bool
{
return $this->isAuthenticatedRecentlyEnough(__FUNCTION__, $token);
}

/**
* Declared on the interface since Symfony 8.2, where not implementing it is deprecated.
*/
public function isAuthenticatedVeryRecently(TokenInterface|null $token = null): bool
{
return $this->isAuthenticatedRecentlyEnough(__FUNCTION__, $token);
}

private function isAuthenticatedRecentlyEnough(string $method, TokenInterface|null $token): bool
{
// A pending two-factor authentication is no proof of anything yet
if ($this->isTwoFactorToken($token)) {
return false;
}

// The decorated resolver only has the method on Symfony 8.2+
if (!method_exists($this->decoratedTrustResolver, $method)) {
return false;
}

/** @psalm-suppress MixedAssignment, MixedMethodCall */
$result = $this->decoratedTrustResolver->$method($token);

return true === $result;
}

private function isTwoFactorToken(TokenInterface|null $token): bool
{
return $token instanceof TwoFactorTokenInterface;
Expand Down
31 changes: 31 additions & 0 deletions src/bundle/Security/Authentication/Token/TwoFactorToken.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use function array_search;
use function array_unshift;
use function count;
use function method_exists;
use function reset;
use function sprintf;

Expand Down Expand Up @@ -73,6 +74,36 @@ public function getRoleNames(): array
return [];
}

/**
* Symfony 8.2 records on the token which authentication methods were proven and when.
* The proofs belong to the token that is authenticated once 2fa completes, so they are
* delegated to it: the first factor is recorded while this token is the current one.
*
* @return array<string, int>
*/
public function getAuthenticationProofs(): array
{
if (!method_exists($this->authenticatedToken, 'getAuthenticationProofs')) {
return [];
}

/** @psalm-suppress MixedAssignment, MixedMethodCall */
return $this->authenticatedToken->getAuthenticationProofs();
}

/**
* @param array<string, int> $proofs
*/
public function setAuthenticationProofs(array $proofs): void
{
if (!method_exists($this->authenticatedToken, 'setAuthenticationProofs')) {
return;
}

/** @psalm-suppress MixedMethodCall */
$this->authenticatedToken->setAuthenticationProofs($proofs);
}

public function createWithCredentials(string $credentials): TwoFactorTokenInterface
{
$credentialsToken = new self($this->authenticatedToken, $credentials, $this->firewallName, $this->twoFactorProviders);
Expand Down
24 changes: 24 additions & 0 deletions src/bundle/Security/Http/Authenticator/TwoFactorAuthenticator.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
use Scheb\TwoFactorBundle\Security\Http\Authenticator\Passport\Credentials\TwoFactorCodeCredentials;
use Scheb\TwoFactorBundle\Security\TwoFactor\Event\TwoFactorAuthenticationEvent;
use Scheb\TwoFactorBundle\Security\TwoFactor\Event\TwoFactorAuthenticationEvents;
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\AuthenticationMethodProviderInterface;
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\TwoFactorProviderRegistry;
use Scheb\TwoFactorBundle\Security\TwoFactor\TwoFactorFirewallConfig;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
Expand All @@ -24,6 +26,7 @@
use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface;
use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface;
use Symfony\Component\Security\Http\Authenticator\InteractiveAuthenticatorInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\AuthenticationMethodBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\RememberMeBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
Expand Down Expand Up @@ -54,6 +57,7 @@ public function __construct(
private readonly AuthenticationRequiredHandlerInterface $authenticationRequiredHandler,
private readonly EventDispatcherInterface $eventDispatcher,
LoggerInterface|null $logger = null,
private readonly TwoFactorProviderRegistry|null $providerRegistry = null,
) {
$this->logger = $logger ?? new NullLogger();
}
Expand Down Expand Up @@ -102,9 +106,29 @@ public function authenticate(Request $request): Passport
$passport->addBadge(new TrustedDeviceBadge());
}

// Symfony 8.2 records on the token which authentication methods were proven, from that badge
$authenticationMethod = $this->getAuthenticationMethod($currentToken);
/** @psalm-suppress UndefinedClass */
if (null !== $authenticationMethod && class_exists(AuthenticationMethodBadge::class)) {
/** @psalm-suppress UndefinedClass, InvalidArgument, MixedMethodCall, MixedArgument */
$passport->addBadge(new AuthenticationMethodBadge($authenticationMethod));
}

return $passport;
}

private function getAuthenticationMethod(TwoFactorTokenInterface $token): string|null
{
$providerName = $token->getCurrentTwoFactorProvider();
if (null === $providerName || null === $this->providerRegistry) {
return null;
}

$provider = $this->providerRegistry->getProvider($providerName);

return $provider instanceof AuthenticationMethodProviderInterface ? $provider->getAuthenticationMethod() : null;
}

private function shouldSetTrustedDevice(Request $request, Passport $passport): bool
{
return $this->twoFactorFirewallConfig->hasTrustedDeviceParameterInRequest($request)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace Scheb\TwoFactorBundle\Security\TwoFactor\Provider;

/**
* A two-factor provider that can name the authentication method it verifies.
*
* Symfony 8.2 records on the security token which methods the user proved and when, as the "amr"
* values of RFC 8176 (see Symfony's AuthenticationMethod constants), so that a trust resolver
* can require a specific one. A provider implementing this interface has its proof recorded
* under that method instead of an unspecified one.
*/
interface AuthenticationMethodProviderInterface
{
/**
* Returns an "amr" value of RFC 8176, such as "otp" for a one-time password.
*/
public function getAuthenticationMethod(): string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Scheb\TwoFactorBundle\Security\TwoFactor\AuthenticationContextInterface;
use Scheb\TwoFactorBundle\Security\TwoFactor\Event\EmailCodeEvents;
use Scheb\TwoFactorBundle\Security\TwoFactor\Event\TwoFactorCodeEvent;
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\AuthenticationMethodProviderInterface;
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Email\Generator\CodeGeneratorInterface;
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\TwoFactorFormRendererInterface;
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\TwoFactorProviderInterface;
Expand All @@ -18,7 +19,7 @@
/**
* @final
*/
class EmailTwoFactorProvider implements TwoFactorProviderInterface
class EmailTwoFactorProvider implements TwoFactorProviderInterface, AuthenticationMethodProviderInterface
{
public function __construct(
private readonly CodeGeneratorInterface $codeGenerator,
Expand Down Expand Up @@ -78,4 +79,10 @@ public function getFormRenderer(): TwoFactorFormRendererInterface
{
return $this->formRenderer;
}

public function getAuthenticationMethod(): string
{
// AuthenticationMethod::ONE_TIME_PASSWORD of Symfony 8.2, which cannot be referenced on older versions
return 'otp';
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Scheb\TwoFactorBundle\Model\Google\TwoFactorInterface;
use Scheb\TwoFactorBundle\Security\TwoFactor\AuthenticationContextInterface;
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\AuthenticationMethodProviderInterface;
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Exception\TwoFactorProviderLogicException;
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\TwoFactorFormRendererInterface;
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\TwoFactorProviderInterface;
Expand All @@ -14,7 +15,7 @@
/**
* @final
*/
class GoogleAuthenticatorTwoFactorProvider implements TwoFactorProviderInterface
class GoogleAuthenticatorTwoFactorProvider implements TwoFactorProviderInterface, AuthenticationMethodProviderInterface
{
public function __construct(
private readonly GoogleAuthenticatorInterface $authenticator,
Expand Down Expand Up @@ -60,4 +61,10 @@ public function getFormRenderer(): TwoFactorFormRendererInterface
{
return $this->formRenderer;
}

public function getAuthenticationMethod(): string
{
// AuthenticationMethod::ONE_TIME_PASSWORD of Symfony 8.2, which cannot be referenced on older versions
return 'otp';
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Scheb\TwoFactorBundle\Model\Totp\TwoFactorInterface;
use Scheb\TwoFactorBundle\Security\TwoFactor\AuthenticationContextInterface;
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\AuthenticationMethodProviderInterface;
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Exception\TwoFactorProviderLogicException;
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\TwoFactorFormRendererInterface;
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\TwoFactorProviderInterface;
Expand All @@ -14,7 +15,7 @@
/**
* @final
*/
class TotpAuthenticatorTwoFactorProvider implements TwoFactorProviderInterface
class TotpAuthenticatorTwoFactorProvider implements TwoFactorProviderInterface, AuthenticationMethodProviderInterface
{
public function __construct(
private readonly TotpAuthenticatorInterface $authenticator,
Expand Down Expand Up @@ -64,4 +65,10 @@ public function getFormRenderer(): TwoFactorFormRendererInterface
{
return $this->formRenderer;
}

public function getAuthenticationMethod(): string
{
// AuthenticationMethod::ONE_TIME_PASSWORD of Symfony 8.2, which cannot be referenced on older versions
return 'otp';
}
}
50 changes: 50 additions & 0 deletions tests/Security/Authentication/AuthenticationTrustResolverTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,56 @@ public function isRememberMe_tokenGiven_returnResultFromDecoratedTrustResolver(b
$this->assertEquals($returnedResult, $returnValue);
}

/**
* @return array<array<string>>
*/
public static function provideRecencyMethods(): array
{
return [
['isAuthenticatedRecently'],
['isAuthenticatedVeryRecently'],
];
}

#[Test]
#[DataProvider('provideRecencyMethods')]
public function isAuthenticatedRecently_twoFactorToken_returnFalse(string $method): void
{
$decoratedTrustResolver = new RecencyAwareTrustResolver();
$decoratedTrustResolver->result = true;
$trustResolver = new AuthenticationTrustResolver($decoratedTrustResolver);

$returnValue = $trustResolver->$method($this->createMock(TwoFactorTokenInterface::class));
$this->assertFalse($returnValue);
$this->assertNull($decoratedTrustResolver->calledMethod);
}

#[Test]
#[DataProvider('provideRecencyMethods')]
public function isAuthenticatedRecently_decoratedTrustResolverWithoutTheMethod_returnFalse(string $method): void
{
// The method exists on the interface since Symfony 8.2 only
$this->decoratedTrustResolver
->expects($this->never())
->method($this->anything());

$returnValue = $this->trustResolver->$method($this->createMock(TokenInterface::class));
$this->assertFalse($returnValue);
}

#[Test]
#[DataProvider('provideRecencyMethods')]
public function isAuthenticatedRecently_notTwoFactorToken_returnResultFromDecoratedTrustResolver(string $method): void
{
$decoratedTrustResolver = new RecencyAwareTrustResolver();
$decoratedTrustResolver->result = true;
$trustResolver = new AuthenticationTrustResolver($decoratedTrustResolver);

$returnValue = $trustResolver->$method($this->createMock(TokenInterface::class));
$this->assertTrue($returnValue);
$this->assertEquals($method, $decoratedTrustResolver->calledMethod);
}

#[Test]
public function isFullFledged_twoFactorToken_returnFalse(): void
{
Expand Down
46 changes: 46 additions & 0 deletions tests/Security/Authentication/RecencyAwareTrustResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

declare(strict_types=1);

namespace Scheb\TwoFactorBundle\Tests\Security\Authentication;

use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolverInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;

/**
* A trust resolver of Symfony 8.2, which declares both methods on its interface.
*/
class RecencyAwareTrustResolver implements AuthenticationTrustResolverInterface
{
public bool $result = false;
public string|null $calledMethod = null;

public function isAuthenticated(TokenInterface|null $token = null): bool
{
return false;
}

public function isRememberMe(TokenInterface|null $token = null): bool
{
return false;
}

public function isFullFledged(TokenInterface|null $token = null): bool
{
return false;
}

public function isAuthenticatedRecently(TokenInterface|null $token = null): bool
{
$this->calledMethod = __FUNCTION__;

return $this->result;
}

public function isAuthenticatedVeryRecently(TokenInterface|null $token = null): bool
{
$this->calledMethod = __FUNCTION__;

return $this->result;
}
}
28 changes: 28 additions & 0 deletions tests/Security/Authentication/Token/ProofsAwareToken.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace Scheb\TwoFactorBundle\Tests\Security\Authentication\Token;

use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;

/**
* A token of Symfony 8.2, where these methods are declared on the interface.
*/
class ProofsAwareToken extends UsernamePasswordToken
{
/** @var array<string, int> */
private array $proofs = [];

/** @return array<string, int> */
public function getAuthenticationProofs(): array
{
return $this->proofs;
}

/** @param array<string, int> $proofs */
public function setAuthenticationProofs(array $proofs): void
{
$this->proofs = $proofs;
}
}
Loading
Loading